fix(security): close two prototype-pollution routes in the exported utilities - #73
Conversation
…tilities
Both are reachable from a jsonic PARSE RESULT, which is what makes them
worth more than the usual "do not feed it untrusted paths". A parse
result legitimately carries an OWN `__proto__` key — map nodes are built
with `Object.create(null)`, so `{"__proto__":{...}}` stores the name
rather than acting on it — and that object is exactly what a caller then
merges, or passes back as options.
**`deep` had no guard at all.** `base[k] = deep(base[k], over[k])` acts
on the name the moment `base` is an ordinary object:
util.deep({}, Jsonic('{"__proto__":{"polluted":"X"}}'))
// ({}).polluted === 'X'
`Jsonic.make(opts)` merges its options with `deep`, so this is reachable
without the caller naming `deep` at all — parse a config file, hand it
back as options, and every object in the process is polluted.
`constructor` is the same defect one step further out, and does not need
an own key on the payload to start: `base['constructor']` reads the
INHERITED constructor, and the recursion merges
`over.constructor.prototype` into the real `Object.prototype`.
Fixed by refusing to leave the object for those names: read only what
`base` owns, and write with `defineProperty`, which creates an own data
property rather than invoking the `__proto__` setter. The key survives
with its value — the repair is not to drop data, it is to keep the name
from reaching a prototype, which is the same thing the parser does when
it stores it.
**`prop` guarded `__proto__` only**, leaving the other route open:
`constructor.prototype.x` walks to a class and assigns there, polluting
every instance. It read as safe because a plain `{}` leads to
`Object.prototype`, whose `prototype` property is not writable, so the
walk threw — an accident of that one class. Any other has a writable
one:
function C() { }
util.prop(new C(), 'constructor.prototype.polluted', 'X')
// new C().polluted === 'X'
A bare `constructor.x` also wrote to the global `Object` constructor.
`error.ts` carries a copy of `prop` — the two modules already import
from each other, so the copy exists to avoid deepening that. Both now
share one `UNSAFE_KEY` table so they cannot drift apart again, which is
how one defect came to live in two files.
That table is built rather than written as a literal, for the two
reasons it exists: `{__proto__: true}` sets a prototype instead of
storing a key, and a lookup on an ordinary object inherits, so
`UNSAFE_KEY['toString']` would have tested truthy and diverted every
inherited name down the guarded path.
Audited all 25 exported utilities the same way. `clone`, `omap`,
`clean`, `entries`, `keys`, `values`, `str`, `strinject` and `errinject`
were already clean against a parse result; these two were not.
Three regression tests in `safe.test.js`, each verified to fail on the
previous code. Suite otherwise unchanged: 478 tests, 0 failures, the
same 3 pre-existing skips.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KeS3Phjrv21moqauXAdhfr
…ch name No behaviour change. Assigning `UNSAFE_KEY['__proto__'] = true` literally is the very shape this table exists to catch, so it reads as the defect even where it is safe (the target has no prototype, so the name is stored rather than acted on) — and a static analyzer has no way to tell those apart. Building from a list says the same thing without the lookalike. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KeS3Phjrv21moqauXAdhfr
There was a problem hiding this comment.
💡 Codex Review
Line 814 in 8e9d37b
For any path segment not listed in UNSAFE_KEY, traversal still reuses an inherited value and then writes through it. For example, Jsonic.util.prop({}, 'toString.polluted', 'X') makes Object.prototype.toString.polluted === 'X', and inherited methods on custom instances can be mutated in the same way, so the advertised prop fix remains bypassable with ordinary inherited property names. Before descending, only reuse an own property; otherwise create a fresh own container rather than assigning the inherited value back onto the object.
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| }) | ||
| } | ||
| else { | ||
| base[k] = deep(base[k], over[k]) |
There was a problem hiding this comment.
Restrict every merge read to own properties
When over has an ordinary key that exists only on base's prototype, this branch still passes the inherited object to deep. For example, deep({}, Jsonic('toString:{polluted:X}')) sets Object.prototype.toString.polluted and makes the result's own toString reference that shared built-in, so parsed/untrusted options can still mutate a process-global prototype-chain object. The own-property rule needs to apply to every merge key, while retaining special write handling for __proto__, rather than only to the three names in UNSAFE_KEY.
Useful? React with 👍 / 👎.
| Object.defineProperty(base, k, { | ||
| value: deep(prev, over[k]), | ||
| writable: true, | ||
| enumerable: true, | ||
| configurable: true, |
There was a problem hiding this comment.
Preserve descriptors when merging a function prototype
When base is a function and over contains prototype—a case covered by deep's documented behavior of injecting plain objects into functions—the existing prototype property is non-configurable and non-enumerable. Forcing these descriptor flags to true throws a TypeError; moreover, because deep(prev, over[k]) is evaluated first, deep(C, { prototype: { x: 1 } }) mutates C.prototype.x and then throws, whereas the previous implementation completed normally. Preserve the existing descriptor or safely assign its value instead.
Useful? React with 👍 / 👎.
| if (UNSAFE_KEY[pn]) { | ||
| throw new Error(pn) |
There was a problem hiding this comment.
Permit safe own fields named constructor or prototype
When the path terminates at an own data property, or the setter is creating such a property, constructor and prototype do not traverse the prototype chain, but this unconditional guard rejects them anyway. Consequently, calls such as Jsonic.util.prop(Object.assign(Object.create(null), { constructor: 7 }), 'constructor') and Jsonic.util.prop({}, 'prototype', 7) now throw; handle_props also uses this helper, so CLI options such as -o constructor=7 fail. Check ownership and the segment position, using an own-property write where necessary, rather than banning these legitimate JSON field names entirely.
Useful? React with 👍 / 👎.
Found by auditing all 25 exported
utilfunctions against a jsonic parse result. Two were vulnerable, and between them they account for five reachable paths.Why a parse result is the payload that matters
A parse result legitimately carries an own
__proto__key: map nodes are built withObject.create(null), so{"__proto__":{…}}stores the name rather than acting on it. That object is exactly what a caller then merges, or hands back as options — which is where it stops being inert.1.
deephad no guard at allJsonic.make(opts)merges its options withdeep, so this is reachable without the caller namingdeepat all — parse a config file, hand it back as options, and every object in the process is polluted:constructoris the same defect one step further out, and does not even need an own key on the payload to start:base['constructor']reads the inherited constructor, and the recursion then mergesover.constructor.prototypeinto the realObject.prototype.Fixed by refusing to leave the object for those names — read only what
baseowns, and write withdefineProperty, which creates an own data property rather than invoking the__proto__setter. The key survives with its value: the repair is not to drop data, it is to stop the name reaching a prototype, which is the same thing the parser already does when it stores it.2.
propguarded__proto__onlyconstructor.prototype.xwalks to a class and assigns there, polluting every instance:It read as safe because a plain
{}leads toObject.prototype, whoseprototypeproperty is not writable, so the walk threw — an accident of that one class. A bareconstructor.xalso wrote to the globalObjectconstructor.error.tscarries a copy ofprop(the two modules already import from each other, so the copy exists to avoid deepening that). Both now share oneUNSAFE_KEYtable, so they cannot drift apart again — which is how one defect came to live in two files.That table is built rather than written as a literal, for the two reasons it exists:
{__proto__: true}sets a prototype instead of storing a key, and a lookup on an ordinary object inherits, soUNSAFE_KEY['toString']would have tested truthy and diverted every inherited name down the guarded path.Audit result
The other 23 exported utilities were already clean against a parse result, including
clone,omap,clean,entries,keys,values,str,strinjectanderrinject.Testing
Three regression tests in
safe.test.js, each verified to fail on the previous code (3 failures before, 0 after). Suite otherwise unchanged: 478 tests, 0 failures, the same 3 pre-existing skips.🤖 Generated with Claude Code
https://claude.ai/code/session_01KeS3Phjrv21moqauXAdhfr