🔒️ Check ops from the server against the json0 path guard - #723
Conversation
|
@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? |
There was a problem hiding this comment.
🟢 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.
cfdb13c to
2471544
Compare
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>
2471544 to
f140966
Compare
Closes #721
ot.jsrefuses to apply a json0 op whose path contains a segment inherited fromObject.prototype.Dochas no equivalent check:_otApply()hands op data straight tothis.type.apply(), so the guard protects the server's ownObject.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.datais 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-json0does, 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-json0quietly 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