Summary
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).
- Attacker submits
{a:'op', c:'docs', d:'shared', v:1, src:<victim src>, seq:<victim seq + 1>, op:[{p:['injected'],oi:'by attacker'}]}.
- 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.
Summary
An op's
srcis whatever the submitting client puts on the wire (createClientOpinlib/agent.jsusesrequest.src || clientId), and every committed op'ssrc/seqis broadcast to all subscribers. A collaborator who can write to a shared document can therefore resubmit using another client'ssrcwith that client's nextseq, 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 editingdocs/shared:srcand latestseq(both are in the broadcast ops).{a:'op', c:'docs', d:'shared', v:1, src:<victim src>, seq:<victim seq + 1>, op:[{p:['injected'],oi:'by attacker'}]}.src === own srcand does not deliver it, so the victim never sees the injected change.seq = victim seq + 1)._transformOpmatches it against the already-committedsrc/seqand returnsERR_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
srcbeing client-chosen is intentional (the handshakeidand 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:srcfor a submitted op to the submitting agent's ownsrc/clientId, rejecting ops that claim a differentsrcthan the connection owns. (This may interact with legitimate server-side resubmission on behalf of another src — needs care.)srcis untrusted and that apps needing per-user op integrity must enforce it insubmitmiddleware.Related client-side note:
Doc._handleOptreats anya:'op'message whosesrc/seqmatch the inflight op as an ack even when it carries anop/create/delpayload, which is what lets a spoofed op be mistaken for the victim's own ack.