Skip to content

fix(v4): skip __proto__ key in object catchall#5898

Merged
colinhacks merged 1 commit into
mainfrom
fix-proto-catchall
Apr 29, 2026
Merged

fix(v4): skip __proto__ key in object catchall#5898
colinhacks merged 1 commit into
mainfrom
fix-proto-catchall

Conversation

@colinhacks
Copy link
Copy Markdown
Owner

@colinhacks colinhacks commented Apr 29, 2026

Summary

In handleCatchall(), an unrecognized __proto__ key in the input is currently assigned directly to a fresh {} result object via (final.value)[key] = result.value. Because that goes through the __proto__ setter on a plain object, the assignment replaces the prototype of the parsed result, giving the validated object attacker-controlled inherited properties. Object.prototype itself is not affected.

PoC on 4.3.6:

const schema = z.looseObject({ name: z.string() });
const input = JSON.parse('{"__proto__":{"isAdmin":true},"name":"alice"}');
const parsed = schema.parse(input);
parsed.isAdmin // true  ← inherited from injected prototype

Same shape reproduces with passthrough() and catchall(...), sync/async, and { jitless: true } (the JIT fastpass falls through to the same handleCatchall for unknown keys).

Fix

One-line guard in the catchall loop:

for (const key in input) {
  if (key === "__proto__") continue;
  ...
}

Mirrors the existing __proto__ skip in $ZodRecord (schemas.ts:2867) and the v3 object-assembly guard at parseUtil.ts:144. Deliberately conservative — for..in iteration of input is preserved, so behavior for any input other than one with a __proto__ own key is unchanged. Inherited prototype-chain enumeration (the separate concern in GHSA-9hmj-h52h-mxcf) is not addressed here; that's a narrower attack model and a noisier behavior change worth handling separately.

Reported in:

  • GHSA-r34p-xfmx-58wv (critical)
  • GHSA-84jv-fqfx-wxhr

Test plan

  • 5 new tests in object.test.ts covering looseObject / passthrough / catchall(unknown) / safeParseAsync + { jitless: true } / strict(), asserting Object.getPrototypeOf(parsed) === Object.prototype and that the injected key is not visible on the result.
  • pnpm vitest run — 3753 passed across 333 files, no regressions.

@pullfrog
Copy link
Copy Markdown
Contributor

pullfrog Bot commented Apr 29, 2026

New pull request. Leaping into action...

Pullfrog  | View workflow run | via Pullfrog | Using Claude Opus𝕏

@pullfrog
Copy link
Copy Markdown
Contributor

pullfrog Bot commented Apr 29, 2026

TL;DR — Fixes a security issue where handleCatchall() allowed a __proto__ key from JSON.parse input to replace the parsed result's prototype via the assignment setter on a plain {}. Adds an explicit __proto__ skip inside the existing for...in loop — a minimal, targeted guard that blocks prototype pollution without changing iteration semantics.

Key changes

  • Skip __proto__ key in handleCatchall — adds if (key === "__proto__") continue inside the for...in loop so a JSON.parse-sourced __proto__ own property never gets assigned to the output object.
  • Add __proto__ in object catchall paths test suite — 5 tests covering looseObject, passthrough, catchall(z.unknown()), safeParseAsync with jitless, and strict() non-rejection.

Summary | 2 files | 1 commit | base: mainfix-proto-catchall


Prototype-safe catchall iteration

Before: handleCatchall iterated with for (const key in input) and had no guard against __proto__ — when JSON.parse('{"__proto__":{"isAdmin":true}}') produced an own enumerable __proto__ key, assigning it to the result {} invoked the __proto__ setter and silently replaced the object's prototype.
After: A single if (key === "__proto__") continue guard skips the dangerous key entirely, preserving Object.prototype on the output.

The fix is intentionally minimal — one conditional inside the hot path. for...in is retained (rather than switching to Object.keys()) to keep the existing iteration behavior for inherited keys unchanged, while surgically blocking the only key that triggers prototype pollution via assignment.

Why guard only __proto__ and not switch to Object.keys()?

Object.keys() would also block inherited keys from leaking through, but changing iteration semantics is a broader behavioral change. The proto guard targets the specific security vector — prototype pollution via the assignment setter — without altering how other inherited properties are handled. This matches the minimal-fix approach appropriate for a security patch.

schemas.ts · object.test.ts

Pullfrog  | View workflow run | via Pullfrog | Using Claude Opus𝕏

In handleCatchall, an unrecognized __proto__ key in the input was
being assigned directly to the fresh {} result object via
(final.value)[key] = result.value. That assignment goes through the
__proto__ setter and replaces the prototype of the parsed result,
giving the validated object attacker-controlled inherited properties
without polluting Object.prototype.

Skip __proto__ before the catchall runs. Mirrors the existing guard
in $ZodRecord and the v3 object-assembly path. No other behavior
change — for..in iteration of input is preserved.

Reported in:
- GHSA-r34p-xfmx-58wv
- GHSA-84jv-fqfx-wxhr
@colinhacks colinhacks changed the title fix(v4): skip __proto__ and inherited keys in object catchall fix(v4): skip __proto__ key in object catchall Apr 29, 2026
@colinhacks colinhacks merged commit 76e8f70 into main Apr 29, 2026
6 checks passed
@colinhacks colinhacks deleted the fix-proto-catchall branch April 29, 2026 05:22
@colinhacks
Copy link
Copy Markdown
Owner Author

Landed in Zod 4.4

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