Skip to content

fix(security): close two prototype-pollution routes in the exported utilities - #73

Merged
rjrodger merged 2 commits into
mainfrom
claude/proto-pollution
Aug 18, 2026
Merged

fix(security): close two prototype-pollution routes in the exported utilities#73
rjrodger merged 2 commits into
mainfrom
claude/proto-pollution

Conversation

@rjrodger

Copy link
Copy Markdown
Collaborator

Found by auditing all 25 exported util functions 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 with Object.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. deep had no guard at all

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:

Jsonic.make(Jsonic('{"__proto__":{"pwned":1}}'))
// ({}).pwned === 1

constructor is 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 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 stop the name reaching a prototype, which is the same thing the parser already does when it stores it.

2. prop guarded __proto__ only

constructor.prototype.x walks to a class and assigns there, polluting every instance:

function C() { }
util.prop(new C(), 'constructor.prototype.polluted', 'X')
// new C().polluted === 'X'

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. 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.

Audit result

The other 23 exported utilities were already clean against a parse result, including clone, omap, clean, entries, keys, values, str, strinject and errinject.

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

rjrodger and others added 2 commits August 18, 2026 00:26
…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
@rjrodger
rjrodger merged commit 46b8655 into main Aug 18, 2026
7 checks passed
@rjrodger
rjrodger deleted the claude/proto-pollution branch August 18, 2026 00:33

@chatgpt-codex-connector chatgpt-codex-connector Bot 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.

💡 Codex Review

obj = obj[pn] = obj[pn] || {}

P1 Badge Avoid inherited values while traversing prop paths

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".

Comment thread src/utility.ts
})
}
else {
base[k] = deep(base[k], over[k])

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge 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 👍 / 👎.

Comment thread src/utility.ts
Comment on lines +593 to +597
Object.defineProperty(base, k, {
value: deep(prev, over[k]),
writable: true,
enumerable: true,
configurable: true,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge 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 👍 / 👎.

Comment thread src/utility.ts
Comment on lines +810 to 811
if (UNSAFE_KEY[pn]) {
throw new Error(pn)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge 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 👍 / 👎.

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.

1 participant