ποΈ Stop json0 ops bypassing the prototype pollution guard - #724
Draft
alecgibson wants to merge 1 commit into
Draft
ποΈ Stop json0 ops bypassing the prototype pollution guard#724alecgibson wants to merge 1 commit into
alecgibson wants to merge 1 commit into
Conversation
Refs GHSA-9rqw-j2q5-gg2g
At the moment, `applyOpEdit()` only scans an op's paths for dangerous
segments if `Array.isArray(edit)`. `ot-json0` isn't so fussy: both
`checkValidOp()` and `apply()` walk the op with `op.length` and numeric
indexing, so an array-like object is an op as far as the type is
concerned, and isn't an op as far as the guard is concerned.
Any client that can submit an op can therefore write to
`Object.prototype` in the server process, for the lifetime of that
process, with a single message:
```
{"a":"op","c":"docs","d":"doc","v":1,"src":"c1","seq":1,
"op":{"0":{"p":["__proto__","polluted"],"oi":"x"},"length":1}}
```
The server acks that like any other op, and the document data is
unchanged, so nothing looks wrong from either end.
The root cause is that our validation and `ot-json0`'s traversal
disagree about what counts as an op, and the guard has two more holes of
its own:
- `isDangerousProperty()` builds its lookup map from
`Object.getOwnPropertyNames(Object.prototype)` but skips the
`__proto__` key. That map has a null prototype, so `__proto__` is an
ordinary own key there and the exclusion bought us nothing. It just
means the lookup, which coerces its key, missed anything that
stringifies to `__proto__`, so `[{p: [['__proto__'], 'x'], oi: 1}]`
was accepted with no array-like trickery at all.
- `normalizeLegacyJson0Ops()` applies op components itself, before
`apply()` guards anything, so `applyOps()` polluted and *then*
returned `Invalid path segment`. That one needs no array-like op
either, and is reachable through `fetchSnapshot()`.
This change makes the path scan traverse an op exactly the way
`ot-json0` does β `.length` and numeric indexing, including its
coercion of a string `length`, since `{"length": "1"}` applies too β and
runs it everywhere an op is applied, including the legacy
normalisation. Anything `ot-json0` will apply is now checked.
It also stops the guard crashing the process on its own: reading
`opComponent.p` threw an uncaught `TypeError` for `op: [null]`, from
inside a `backend.trigger()` callback. The scan now stops at the first
component `ot-json0` would reject, and leaves it to complain, which it
does inside the existing `try`/`catch`. Stopping rather than skipping
past it also matters because `{"length": 1e9}` would otherwise spin.
Finally, `projections` tested its field allow-list with plain-object
truthiness, so `fields['__proto__']` and `fields['toString']` were
truthy, and a projection reported those segments as allowed fields.
Those lookups now use `util.hasOwn()`.
Note this deliberately doesn't police the *shape* of an op, only its
paths, so no op that used to apply stops applying. `ot-json0` quietly
treats a non-array as a no-op, and `backend.submit()` accepts those, so
they exist in real op histories β rejecting them here would make those
documents permanently unreadable through `fetchSnapshot()`, which is
exactly what `normalizeLegacyJson0Ops()` exists to avoid. Those shapes
are a problem for a different reason, handled separately.
Note also that none of this defends a client against ops arriving from
the server, which has no equivalent guard. That's tracked in
#721
π€ Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Refs GHSA-9rqw-j2q5-gg2g
At the moment,
applyOpEdit()only scans an op's paths for dangerous segments ifArray.isArray(edit).ot-json0isn't so fussy: bothcheckValidOp()andapply()walk the op withop.lengthand numeric indexing, so an array-like object is an op as far as the type is concerned, and isn't an op as far as the guard is concerned.Any client that can submit an op can therefore write to
Object.prototypein the server process, for the lifetime of that process, with a single message:The server acks that like any other op, and the document data is unchanged, so nothing looks wrong from either end.
The root cause is that our validation and
ot-json0's traversal disagree about what counts as an op, and the guard has two more holes of its own:isDangerousProperty()builds its lookup map fromObject.getOwnPropertyNames(Object.prototype)but skips the__proto__key. That map has a null prototype, so__proto__is an ordinary own key there and the exclusion bought us nothing. It just means the lookup, which coerces its key, missed anything that stringifies to__proto__, so[{p: [['__proto__'], 'x'], oi: 1}]was accepted with no array-like trickery at all.normalizeLegacyJson0Ops()applies op components itself, beforeapply()guards anything, soapplyOps()polluted and then returnedInvalid path segment. That one needs no array-like op either, and is reachable throughfetchSnapshot().This change makes the path scan traverse an op exactly the way
ot-json0does β.lengthand numeric indexing, including its coercion of a stringlength, since{"length": "1"}applies too β and runs it everywhere an op is applied, including the legacy normalisation. Anythingot-json0will apply is now checked.It also stops the guard crashing the process on its own: reading
opComponent.pthrew an uncaughtTypeErrorforop: [null], from inside abackend.trigger()callback. The scan now stops at the first componentot-json0would reject, and leaves it to complain, which it does inside the existingtry/catch. Stopping rather than skipping past it also matters because{"length": 1e9}would otherwise spin.Finally,
projectionstested its field allow-list with plain-object truthiness, sofields['__proto__']andfields['toString']were truthy, and a projection reported those segments as allowed fields. Those lookups now useutil.hasOwn().Note this deliberately doesn't police the shape of an op, only its paths, so no op that used to apply stops applying.
ot-json0quietly treats a non-array as a no-op, andbackend.submit()accepts those, so they exist in real op histories β rejecting them here would make those documents permanently unreadable throughfetchSnapshot(), which is exactly whatnormalizeLegacyJson0Ops()exists to avoid. Those shapes are a problem for a different reason, handled separately.Note also that none of this defends a client against ops arriving from the server, which has no equivalent guard. That's tracked in #721
π€ Generated with Claude Code
Co-Authored-By: Claude noreply@anthropic.com