Skip to content

Validate __proto__ properties against raw input before Zod parsing - #53

Closed
schani wants to merge 5 commits into
mainfrom
fix/issue-41-proto-validation
Closed

Validate __proto__ properties against raw input before Zod parsing#53
schani wants to merge 5 commits into
mainfrom
fix/issue-41-proto-validation

Conversation

@schani

@schani schani commented Jul 10, 2026

Copy link
Copy Markdown
Member

Problem

A __proto__ entry in properties never validated its value, and required: ["__proto__"] was only enforced for schemas without an explicit type:

const schema = convertJsonSchemaToZod({
    type: "object",
    properties: { ["__proto__"]: { type: "number" } },
});
schema.safeParse(JSON.parse('{"__proto__": "bad"}')).success; // was true — now false

const schema2 = convertJsonSchemaToZod({ type: "object", required: ["__proto__"] });
schema2.safeParse({}).success; // was true — now false

Root cause: Zod strips own __proto__ keys when building parse output (prototype-pollution defense), and all of this library's refinement-based validation runs on that output — by the time any check executes, the key is gone.

Fix

All of the logic lives in a single new ProtoPropertyHandler (the old ProtoRequiredHandler special case is deleted); no other handler needed changes beyond comment updates. For schemas that constrain an own __proto__ key (via required or a properties entry), the handler replaces the converted schema wholesale:

  1. It builds a reduced copy of the JSON schema: __proto__ is dropped from required, its properties entry is relaxed to the boolean true schema (so additionalProperties still treats it as a declared property), and keywords that must observe raw object keys (enum, const, minProperties, maxProperties, propertyNames, dependentRequired, dependentSchemas, and sibling references) are removed. The copy is built with rest/spread and Object.fromEntries so no __proto__ setter is ever invoked.
  2. The reduced schema is converted normally via the existing re-entrant convertJsonSchemaToZod (it no longer constrains __proto__, so the recursion terminates immediately).
  3. Everything removed from the reduced schema is checked against the raw input in front of it:
z.any().superRefine(rawChecks).pipe(convertJsonSchemaToZod(reducedSchema))

z.any() passes the input through untouched, so the checks see the own __proto__ key; .pipe() then runs the reduced schema, so every other constraint still applies and the parse output remains pollution-safe (own __proto__ keys are still stripped from output — verified by a test). The raw checks cover: required presence and the __proto__ value schema (only for non-null, non-array objects; boolean schemas like false work), full enum/const deep-equality semantics, minProperties/maxProperties, propertyNames, dependentRequired, dependentSchemas, and sibling references. Keys are always read via Object.getOwnPropertyDescriptor / Object.prototype.hasOwnProperty.call, never through the __proto__ accessor; the schema's own properties.__proto__ entry is likewise read via a property descriptor so schemas arriving from JSON.parse work.

Details:

  • Both issue cases now work: properties["__proto__"] value schemas and required: ["__proto__"], on typed and untyped object schemas.
  • ProtoPropertyHandler runs after all other refinements (before MetadataHandler, so .describe() still lands on the outermost schema — covered by a check that description survives).
  • The previously-skipped official-suite case properties | properties whose names are Javascript object property names | __proto__ not valid now passes and was removed from failing-tests-skip-list.json (104 → 103 skips). The sibling required.json __proto__ group stays green.
  • README "Special Property Support" and "Known Limitations" updated: the remaining gaps are that jsonSchemaObjectToZodRawShape still skips __proto__ entries, patternProperties/additionalProperties schemas still do not apply to the key in the deferred refinement path, and sibling composition keywords still evaluate after Zod strips the key.

Testing

Tests were written first and confirmed to fail on the unfixed code (7 failures reproducing both issue cases plus variants), then the fix was implemented:

  • Rewrote the two tests in src/object-properties.test.ts that previously asserted the limitation.
  • New src/proto-properties.test.ts covering: untyped/typed required and properties cases, combined required + value schema, __proto__ required alongside ordinary keys, schemas arriving via JSON.parse, boolean/undefined property schemas, non-object inputs, pollution-safety of parse output, enum/const/minProperties/maxProperties interaction (including the empty-enum special case), additionalProperties: false with a declared __proto__ property, interaction with the required-with-default pipe from Enforce required for properties that have a default #51, sibling references, propertyNames, dependentRequired, and dependentSchemas.
  • Full suite after merging current main: 1567 passed | 103 skipped, with 100% line and branch coverage.
  • The simplified implementation was differentially tested against the previous multi-file implementation of this PR: 546 schema/input pairs across 26 __proto__-related schemas produced identical results.
  • npm run build succeeds; issue examples verified against the built CJS and ESM bundles.

Fixes #41

🤖 Generated with Claude Code

@schani
schani force-pushed the fix/issue-41-proto-validation branch 3 times, most recently from 18db14d to 3a06bc9 Compare July 10, 2026 16:17
@schani
schani force-pushed the fix/issue-41-proto-validation branch from cc85d08 to f9e2f7b Compare July 10, 2026 17:10
schani and others added 4 commits July 10, 2026 13:18
Zod strips own __proto__ keys from its parse output as a
prototype-pollution defense, so refinements running on that output can
never see the key. As a result, a __proto__ entry in `properties` was
never validated, and `required: ["__proto__"]` was only enforced for
schemas without an explicit type (via the old ProtoRequiredHandler
special case).

Generalize that approach into a new ProtoPropertyHandler that wraps the
converted schema as z.any().refine(rawProtoCheck).pipe(zodSchema):
z.any() passes the input through untouched, so the refine observes the
own __proto__ key on the raw input; .pipe() then runs the full existing
schema, keeping all other constraints and the pollution-safe stripped
output intact. The key is read via Object.getOwnPropertyDescriptor /
Object.prototype.hasOwnProperty.call, never through the __proto__
accessor.

- Enforce properties["__proto__"] value schemas and
  required: ["__proto__"] on typed and untyped object schemas
- Delete ProtoRequiredHandler (subsumed by ProtoPropertyHandler)
- Skip __proto__ in ObjectPropertiesHandler's required loop so the
  union-output refinement doesn't reject valid inputs after Zod strips
  the key
- Remove the now-passing official-suite skip-list entry
  "properties | ... | __proto__ not valid"
- Update README Known Limitations and related comments

Fixes #41

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Review of the previous commit found two regressions vs main: on untyped
schemas, required: ["__proto__"] combined with a complex enum/const or
with minProperties rejected spec-valid inputs. The complex enum/const
deep-equality refinements and the minProperties/maxProperties counting
refinements ran inside the pipe, on Zod's parse output — which has own
"__proto__" keys stripped — so an input's "__proto__" key could never
match an enum/const member and was never counted. (On main these inputs
passed because the old ProtoRequiredHandler replaced the whole base with
z.any(), bypassing those checks entirely.)

Fix: introduce schemaConstrainsProtoProperty() in core/utils. When it
holds, EnumComplexHandler, ConstComplexHandler, MinPropertiesHandler,
and MaxPropertiesHandler skip attaching their output-side checks, and
ProtoPropertyHandler applies the same checks (via helpers exported from
enumComplex/constComplex) on the raw side of the pipe, alongside the
existing raw "__proto__" presence/value check.

This also makes these keywords spec-correct where main was silently
lax: enum now rejects non-member primitives instead of accepting
anything, maxProperties counts the real key set, and typed object
schemas ({type: "object", required: ["__proto__"], minProperties: 1})
now count own "__proto__" keys too.

Tests written first: the six new cases in src/proto-properties.test.ts
fail on the previous commit and pass now. Full suite green with 100%
line and branch coverage on all touched files; parse output remains
pollution-safe (own "__proto__" keys still stripped).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The raw-input required check from PropertiesHandler and the raw-input
__proto__ check from ProtoPropertyHandler both pipe into the converted
schema; these tests pin down that the nested pipes cooperate.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
ProtoPropertyHandler previously needed cooperation from four other
files: EnumComplexHandler, ConstComplexHandler, and the Min/Max
properties handlers had to skip schemas constraining "__proto__" (so
their checks wouldn't run on Zod's __proto__-stripped parse output),
ObjectPropertiesHandler had to skip "__proto__" in its required loop,
and utils.ts exported the shared predicate.

Instead, the handler now replaces the converted schema wholesale: it
builds a reduced copy of the JSON schema — "__proto__" dropped from
required, its properties entry relaxed to the boolean true schema (so
additionalProperties still sees it as declared), and enum / const /
minProperties / maxProperties removed — converts that reduced schema
normally, and checks everything it removed against the raw input in a
z.any().superRefine() piped in front. The reduced schema no longer
constrains "__proto__", so the re-entrant conversion terminates.

All other handlers go back to their main versions; behavior is
unchanged (verified by the existing tests plus a differential run of
546 schema/input pairs against the previous implementation). Three
tests were added to pin the empty-enum special case, the boolean true
schema, and the additionalProperties: false interaction.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@schani
schani force-pushed the fix/issue-41-proto-validation branch from f9e2f7b to b6ab129 Compare July 10, 2026 17:19
@schani

schani commented Jul 13, 2026

Copy link
Copy Markdown
Member Author

This doesn't fix all the cases, and doing that seems like a huge headache. So for the time being we're punting on this. Will document in the README if it's not already.

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.

__proto__ properties cannot be validated (values never checked, required only on untyped schemas)

1 participant