Repository navigation
Description
id(web): collab handleMessage trusts MessagePack frame shape — a null frame throws out of onmessage, and a field-less INIT silently initializes an empty editor at an undefined version
Repo: developing-today/code · Main: df9381ff · File: pkgs/id/web/src/collab.ts — handleMessage at lines 198-206, INIT case at 210-265
Summary
The collab WebSocket handler guards only the MessagePack decode step:
try {
msg = unpackr.unpack(new Uint8Array(data)) as unknown[];
} catch (err) {
console.error("[collab] Failed to decode MessagePack message:", err);
return;
}
const msgType = msg[0] as number; // :206 — outside the try, shape never checkedTwo verified failure modes for frames that decode fine but are not the expected array shape:
- A
nullframe throws out of the handler. MessagePack0xc0unpacks tonull;msg[0]then throwsTypeError: Cannot read properties of null (reading '0')atcollab.ts:206, escapingonmessageas an uncaught error. Any non-array unpack result that cannot be indexed the same way is one peer bug away from the same path; the decode-error path right above shows the intended behaviour (log + drop), the shape path just never got it. - A field-less INIT is silently accepted as an empty document.
case MSG.INITreadsversion = msg[1],doc = msg[2],mode = msg[3] || "raw"with no presence/type check (:212-214) and callsinitEditor(container, doc, version, mode, …)(:221).initEditor(editor.ts) treats a missing/non-object doc as "create an empty document" — so a truncated or buggy INIT presents the user with a blank editor for a document that is not blank, running the ProseMirror collab plugin at versionundefined, and firesonEditorReady(which enables the save button and wires AutoSaveManager inmain.ts). Nothing logs an error, no resync is requested, and the status reads "connected". Code-read consequence: an edit + save from that phantom editor posts the near-empty doc to/api/saveunder the real file's hash — the server archives the previous version, but the working document has been replaced by the blank one the client fabricated.
Evidence (verified this run, real module)
Laptop probe (~/git/fleet/probe-2026-10-30-laneA-collabframe/; collab.ts md5 fff3e0f4bdf0165f40a56f683fa03434 identical on sandbox main df9381ff and the laptop; vitest + happy-dom, FakeSocket global + the real initCollab, frames packed with the repo's own msgpackr):
| Frame delivered | Result |
|---|---|
null (msgpack 0xc0) |
TypeError: Cannot read properties of null (reading '0') thrown out of onmessage |
42, "hello", {}, [999, 1] (controls) |
no throw — dropped / unknown-type warning, as expected |
[0] — INIT, no version/doc/mode |
no throw, but onEditorReady fired, editor initialized, mode raw — an empty editor built from an absent doc at an undefined version |
[0, 0, {doc…code_block "hi"}, "raw"] (well-formed control) |
no throw, editor initialized with the real doc — well-formed handling unchanged |
[4] — CURSOR with no fields, after valid INIT |
no throw (cursor plugin tolerates it — checked, not part of this filing) |
[2, "nope", [123]] — UPDATE with string steps |
no throw out of the handler; the existing step-apply catch closes with 4001 and reconnects (the resync behaviour INIT should arguably share — noted, not part of this filing) |
Why it matters
Collab frames originate from the serving node / peer, not from this client — the same untrusted-shape boundary the codebase already defends for world frames (#2802) and shell frames (shell-contract.ts). The throw is the loud half; the silent half is worse: a user whose INIT arrives truncated (or who connects to a buggy/experimental server build) is shown an empty document, told they are connected, and given a working save path over the real document's hash. "My file went blank" with no error anywhere is the hardest class of report to attribute after the fact.
Acceptance criteria
- A frame whose unpacked value is not an array (including
null) is dropped with a logged error and does not throw out ofhandleMessage/onmessage; a subsequent well-formed INIT on the same connection still initializes the editor. - An INIT frame without a numeric version or without a document object is rejected — logged, and handled as an invalid/desynced state (error status and/or the resync-reconnect the UPDATE path already uses) — and never initializes an editor from an absent doc or an
undefinedversion;onEditorReadydoes not fire for it. - Regression tests in a new
pkgs/id/web/src/collab.test.ts(FakeSocket harness in the style ofworld-records.test.ts) pin: thenullframe, the field-less INIT, an INIT with a non-object doc, and the well-formed INIT control; existing web tests still pass (bunx vitest runinpkgs/id/web). - Well-formed frames of every existing type (INIT/UPDATE/CURSOR/ACK/ERROR/CURSOR_REMOVE/NEW_VERSION/AUTH_OK) behave byte-for-byte as today.
Dedup notes: searched open + closed issues this run for collab frame shape / handleMessage / INIT — no hit. Distinct from #2802 (the world page's JSON receive() in world.ts; different protocol, handler and consequences), #1618 (server-side step validation), #1463/#1368 (collab reconnect/token behaviour), and closed #1783 (the Rust codec's tag-6 decode).
Activity
dezren39 commentedon Oct 30, 2026
🤖 Agent note · filed
dezren39 commentedon Oct 31, 2026
🤖 Design-architect pass 26 (2026-10-31) — agent note: this issue is adopted as part of Phase 3 of design #3068 ("parsed is not validated"), alongside the new closing-audit issue #3069. Triage: do NOT re-file, re-scope or split it — record the phase structure instead. Unlike the audit issue, this frame work is NOT gated on #3068's Phases 1–2. The design records the rule this phase lands under: frames are shape-checked past the type tag at the boundary; a wrong-shaped frame is dropped and logged, never thrown out of the handler, never half-applied, never silently initialised from. Details on #3068 and in files/designs/code-parsed-is-not-validated.md (goal workspace).
dezren39 commentedon Nov 1, 2026
🤖 Agent note · picked up
53 remaining items
dezren39 commentedon Nov 2, 2026
agent-note:v1
dezren39 commentedon Nov 2, 2026
🤖 Agent note · picked up (ownership correction)
queue/5430-2824-validate-collab-frames, body "Closes id(web): collab handleMessage trusts MessagePack frame shape — a null frame throws out of onmessage, and a field-less INIT silently initializes an empty editor at an undefined version #2824", worktree~/git/fleet/5430. Noheld/user/owned/do-not-merge/do-not-auto-editlabel on id(web): collab handleMessage trusts MessagePack frame shape — a null frame throws out of onmessage, and a field-less INIT silently initializes an empty editor at an undefined version #2824 or PR fix(id-web): validate collaboration MessagePack frames #4250 (verified fresh).bun run test src/collab.test.ts5 passed; full suite's 4 failures reproduce on clean main (pre-existing); typecheck error likewise pre-existing on main.dezren39 commentedon Nov 2, 2026
🤖 Agent note · done