Skip to content

🔒️ Check ops from the server against the json0 path guard - #723

Merged
alecgibson merged 1 commit into
masterfrom
fix-client-json0-path-guard
Sep 7, 2026
Merged

🔒️ Check ops from the server against the json0 path guard#723
alecgibson merged 1 commit into
masterfrom
fix-client-json0-path-guard

Conversation

@alecgibson

Copy link
Copy Markdown
Collaborator

Closes #721

ot.js refuses to apply a json0 op whose path contains a segment inherited from Object.prototype. Doc has no equivalent check: _otApply() hands op data straight to this.type.apply(), so the guard protects the server's own Object.prototype, and nothing protects a client's.

That matters for documents whose history predates the guard, since a committed __proto__ op is replayed by every client that fetches the document, and for a hostile or compromised server, or anything else that can put a frame on the socket. The write is silent: doc.data is unchanged, no error is emitted, and nothing in the client notices.

The type won't complain, so the check has to be explicit. This change adds it to _otApply(), which covers remote ops, fixup ops echoed back by the server, and the inverted op on rollback. Every call site already wraps _otApply() in a try/catch and hard rollbacks, so throwing fits the existing contract, and the error surfaces the way it does for any other op we can't apply.

Locally submitted ops are checked in _submit() rather than left to _otApply(), because _pushOp() runs first, and _tryCompose() applies the op to a pending create on the way past. By the time _otApply() sees the op, the prototype is already polluted, permanently. Erroring out of _submit() also means we call back with the error rather than tearing the document down and refetching it, matching what we already do for an op submitted to an uncreated document.

The check reuses the traversal added for GHSA-9rqw-j2q5-gg2g, which walks an op the way ot-json0 does, so the array-like and non-string path segment bypasses are closed on the client too. It deliberately reuses only the path check, not the shape check: ot-json0 quietly treats a non-array as a no-op and older versions of ShareDB committed those, so they exist in real op histories, and the client is on the replay side of that line.

Note this doesn't clean up documents that already have such an op in their history. Those ops stay in the database and keep failing on every replay.

🤖 Generated with Claude Code

Co-Authored-By: Claude noreply@anthropic.com

@alecgibson
alecgibson marked this pull request as ready for review September 7, 2026 09:20
@alecgibson
alecgibson requested review from dawidreedsy and a lite review from Copilot September 7, 2026 09:29
@alecgibson

Copy link
Copy Markdown
Collaborator Author

@dawidreedsy I'm going to go ahead and release this since it's a security issue, but could you please sense-check it when you're back from leave?

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Approval recommended

The security check is correctly enforced in both remote-apply and local-submit paths and is backed by targeted regression tests; only a minor comment wording nit remains.

Pull request overview

This PR hardens the ShareDB client against prototype pollution by rejecting incoming/outgoing json0 ops whose path contains a segment inherited from Object.prototype (e.g. __proto__, constructor). It aligns client-side behavior with the existing server-side json0 path guard, preventing silent client pollution when replaying historical ops or receiving malicious frames.

Changes:

  • Add ot.checkOpPathsForType() to validate json0 op path segments (without enforcing full op shape).
  • Enforce the path guard when applying ops in Doc._otApply() (covers ops from the server, echoed fixups, and rollback inversions).
  • Enforce the path guard early in Doc._submit() to prevent local submission from polluting state during compose/push, with new client tests covering multiple bypass patterns.
File summaries
File Description
test/client/doc.js Adds regression tests ensuring dangerous json0 path segments are rejected for server-sent and locally submitted ops.
lib/ot.js Exposes a json0-specific “paths-only” validation helper for ops being applied/prechecked.
lib/client/doc.js Uses the paths-only guard in _otApply() and _submit() to prevent prototype pollution on the client.
Review details
  • Files reviewed: 3/3 changed files
  • Comments generated: 1
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread lib/ot.js Outdated
@alecgibson
alecgibson force-pushed the fix-client-json0-path-guard branch from cfdb13c to 2471544 Compare September 7, 2026 09:35
@alecgibson
alecgibson changed the base branch from fix-json0-op-path-guard-bypass to master September 7, 2026 09:35
Closes #721

`ot.js` refuses to apply a json0 op whose path contains a segment
inherited from `Object.prototype`. `Doc` has no equivalent check:
`_otApply()` hands op data straight to `this.type.apply()`, so the guard
protects the server's own `Object.prototype`, and nothing protects a
client's.

That matters for documents whose history predates the guard, since a
committed `__proto__` op is replayed by every client that fetches the
document, and for a hostile or compromised server, or anything else that
can put a frame on the socket. The write is silent: `doc.data` is
unchanged, no error is emitted, and nothing in the client notices.

The type won't complain, so the check has to be explicit. This change
adds it to `_otApply()`, which covers remote ops, fixup ops echoed back
by the server, and the inverted op on rollback. Every call site already
wraps `_otApply()` in a try/catch and hard rollbacks, so throwing fits
the existing contract, and the error surfaces the way it does for any
other op we can't apply.

Locally submitted ops are checked in `_submit()` rather than left to
`_otApply()`, because `_pushOp()` runs first, and `_tryCompose()`
applies the op to a pending create on the way past. By the time
`_otApply()` sees the op, the prototype is already polluted,
permanently. Erroring out of `_submit()` also means we call back with
the error rather than tearing the document down and refetching it,
matching what we already do for an op submitted to an uncreated
document.

The check reuses the traversal added for GHSA-9rqw-j2q5-gg2g, which
walks an op the way `ot-json0` does, so the array-like and non-string
path segment bypasses are closed on the client too. It's extracted into
`ot.checkOpPathsForType()`, which `applyOps()` now shares, so there's a
single answer to whether an op needs its paths checked.

Note this doesn't clean up documents that already have such an op in
their history. Those ops stay in the database and keep failing on every
replay.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
@alecgibson
alecgibson force-pushed the fix-client-json0-path-guard branch from 2471544 to f140966 Compare September 7, 2026 09:38
@alecgibson
alecgibson merged commit 79c4a6d into master Sep 7, 2026
7 checks passed
@alecgibson
alecgibson deleted the fix-client-json0-path-guard branch September 7, 2026 09:40
@coveralls

Copy link
Copy Markdown

Coverage Status

coverage: 97.485% (+0.007%) from 97.478% — fix-client-json0-path-guard into master

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Client applies ops from the server without the json0 path guard the server uses

3 participants