Validate __proto__ properties against raw input before Zod parsing - #53
Closed
schani wants to merge 5 commits into
Closed
Validate __proto__ properties against raw input before Zod parsing#53schani wants to merge 5 commits into
schani wants to merge 5 commits into
Conversation
schani
force-pushed
the
fix/issue-41-proto-validation
branch
3 times, most recently
from
July 10, 2026 16:17
18db14d to
3a06bc9
Compare
schani
force-pushed
the
fix/issue-41-proto-validation
branch
from
July 10, 2026 17:10
cc85d08 to
f9e2f7b
Compare
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
force-pushed
the
fix/issue-41-proto-validation
branch
from
July 10, 2026 17:19
f9e2f7b to
b6ab129
Compare
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. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
A
__proto__entry inpropertiesnever validated its value, andrequired: ["__proto__"]was only enforced for schemas without an explicittype: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 oldProtoRequiredHandlerspecial case is deleted); no other handler needed changes beyond comment updates. For schemas that constrain an own__proto__key (viarequiredor apropertiesentry), the handler replaces the converted schema wholesale:__proto__is dropped fromrequired, itspropertiesentry is relaxed to the booleantrueschema (soadditionalPropertiesstill 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 andObject.fromEntriesso no__proto__setter is ever invoked.convertJsonSchemaToZod(it no longer constrains__proto__, so the recursion terminates immediately).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:requiredpresence and the__proto__value schema (only for non-null, non-array objects; boolean schemas likefalsework), fullenum/constdeep-equality semantics,minProperties/maxProperties,propertyNames,dependentRequired,dependentSchemas, and sibling references. Keys are always read viaObject.getOwnPropertyDescriptor/Object.prototype.hasOwnProperty.call, never through the__proto__accessor; the schema's ownproperties.__proto__entry is likewise read via a property descriptor so schemas arriving fromJSON.parsework.Details:
properties["__proto__"]value schemas andrequired: ["__proto__"], on typed and untyped object schemas.ProtoPropertyHandlerruns after all other refinements (beforeMetadataHandler, so.describe()still lands on the outermost schema — covered by a check thatdescriptionsurvives).properties | properties whose names are Javascript object property names | __proto__ not validnow passes and was removed fromfailing-tests-skip-list.json(104 → 103 skips). The siblingrequired.json__proto__group stays green.jsonSchemaObjectToZodRawShapestill skips__proto__entries,patternProperties/additionalPropertiesschemas 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:
src/object-properties.test.tsthat previously asserted the limitation.src/proto-properties.test.tscovering: untyped/typedrequiredandpropertiescases, combined required + value schema,__proto__required alongside ordinary keys, schemas arriving viaJSON.parse, boolean/undefined property schemas, non-object inputs, pollution-safety of parse output,enum/const/minProperties/maxPropertiesinteraction (including the empty-enumspecial case),additionalProperties: falsewith 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, anddependentSchemas.main: 1567 passed | 103 skipped, with 100% line and branch coverage.__proto__-related schemas produced identical results.npm run buildsucceeds; issue examples verified against the built CJS and ESM bundles.Fixes #41
🤖 Generated with Claude Code