Skip to content

πŸ”’οΈ Stop json0 ops bypassing the prototype pollution guard - #724

Draft
alecgibson wants to merge 1 commit into
masterfrom
fix-json0-op-prototype-pollution
Draft

πŸ”’οΈ Stop json0 ops bypassing the prototype pollution guard#724
alecgibson wants to merge 1 commit into
masterfrom
fix-json0-op-prototype-pollution

Conversation

@alecgibson

Copy link
Copy Markdown
Collaborator

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

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

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>
@coveralls

Copy link
Copy Markdown

Coverage Status

coverage: 97.477% (+0.003%) from 97.474% β€” fix-json0-op-prototype-pollution 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.

2 participants