Skip to content

A client can spoof another client's op src/seq to silently drop its edits #733

Description

@alecgibson

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:

  1. Attacker fetches the op history and learns the victim's src and latest seq (both are in the broadcast ops).
  2. Attacker submits {a:'op', c:'docs', d:'shared', v:1, src:<victim src>, seq:<victim seq + 1>, op:[{p:['injected'],oi:'by attacker'}]}.
  3. 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.
  4. 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.

Activity

  1. alecgibson commented on Oct 6, 2026

    @alecgibson
    CollaboratorAuthor

    #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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions