You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
A client can spoof another client's op src/seq to silently drop its edits #733
An op's src is whatever the submitting client puts on the wire (createClientOp in lib/agent.js uses request.src || clientId), and every committed op's src/seq is broadcast to all subscribers. A collaborator who can write to a shared document can therefore resubmit using another client's src with that client's next seq, which makes the server treat the victim's genuine next op as an already-submitted duplicate and silently drop it, while the victim's own agent hides the attacker's op from the victim (treating it as the victim's own echo).
The victim ends up with a different document state than the server, and sees no error.
Reproduction
See scratchpad/server/poc5-src-spoof.js. With a victim editing docs/shared:
Attacker fetches the op history and learns the victim's src and latest seq (both are in the broadcast ops).
The server commits it. The victim's agent sees src === own src and does not deliver it, so the victim never sees the injected change.
The victim submits their real next op (which naturally uses seq = victim seq + 1). _transformOp matches it against the already-committed src/seq and returns ERR_OP_ALREADY_SUBMITTED, which the agent acks as success. The op is never applied.
End state in the PoC: victim's local doc is {"title":"final"} @ v2, server is {"title":"draft","injected":"by attacker"} @ v2. No error surfaced to the victim.
Notes
src being client-chosen is intentional (the handshake id and op resubmission after reconnect rely on it). The problem is that it's unauthenticated and world-readable, so one collaborator can impersonate another's op identity. Options worth discussing:
Bind the accepted src for a submitted op to the submitting agent's own src/clientId, rejecting ops that claim a different src than the connection owns. (This may interact with legitimate server-side resubmission on behalf of another src — needs care.)
At minimum, document that src is untrusted and that apps needing per-user op integrity must enforce it in submit middleware.
Related client-side note: Doc._handleOp treats any a:'op' message whose src/seq match the inflight op as an ack even when it carries an op/create/del payload, which is what lets a spoofed op be mistaken for the victim's own ack.
#754 addresses this with docs rather than a library change. The new docs explain that src is client-chosen, and add a connect + receive middleware recipe that ties each client's src to its authenticated user.
Why we're not enforcing it in the library:
Requiring an op's src to match the agent's src doesn't help, because the agent's src comes from the handshake id. The client chooses that too, so an attacker can just reconnect using the victim's ID. Proving ownership of an ID across reconnects without server-side state needs a secret plus an HMAC, which means a protocol change.
The client's "a matching src/seq is my ack" behaviour has to stay. On resubscribe, catch-up ops aren't filtered by src, so a client can receive its own committed op before the ack arrives. Treating that op as remote would apply it twice.
The attacker already has write access, so the only extra harm is that the dropped edit goes unnoticed.
Activity
alecgibson commentedon Oct 6, 2026
#754 addresses this with docs rather than a library change. The new docs explain that
srcis client-chosen, and add aconnect+receivemiddleware recipe that ties each client'ssrcto its authenticated user.Why we're not enforcing it in the library:
srcto match the agent'ssrcdoesn't help, because the agent'ssrccomes from the handshakeid. The client chooses that too, so an attacker can just reconnect using the victim's ID. Proving ownership of an ID across reconnects without server-side state needs a secret plus an HMAC, which means a protocol change.src/seqis my ack" behaviour has to stay. On resubscribe, catch-up ops aren't filtered bysrc, so a client can receive its own committed op before the ack arrives. Treating that op as remote would apply it twice.