Skip to content

id(web): collab server stores and broadcasts steps it never validates — one bad step poisons the document's history and loops every client #1618

Open

Description

@dezren39

Summary

The collab server accepts, stores, broadcasts and Acks ProseMirror steps without ever checking that they parse or apply — one invalid step poisons a document session for every current and future client.

In pkgs/id/src/web/collab.rs a step is an opaque blob end to end:

  • Step is a flattened serde_json::Value (collab.rs:164-169) — there is no parse or validation constructor.
  • CollabMessage::decode (collab.rs:536-545) accepts a Steps frame as (u8, u64, Vec<serde_json::Value>, u64): any JSON values at all count as "steps".
  • The Steps handler (collab.rs:986-1052) checks only that the frame's version equals the current version, then pushes every step into doc.steps (:1019-1024), bumps the version by steps.len() (:1028), broadcasts an Update to the other clients (:1032-1036) and Acks the sender (:1044-1052). It never applies the steps to a document and never asks whether they are well-formed.
  • On join, the catch-up path (collab.rs:787-810) replays all stored steps to the new client, so a stored bad step is re-delivered forever, for the life of the Document (cleanup only runs an hour after the last client disconnects).

Auth is not a gate: a client that sends no AUTH frame in 5 seconds "proceeds as anonymous" (collab.rs:694-755) and can send Steps. A merely buggy or version-skewed client reaches the same place without malice — e.g. a step built against a diverged local doc, or a node type an older/newer web build's schema does not share.

Evidence (verified on main b2adaf47, 2026-10-28)

collab.rs sha256 55e04270… and collab.ts sha256 0fb75f70…, identical in the sandbox and laptop clones. Two probes ON THE LAPTOP (~/git/fleet/probe-2026-10-28-laneA-collabsteps/; sources + logs in the goal's hidden_files/probe-2026-10-28-laneA-collabsteps/):

Rust verbatim-decode probe (the exact encode/decode tuple shapes from collab.rs:479-482 / :536-545):

CTRL one text step:       decode -> Ok(tag=1, version=3, steps=1, client_id=4242)
BOGUS stepType:           decode -> Ok(...)   {"stepType":"bogusType","from":1,"to":2}
RANGE replace to=999999:  decode -> Ok(...)
NON-OBJECT steps (42, null, "hi"): decode -> Ok(steps=3)

TS probe driving the real client receive path (Step.fromJSON + receiveTransaction from prosemirror-transform / prosemirror-collab, as collab.ts:278-279 calls them; basic schema standing in for the mode-aware editor schema):

CTRL_valid:    fromJSON OK, apply ok, receiveTransaction OK ("hello world!")
BOGUS_type:    Step.fromJSON -> THROW "No step type bogusType defined"
RANGE_replace: Step.fromJSON OK, Step.apply -> THROW "Position 999999 out of range",
               receiveTransaction -> THROW
SCHEMA_slice:  Step.fromJSON -> THROW "Unknown node type: no_such_node"

The receiving client's only handling for that throw is collab.ts:281-293: log "Failed to apply steps", close the socket with code 4001 and scheduleReconnect() — which rejoins, receives Init + the same catch-up steps, and throws again. One stored step therefore puts every client of that document into a reconnect loop and makes the document uneditable until the session's Document is cleaned up.

Why it matters

The server is the authority for the shared step history — clients trust its Update/catch-up stream to be applicable, because receiveTransaction cannot skip a step in the middle of the version sequence. Storing unvalidated steps breaks that invariant permanently rather than transiently: the failure is not one dropped edit but a poisoned history that every joiner must replay. The version counter also advances by the number of poison steps, so even the version bookkeeping certifies steps that were never applied anywhere.

Acceptance criteria

  • A Steps frame containing a step that does not parse against the document's schema, or does not apply to the document at the frame's version, is refused (Error to the sender, no Ack): nothing is stored, the version does not advance, and nothing is broadcast.
  • Validation is against the server's actual document state (base doc + previously accepted steps) or an equivalent server-side model — a purely syntactic/shape check that still admits the out-of-range replace above does not satisfy this.
  • Valid multi-step frames from a client at the current version keep working exactly as today (stored, version += n, broadcast, Ack), pinned by a test alongside the refusal cases (bogus stepType, unknown node in slice, out-of-range positions, non-object step).
  • A client that somehow still receives an unappliable step does not loop forever on the same history: e.g. the reconnect path distinguishes "catch-up itself is poisoned" and surfaces a terminal error for that document instead of rejoining indefinitely — or the server-side guarantee is documented as the sole line of defence with a test that a poisoned history cannot be created.
  • The anonymous-client reachability is considered explicitly: whatever validation is added applies before storage for authenticated and anonymous senders alike.

Activity

  1. developing-today commented on Oct 28, 2026

    @developing-today
    Contributor
  2. added
    kind/bugSomething isn't working (prefixed form of the classic bug label)
    queue/queuedHas a queued task in the Supabase agent queue
    source/creatorFiled by the new-issue-creator job
    task/triageQueue packet kind: verify, reproduce, and route this item
    priority/normalOrdinary priority (the default)
    app/idPackage/app: id (pkgs/ or apps/ in this repo)
    effort/sSmall: expected under 2 hours of work
    on Oct 28, 2026
  3. dezren39 commented on Oct 28, 2026

    @dezren39
    MemberAuthor

    agent: new-issue-creator-code-a
    state: filed
    found_via: filed this run
    at: 2026-10-28T07:45:46Z

    Filed this run after verification on main b2adaf47 (collab.rs sha256 55e04270…, collab.ts 0fb75f70…, identical in both clones):

    • Rust verbatim-decode probe on the laptop: Steps frames carrying a bogus stepType, an out-of-range replace, and even non-object steps (42, null, "hi") all decode Ok under the exact tuple shapes the server uses — the handler then stores, broadcasts and Acks them with no parse/apply check.
    • TS probe driving the real client receive path (Step.fromJSON + receiveTransaction, as collab.ts:278-279 calls them): bogus type and unknown slice node throw at fromJSON; the out-of-range replace throws in receiveTransaction; the control step applies cleanly. The client's only recovery (collab.ts:281-293) is close + reconnect, and catch-up (collab.rs:787-810) replays the same stored steps — so one bad step loops every client and every future joiner.

    Labels: kind/bug, queue/queued, source/creator, task/triage, status/needs-triage, priority/normal, app/id, effort/s. Queue packet enqueued as a triage task in repo-health-code-pilot (see backlog). Deliberately NOT self-solved: the server has no server-side ProseMirror document model today (steps are opaque values and the doc is never advanced server-side), so adding real apply-validation is an architecture choice for queue-lane triage, not a creator one-liner.

  4. dezren39 commented on Oct 28, 2026

    @dezren39
    MemberAuthor

    agent: new-issue-creator-code-a
    state: filed
    found_via: correction to the filing note, same run
    at: 2026-10-28T07:50:00Z

    Precision correction to my filing note: the client-side reconnect is not literally unbounded — scheduleReconnect (collab.ts:149-155) caps at 10 attempts with exponential backoff, then sets status error and stops. The substance is unchanged: every attempt rejoins into the same poisoned catch-up and throws again, the client then gives up, and any fresh page load or new joiner hits the same stored step in catch-up and fails the same way. The document stays uneditable until the server-side Document is cleaned up (1h after the last client disconnects). Read "reconnect loop" in the issue as "reconnect loop up to the 10-attempt cap, then a dead session — repeated on every join".

  5. added
    work/assignedQueue worker lifecycle state
    and removed
    queue/queuedHas a queued task in the Supabase agent queue
    on Nov 1, 2026
  6. 37 remaining items

  7. added
    queue/queuedHas a queued task in the Supabase agent queue
    work/assignedQueue worker lifecycle state
    queue/claimedQueue worker lifecycle state
    and removed
    work/assignedQueue worker lifecycle state
    queue/queuedHas a queued task in the Supabase agent queue
    on Nov 3, 2026
  8. dezren39 commented on Nov 3, 2026

    @dezren39
    MemberAuthor

    🤖 Agent note · picked up

  9. added
    queue/blockedQueue or user escalation state
    status/blockedQueue or user escalation state
    and removed
    queue/claimedQueue worker lifecycle state
    work/assignedQueue worker lifecycle state
    on Nov 3, 2026
  10. dezren39 commented on Nov 3, 2026

    @dezren39
    MemberAuthor

    🤖 Agent note · blocked

    Fresh revalidation: #1618 is open and gh pr list --state all --search "1618 collab" returned no matching PR. The queued packet is materially incomplete for its small_fix / 45-minute classification: it requires actual schema-aware sequential ProseMirror application plus atomic all-or-nothing commit before storage/version/broadcast, but chooses no authoritative server-side ProseMirror engine or schema-parity strategy and no serialization boundary. On current main, Document.doc is JSON and apply_steps still stores opaque steps; related #2968 remains open for the non-atomic version check/append. Prior triage also found no Rust ProseMirror engine and called this an architecture choice. Please settle the server-side model and atomic serialization design, then enqueue a bounded implementation task. No branch, worktree, code changes, or tests.

  11. developing-today commented on Nov 3, 2026

    @developing-today
    Contributor

    agent-note:v1
    state: reviewed (coverage loop-back)
    Coverage pass 2026-11-03, loop-back review: this issue was re-reviewed (drift / overlap / already-handled / stale / blockers / ordering) so every open issue in the four repos is reviewed and in the system. Verdict: still live; in the system as task 4790 (small_fix, blocked, priority 50) in repo-health-code-pilot. Labels audited and synced. If it is already handled at current main, its triage closes it with evidence; if oversized, the triage files the split; blockers belong on this issue.
    Found via: coverage loop-back 2026-11-03

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

app/idPackage/app: id (pkgs/ or apps/ in this repo)effort/sSmall: expected under 2 hours of workkind/bugSomething isn't working (prefixed form of the classic bug label)priority/normalOrdinary priority (the default)queue/blockedQueue or user escalation statequeue/queuedHas a queued task in the Supabase agent queuesource/creatorFiled by the new-issue-creator jobstatus/blockedQueue or user escalation statetask/triageQueue packet kind: verify, reproduce, and route this item

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions