diff --git a/README.md b/README.md index 7afa2e6..69d02b4 100644 --- a/README.md +++ b/README.md @@ -226,7 +226,7 @@ This library provides comprehensive support for JSON Schema Draft 2020-12 featur - `maxProperties` - Maximum number of object properties - `dependentRequired` - Required properties based on other property presence -**Special Property Support**: Correctly handles JavaScript reserved property names like `constructor` and `toString` — inherited members are not mistaken for property values, and own keys are validated normally. `__proto__` is an exception; see [Known Limitations](#known-limitations). +**Special Property Support**: Correctly handles JavaScript reserved property names like `constructor` and `toString` — inherited members are not mistaken for property values, and own keys are validated normally. `__proto__` entries in `properties` and `required` are validated against the raw input before Zod's object parsing runs (Zod strips own `__proto__` keys from parse *output* as a prototype-pollution defense, and that stripping is preserved). For such schemas, `enum`, `const`, `minProperties`, `maxProperties`, `propertyNames`, `dependentRequired`, `dependentSchemas`, and sibling references are also checked against the raw input, so own `__proto__` keys participate in them. ### Schema Composition - `const` - Literal value constraints @@ -280,7 +280,7 @@ The following JSON Schema features are **not yet implemented**: Beyond the unsupported keywords above, supported features have some known gaps: -- **`__proto__` properties**: Zod removes own `__proto__` keys from parsed objects to prevent prototype pollution, and this library's validation runs on Zod's parse output. As a result, a `__proto__` entry in `properties` cannot validate its value, and `required: ["__proto__"]` is only enforced for schemas without an explicit `type`. +- **`__proto__` properties**: `convertJsonSchemaToZod` validates `__proto__` entries in `properties` and `required` against the raw input, but three gaps remain: `jsonSchemaObjectToZodRawShape` still skips `__proto__` entries (a raw `z.object()` shape cannot express them); `patternProperties` / `additionalProperties` schemas are not applied to an own `__proto__` key in the deferred refinement path; and sibling composition keywords (`allOf`, `anyOf`, `oneOf`, `not`, and `if` / `then` / `else`) evaluate their subschemas after Zod has stripped the key. Own `__proto__` keys are still removed from parse *output* to prevent prototype pollution. - **Unresolvable references are ignored**: A `$ref`/`$dynamicRef` that cannot be resolved within the document (remote URIs, unknown anchors, dangling pointers) does not constrain the value at all; the rest of the schema still applies. - **`$dynamicRef` is resolved statically**: Dynamic scoping is not implemented; `$dynamicRef` behaves like `$ref` (and `$dynamicAnchor` like `$anchor`), which matches the spec whenever the dynamic anchor is bookended in the same schema resource. - **Degenerate reference cycles**: Reference chains that lead back to themselves without making progress on the data are dropped (treated as unresolvable) when the cycle consists purely of `$ref`/`$dynamicRef` edges. Cycles that pass through applicators that don't consume data (e.g. `not` or `allOf`) are not detected and will recurse at validation time. diff --git a/failing-tests-skip-list.json b/failing-tests-skip-list.json index 420d653..edaf917 100644 --- a/failing-tests-skip-list.json +++ b/failing-tests-skip-list.json @@ -16,7 +16,6 @@ "dynamicRef|tests for implementation dynamic anchor and reference link|incorrect extended schema", "dynamicRef|tests for implementation dynamic anchor and reference link|incorrect parent schema", "not|collect annotations inside a 'not', even if collection is disabled|unevaluated property", - "properties|properties whose names are Javascript object property names|__proto__ not valid", "refRemote|$ref to $ref finds detached $anchor|non-number is invalid", "refRemote|Location-independent identifier in remote ref|string is invalid", "refRemote|anchor within remote ref|remote anchor invalid", diff --git a/src/core/converter.ts b/src/core/converter.ts index 28ee719..d4b20c5 100644 --- a/src/core/converter.ts +++ b/src/core/converter.ts @@ -44,7 +44,7 @@ import { DependentSchemasHandler } from "../handlers/refinement/dependentSchemas import { EnumComplexHandler } from "../handlers/refinement/enumComplex"; import { ConstComplexHandler } from "../handlers/refinement/constComplex"; import { MetadataHandler } from "../handlers/refinement/metadata"; -import { ProtoRequiredHandler } from "../handlers/refinement/protoRequired"; +import { ProtoPropertyHandler } from "../handlers/refinement/protoProperty"; import { ContainsHandler } from "../handlers/refinement/contains"; import { DependentRequiredHandler } from "../handlers/refinement/dependentRequired"; import { DefaultHandler } from "../handlers/refinement/default"; @@ -95,7 +95,6 @@ const refinementHandlers: RefinementHandler[] = [ new RefHandler(), // Handle special cases first - new ProtoRequiredHandler(), new EnumComplexHandler(), new ConstComplexHandler(), @@ -121,6 +120,12 @@ const refinementHandlers: RefinementHandler[] = [ new UniqueItemsHandler(), new DefaultHandler(), + // For schemas constraining "__proto__" keys, replaces the converted + // schema with a raw-input check piped into a re-conversion of the schema + // sans "__proto__" entries; MetadataHandler stays last so .describe() + // lands on the outermost schema. + new ProtoPropertyHandler(), + // Metadata last new MetadataHandler(), ]; diff --git a/src/handlers/primitive/object.ts b/src/handlers/primitive/object.ts index 713249c..5ae6e70 100644 --- a/src/handlers/primitive/object.ts +++ b/src/handlers/primitive/object.ts @@ -86,10 +86,9 @@ export class PropertiesHandler implements PrimitiveHandler { objectZod = objectZod.passthrough(); } - // Presence of required "__proto__" stays unenforced to keep the - // documented __proto__ limitation consistent: Zod strips own - // "__proto__" keys for security, so its value can't be validated - // either. (ProtoRequiredHandler covers the untyped-schema case.) + // Zod strips own "__proto__" keys for security, so presence of + // "__proto__" can't be enforced here. (ProtoPropertyHandler + // enforces it on the raw input instead.) const enforcibleKeys = presenceCheckKeys.filter((key) => key !== "__proto__"); if (enforcibleKeys.length > 0) { diff --git a/src/handlers/refinement/objectProperties.ts b/src/handlers/refinement/objectProperties.ts index 57cbdd0..48368f0 100644 --- a/src/handlers/refinement/objectProperties.ts +++ b/src/handlers/refinement/objectProperties.ts @@ -33,9 +33,9 @@ export class ObjectPropertiesHandler implements RefinementHandler { // Properties named like Object.prototype members were left out of the // object shape built by PropertiesHandler; they are validated here with - // own-property semantics. Exception: "__proto__" values still can't be - // checked, because Zod strips own "__proto__" keys from the parse - // output this refinement runs on. + // own-property semantics. Exception: "__proto__" is handled by + // ProtoPropertyHandler on the raw input, because Zod strips own + // "__proto__" keys from the parse output this refinement runs on. const hasHazardousProperties = propertyEntries.some(([propName]) => isHazardousPropertyName(propName)); // A plain object schema already enforces properties, required, and diff --git a/src/handlers/refinement/protoProperty.ts b/src/handlers/refinement/protoProperty.ts new file mode 100644 index 0000000..bd25d15 --- /dev/null +++ b/src/handlers/refinement/protoProperty.ts @@ -0,0 +1,212 @@ +import { z } from "zod/v4"; +import type { JSONSchema } from "zod/v4/core"; +import { RefinementHandler } from "../../core/types"; +import { convertJsonSchemaToZod } from "../../core/converter"; +import { convertSchemaRefs } from "../../core/refs"; +import { deepEqual, isValidWithSchema } from "../../core/utils"; + +/** + * Checks whether a schema constrains an own "__proto__" key, via `required` + * or a `properties` entry. The `properties` entry is read via + * Object.getOwnPropertyDescriptor; the "__proto__" accessor itself is never + * used. The boolean `true` schema constrains nothing, so it doesn't count — + * the reduced schema built below relies on that to avoid re-entering the + * handler. + */ +function schemaConstrainsProtoProperty(schema: JSONSchema.BaseSchema): boolean { + const objectSchema = schema as JSONSchema.ObjectSchema; + if (objectSchema.required?.includes("__proto__")) return true; + if (objectSchema.properties === undefined) return false; + const propertySchema = Object.getOwnPropertyDescriptor(objectSchema.properties, "__proto__")?.value; + return propertySchema !== undefined && propertySchema !== true; +} + +/** + * Checks whether object keywords apply to a value: JSON Schema object + * keywords only constrain objects (and not arrays). + */ +function isConstrainableObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +/** + * Validates "__proto__" entries in `properties` and `required` against the + * RAW input, before Zod's object parsing runs. + * + * This must happen outside the converted schema because Zod strips own + * "__proto__" keys from its parse output as a prototype-pollution defense, + * so any refinement running on that output can never see the key. For + * schemas that constrain "__proto__", the converted schema is therefore + * replaced wholesale: a reduced copy of the JSON schema — with "__proto__" + * dropped from `required`, its `properties` entry relaxed to `true` (so it + * still counts as a declared property for `additionalProperties`), and + * without keywords that must observe raw object keys (`enum`, `const`, + * `minProperties`, `maxProperties`, `propertyNames`, `dependentRequired`, + * `dependentSchemas`, and sibling references) — is converted normally, and + * the constraints removed from it are checked on the raw input in front of + * it. z.any() passes the input through + * untouched, so the checks observe the own "__proto__" key; `.pipe()` then + * runs the reduced schema, keeping every other constraint (and the + * stripped, pollution-safe output) intact. + * + * The schema's own "__proto__" property entry is read via + * Object.getOwnPropertyDescriptor, and the input's key likewise — the + * "__proto__" accessor itself is never used. The reduced schema no longer + * constrains "__proto__", so its conversion does not re-enter this handler. + */ +export class ProtoPropertyHandler implements RefinementHandler { + apply(zodSchema: z.ZodTypeAny, schema: JSONSchema.BaseSchema): z.ZodTypeAny { + if (!schemaConstrainsProtoProperty(schema)) { + return zodSchema; + } + + const objectSchema = schema as JSONSchema.ObjectSchema & { + dependentSchemas?: Record; + }; + const { + enum: enumValues, + const: constValue, + minProperties, + maxProperties, + required, + properties, + propertyNames, + dependentRequired, + dependentSchemas, + ...rest + } = objectSchema; + + // Build the reduced schema without ever touching a "__proto__" + // accessor: rest/spread and Object.fromEntries both create keys with + // define semantics. The "__proto__" property entry is relaxed to the + // boolean `true` schema rather than removed, so the reduced schema + // still treats it as a declared property (for additionalProperties) + // without constraining it. + const reduced: JSONSchema.ObjectSchema = { ...rest }; + if (required !== undefined) { + reduced.required = required.filter((name) => name !== "__proto__"); + } + if (properties !== undefined) { + reduced.properties = Object.fromEntries( + Object.entries(properties).map(([name, propertySchema]) => + name === "__proto__" ? [name, true] : [name, propertySchema], + ), + ); + } + // An empty `enum` is special-cased by type in the primitive + // EnumHandler and can't be affected by own "__proto__" keys, so it + // stays in the reduced schema; non-empty enums are checked raw. + const rawEnum = enumValues !== undefined && enumValues.length > 0 ? enumValues : undefined; + if (rawEnum === undefined && enumValues !== undefined) { + reduced.enum = enumValues; + } + // References on the copied node no longer have a document-analysis + // identity, and must inspect the raw value anyway. Resolve them from + // the original node, then omit them from the reduced conversion. + const rawRefs = convertSchemaRefs(schema); + delete (reduced as any).$ref; + delete (reduced as any).$dynamicRef; + + const protoRequired = required?.includes("__proto__") ?? false; + const protoPropertySchema = + properties !== undefined + ? Object.getOwnPropertyDescriptor(properties, "__proto__")?.value + : undefined; + const protoValueSchema = + protoPropertySchema !== undefined ? convertJsonSchemaToZod(protoPropertySchema) : undefined; + const propertyNameSchema = + propertyNames !== undefined ? convertJsonSchemaToZod(propertyNames) : undefined; + const dependentRequiredEntries = + dependentRequired !== undefined ? Object.entries(dependentRequired) : []; + const dependentSchemasEntries = + dependentSchemas !== undefined + ? Object.entries(dependentSchemas) + .filter(([, dependentSchema]) => dependentSchema !== undefined) + .map( + ([name, dependentSchema]) => + [ + name, + convertJsonSchemaToZod( + dependentSchema as JSONSchema.BaseSchema | boolean, + ), + ] as const, + ) + : []; + + return z + .any() + .superRefine((value: unknown, ctx) => { + if (isConstrainableObject(value)) { + const descriptor = Object.getOwnPropertyDescriptor(value, "__proto__"); + if (descriptor === undefined) { + if (protoRequired) { + ctx.addIssue({ code: "custom", message: "__proto__ property validation failed" }); + } + } else if ( + protoValueSchema !== undefined && + !protoValueSchema.safeParse(descriptor.value).success + ) { + ctx.addIssue({ code: "custom", message: "__proto__ property validation failed" }); + } + + const propertyCount = Object.keys(value).length; + if (minProperties !== undefined && propertyCount < minProperties) { + ctx.addIssue({ + code: "custom", + message: `Object must have at least ${minProperties} properties`, + }); + } + if (maxProperties !== undefined && propertyCount > maxProperties) { + ctx.addIssue({ + code: "custom", + message: `Object must have at most ${maxProperties} properties`, + }); + } + + if ( + propertyNameSchema !== undefined && + !Object.keys(value).every((name) => propertyNameSchema.safeParse(name).success) + ) { + ctx.addIssue({ code: "custom", message: "Property names validation failed" }); + } + if ( + !dependentRequiredEntries.every( + ([name, dependents]) => + !Object.prototype.hasOwnProperty.call(value, name) || + dependents.every((dependent) => + Object.prototype.hasOwnProperty.call(value, dependent), + ), + ) + ) { + ctx.addIssue({ + code: "custom", + message: "Missing dependent required properties", + }); + } + if ( + !dependentSchemasEntries.every( + ([name, dependentSchema]) => + !Object.prototype.hasOwnProperty.call(value, name) || + isValidWithSchema(dependentSchema, value), + ) + ) { + ctx.addIssue({ + code: "custom", + message: "Value does not satisfy a dependent schema", + }); + } + } + + if (rawEnum !== undefined && !rawEnum.some((member) => deepEqual(value, member))) { + ctx.addIssue({ code: "custom", message: "Value must match one of the enum values" }); + } + if (constValue !== undefined && !deepEqual(value, constValue)) { + ctx.addIssue({ code: "custom", message: "Value must equal the const value" }); + } + if (!rawRefs.every(({ schema: targetSchema }) => targetSchema.safeParse(value).success)) { + ctx.addIssue({ code: "custom", message: "Value does not match referenced schema" }); + } + }) + .pipe(convertJsonSchemaToZod(reduced)); + } +} diff --git a/src/handlers/refinement/protoRequired.ts b/src/handlers/refinement/protoRequired.ts deleted file mode 100644 index c0a5a38..0000000 --- a/src/handlers/refinement/protoRequired.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { z } from "zod/v4"; -import type { JSONSchema } from "zod/v4/core"; -import { RefinementHandler } from "../../core/types"; - -/** - * Special handler for when __proto__ is in required properties. - * This is needed because z.object().passthrough() strips __proto__ for security. - */ -export class ProtoRequiredHandler implements RefinementHandler { - apply(zodSchema: z.ZodTypeAny, schema: JSONSchema.BaseSchema): z.ZodTypeAny { - const objectSchema = schema as JSONSchema.ObjectSchema; - - // Only handle schemas with __proto__ in required and no explicit type - if (!objectSchema.required?.includes("__proto__") || schema.type !== undefined) { - return zodSchema; - } - - // Use z.any() to preserve __proto__ property - return z.any().refine( - (value: any) => this.validateRequired(value, objectSchema.required!), - { message: "Missing required properties" } - ); - } - - private validateRequired(value: any, required: string[]): boolean { - // Non-objects pass through - if (typeof value !== "object" || value === null || Array.isArray(value)) { - return true; - } - - // Check all required properties exist - return required.every(prop => - Object.prototype.hasOwnProperty.call(value, prop) - ); - } -} \ No newline at end of file diff --git a/src/object-properties.test.ts b/src/object-properties.test.ts index bd7e74e..c332a6d 100644 --- a/src/object-properties.test.ts +++ b/src/object-properties.test.ts @@ -37,13 +37,12 @@ describe("required property presence", () => { expect(zodSchema.safeParse(JSON.parse('{"toString": 12}')).success).toBe(false); }); - it("cannot enforce presence of required __proto__ on typed objects", () => { + it("enforces presence of required __proto__ on typed objects", () => { // Zod strips own "__proto__" keys when building parse output, so - // presence can't be checked there; the key is skipped rather than - // making every object fail. + // presence is checked against the raw input by ProtoPropertyHandler. const zodSchema = convertJsonSchemaToZod({ type: "object", required: ["__proto__"] } as any); expect(zodSchema.safeParse(JSON.parse('{"__proto__": 12}')).success).toBe(true); - expect(zodSchema.safeParse({}).success).toBe(true); + expect(zodSchema.safeParse({}).success).toBe(false); }); }); @@ -192,19 +191,17 @@ describe("properties named like Object.prototype members", () => { expect(zodSchema.safeParse({ plain: 1, extra: true }).success).toBe(false); }); - it("cannot validate __proto__ values on typed objects", () => { - // Zod strips own "__proto__" keys when building parse output, and the - // own-property refinement only sees that output, so a __proto__ - // property schema cannot reject bad values. This documents the - // limitation; the conformance suite skip list has the matching entry - // "properties whose names are Javascript object property names | - // __proto__ not valid". + it("validates __proto__ values on typed objects", () => { + // Zod strips own "__proto__" keys when building parse output, so the + // __proto__ property schema is enforced against the raw input by + // ProtoPropertyHandler before Zod's object parsing runs. const zodSchema = convertJsonSchemaToZod({ type: "object", properties: { ["__proto__"]: { type: "number" } }, } as any); expect(zodSchema.safeParse(JSON.parse('{"__proto__": 12}')).success).toBe(true); - expect(zodSchema.safeParse(JSON.parse('{"__proto__": "bad"}')).success).toBe(true); + expect(zodSchema.safeParse(JSON.parse('{"__proto__": "bad"}')).success).toBe(false); + expect(zodSchema.safeParse({}).success).toBe(true); }); it("validates extra keys against an additionalProperties schema alongside hazardous names", () => { diff --git a/src/proto-properties.test.ts b/src/proto-properties.test.ts new file mode 100644 index 0000000..b6dd39f --- /dev/null +++ b/src/proto-properties.test.ts @@ -0,0 +1,320 @@ +import { describe, it, expect } from "vitest"; +import { convertJsonSchemaToZod } from "./core/converter"; + +// Validation involving an own "__proto__" key must run on the RAW input: +// Zod strips own "__proto__" keys from its parse output as a +// prototype-pollution defense, so refinements running on that output never +// see the key. ProtoPropertyHandler wraps the converted schema so the check +// happens before Zod's object parsing. Inputs are built via JSON.parse or +// computed keys, and read back with Object.prototype.hasOwnProperty.call — +// never through obj.__proto__. +describe("__proto__ property validation on raw input", () => { + it("keeps enforcing required __proto__ on untyped schemas", () => { + const zodSchema = convertJsonSchemaToZod({ required: ["__proto__"] } as any); + expect(zodSchema.safeParse(JSON.parse('{"__proto__": 1}')).success).toBe(true); + expect(zodSchema.safeParse({}).success).toBe(false); + // Non-objects are not constrained by object keywords. + expect(zodSchema.safeParse("hi").success).toBe(true); + expect(zodSchema.safeParse(null).success).toBe(true); + expect(zodSchema.safeParse([1]).success).toBe(true); + }); + + it("validates a __proto__ property schema on untyped schemas", () => { + const zodSchema = convertJsonSchemaToZod({ + properties: { ["__proto__"]: { type: "number" } }, + } as any); + expect(zodSchema.safeParse(JSON.parse('{"__proto__": "bad"}')).success).toBe(false); + expect(zodSchema.safeParse(JSON.parse('{"__proto__": 3}')).success).toBe(true); + expect(zodSchema.safeParse(12).success).toBe(true); + expect(zodSchema.safeParse(null).success).toBe(true); + }); + + it("combines required __proto__ with a __proto__ property schema", () => { + const zodSchema = convertJsonSchemaToZod({ + type: "object", + properties: { ["__proto__"]: { type: "number" } }, + required: ["__proto__"], + } as any); + expect(zodSchema.safeParse({}).success).toBe(false); + expect(zodSchema.safeParse(JSON.parse('{"__proto__": "bad"}')).success).toBe(false); + expect(zodSchema.safeParse(JSON.parse('{"__proto__": 5}')).success).toBe(true); + }); + + it("enforces required __proto__ alongside ordinary required keys", () => { + const zodSchema = convertJsonSchemaToZod({ + type: "object", + properties: { x: { type: "number" } }, + required: ["__proto__", "x"], + } as any); + expect(zodSchema.safeParse(JSON.parse('{"__proto__": 1, "x": 2}')).success).toBe(true); + expect(zodSchema.safeParse(JSON.parse('{"x": 2}')).success).toBe(false); + expect(zodSchema.safeParse(JSON.parse('{"__proto__": 1}')).success).toBe(false); + }); + + it("reads a __proto__ property schema safely off a JSON.parse'd schema", () => { + const schema = JSON.parse( + '{"type":"object","properties":{"__proto__":{"type":"string"},"x":{"type":"number"}}}', + ); + const zodSchema = convertJsonSchemaToZod(schema); + expect(zodSchema.safeParse(JSON.parse('{"__proto__":"ok","x":1}')).success).toBe(true); + expect(zodSchema.safeParse(JSON.parse('{"__proto__":5,"x":1}')).success).toBe(false); + expect(zodSchema.safeParse(JSON.parse('{"x":"bad"}')).success).toBe(false); + }); + + it("still strips own __proto__ keys from parse output (pollution defense)", () => { + const zodSchema = convertJsonSchemaToZod({ + type: "object", + properties: { ["__proto__"]: { type: "number" } }, + } as any); + const result = zodSchema.safeParse(JSON.parse('{"__proto__": 12, "x": 1}')); + expect(result.success).toBe(true); + expect(Object.prototype.hasOwnProperty.call(result.data, "__proto__")).toBe(false); + expect(Object.getPrototypeOf(result.data)).toBe(Object.prototype); + }); + + it("ignores a __proto__ property entry whose schema is undefined", () => { + const zodSchema = convertJsonSchemaToZod({ + properties: { ["__proto__"]: undefined }, + } as any); + expect(zodSchema.safeParse(JSON.parse('{"__proto__": "anything"}')).success).toBe(true); + }); + + it("supports a boolean false schema for __proto__", () => { + const zodSchema = convertJsonSchemaToZod({ + properties: { ["__proto__"]: false }, + } as any); + expect(zodSchema.safeParse(JSON.parse('{"__proto__": 1}')).success).toBe(false); + expect(zodSchema.safeParse({}).success).toBe(true); + }); + + it("treats a boolean true schema for __proto__ as unconstrained", () => { + const zodSchema = convertJsonSchemaToZod({ + properties: { ["__proto__"]: true }, + } as any); + expect(zodSchema.safeParse(JSON.parse('{"__proto__": "anything"}')).success).toBe(true); + expect(zodSchema.safeParse({}).success).toBe(true); + }); + + it("keeps a declared __proto__ property exempt from additionalProperties: false", () => { + const zodSchema = convertJsonSchemaToZod( + JSON.parse( + '{"type":"object","properties":{"__proto__":{"type":"number"},"a":{"type":"string"}},"additionalProperties":false}', + ), + ); + expect(zodSchema.safeParse(JSON.parse('{"__proto__": 1, "a": "x"}')).success).toBe(true); + expect(zodSchema.safeParse(JSON.parse('{"__proto__": 2}')).success).toBe(true); + expect(zodSchema.safeParse(JSON.parse('{"__proto__": "bad"}')).success).toBe(false); + expect(zodSchema.safeParse(JSON.parse('{"b": 1}')).success).toBe(false); + }); + + it("does not reject non-objects when __proto__ is required on another type", () => { + const zodSchema = convertJsonSchemaToZod({ + type: "string", + required: ["__proto__"], + } as any); + expect(zodSchema.safeParse("hi").success).toBe(true); + }); +}); + +// Refinements that inspect the parsed value (complex enum/const deep +// equality, minProperties/maxProperties counting) would otherwise run on +// Zod's __proto__-stripped output. When a schema constrains "__proto__", +// they must run on the raw input instead, so inputs that are valid per +// JSON Schema keep being accepted. +describe("__proto__ combined with value-inspecting keywords", () => { + it("matches enum members that carry an own __proto__ key", () => { + const zodSchema = convertJsonSchemaToZod({ + required: ["__proto__"], + enum: [JSON.parse('{"__proto__": 1}'), "a"], + } as any); + expect(zodSchema.safeParse(JSON.parse('{"__proto__": 1}')).success).toBe(true); + expect(zodSchema.safeParse("a").success).toBe(true); + expect(zodSchema.safeParse(JSON.parse('{"__proto__": 2}')).success).toBe(false); + expect(zodSchema.safeParse("b").success).toBe(false); + expect(zodSchema.safeParse({}).success).toBe(false); + }); + + it("matches enum members when __proto__ has a property schema", () => { + const zodSchema = convertJsonSchemaToZod({ + properties: { ["__proto__"]: { type: "number" } }, + enum: [JSON.parse('{"__proto__": 1}')], + } as any); + expect(zodSchema.safeParse(JSON.parse('{"__proto__": 1}')).success).toBe(true); + // Passes the property schema but is not an enum member. + expect(zodSchema.safeParse(JSON.parse('{"__proto__": 2}')).success).toBe(false); + }); + + it("keeps the empty-enum special cases when __proto__ is constrained", () => { + // enum: [] without a type rejects everything; with a type it acts as + // no enum constraint. Own "__proto__" keys can't change either, so + // the __proto__ checks compose with the usual empty-enum handling. + const untyped = convertJsonSchemaToZod({ required: ["__proto__"], enum: [] } as any); + expect(untyped.safeParse(JSON.parse('{"__proto__": 1}')).success).toBe(false); + + const typed = convertJsonSchemaToZod({ + type: "object", + required: ["__proto__"], + enum: [], + } as any); + expect(typed.safeParse(JSON.parse('{"__proto__": 1}')).success).toBe(true); + expect(typed.safeParse({}).success).toBe(false); + }); + + it("matches a const value that carries an own __proto__ key", () => { + const zodSchema = convertJsonSchemaToZod({ + required: ["__proto__"], + const: JSON.parse('{"__proto__": 1}'), + } as any); + expect(zodSchema.safeParse(JSON.parse('{"__proto__": 1}')).success).toBe(true); + expect(zodSchema.safeParse(JSON.parse('{"__proto__": 2}')).success).toBe(false); + expect(zodSchema.safeParse("x").success).toBe(false); + }); + + it("counts own __proto__ keys for minProperties on untyped schemas", () => { + const one = convertJsonSchemaToZod({ + required: ["__proto__"], + minProperties: 1, + } as any); + expect(one.safeParse(JSON.parse('{"__proto__": 1}')).success).toBe(true); + // Object keywords do not constrain non-objects. + expect(one.safeParse("hi").success).toBe(true); + + const two = convertJsonSchemaToZod({ + required: ["__proto__"], + minProperties: 2, + } as any); + expect(two.safeParse(JSON.parse('{"__proto__": 1, "x": 2}')).success).toBe(true); + expect(two.safeParse(JSON.parse('{"__proto__": 1}')).success).toBe(false); + }); + + it("counts own __proto__ keys for maxProperties on untyped schemas", () => { + const zodSchema = convertJsonSchemaToZod({ + required: ["__proto__"], + maxProperties: 1, + } as any); + expect(zodSchema.safeParse(JSON.parse('{"__proto__": 1}')).success).toBe(true); + expect(zodSchema.safeParse(JSON.parse('{"__proto__": 1, "x": 2}')).success).toBe(false); + expect(zodSchema.safeParse("hi").success).toBe(true); + }); + + it("counts own __proto__ keys for minProperties on typed object schemas", () => { + const zodSchema = convertJsonSchemaToZod({ + type: "object", + required: ["__proto__"], + minProperties: 1, + } as any); + expect(zodSchema.safeParse(JSON.parse('{"__proto__": 1}')).success).toBe(true); + expect(zodSchema.safeParse(JSON.parse('{"__proto__": 1, "x": 2}')).success).toBe(true); + }); +}); + +describe("__proto__ combined with required properties that have defaults", () => { + // PropertiesHandler pipes a raw-input required check in front of the + // object schema, and ProtoPropertyHandler pipes its raw __proto__ check + // in front of everything. These tests pin down that the nested pipes + // cooperate. + it("enforces both a required-with-default property and a __proto__ value schema", () => { + const zodSchema = convertJsonSchemaToZod({ + type: "object", + properties: { + foo: { type: "string", default: "x" }, + ["__proto__"]: { type: "number" }, + }, + required: ["foo"], + } as any); + + expect(zodSchema.safeParse({}).success).toBe(false); + expect(zodSchema.safeParse(JSON.parse('{"foo": "y", "__proto__": "bad"}')).success).toBe(false); + expect(zodSchema.safeParse(JSON.parse('{"foo": "y", "__proto__": 3}')).success).toBe(true); + }); + + it("enforces required __proto__ alongside a required-with-default property", () => { + const zodSchema = convertJsonSchemaToZod({ + type: "object", + required: ["__proto__", "foo"], + properties: { + foo: { type: "string", default: "x" }, + }, + } as any); + + expect(zodSchema.safeParse({}).success).toBe(false); + expect(zodSchema.safeParse(JSON.parse('{"__proto__": 1}')).success).toBe(false); + expect(zodSchema.safeParse(JSON.parse('{"__proto__": 1, "foo": "y"}')).success).toBe(true); + }); +}); + +describe("__proto__ combined with references", () => { + it("preserves a sibling $ref constraint", () => { + const zodSchema = convertJsonSchemaToZod({ + type: "object", + properties: { ["__proto__"]: { type: "number" } }, + $ref: "#/$defs/needsFoo", + $defs: { + needsFoo: { + type: "object", + properties: { + foo: { type: "number" }, + ["__proto__"]: { type: "number" }, + }, + required: ["foo", "__proto__"], + }, + }, + } as any); + + expect(zodSchema.safeParse(JSON.parse('{"__proto__": 1, "foo": 2}')).success).toBe(true); + expect(zodSchema.safeParse(JSON.parse('{"__proto__": 1}')).success).toBe(false); + expect(zodSchema.safeParse(JSON.parse('{"__proto__": 1, "foo": "bad"}')).success).toBe(false); + }); + + it("keeps ignoring a no-progress self-reference", () => { + const zodSchema = convertJsonSchemaToZod({ + required: ["__proto__"], + $ref: "#", + } as any); + + expect(zodSchema.safeParse(JSON.parse('{"__proto__": 1}')).success).toBe(true); + expect(zodSchema.safeParse({}).success).toBe(false); + }); +}); + +describe("__proto__ combined with other object keywords", () => { + it("applies propertyNames to an own __proto__ key", () => { + const zodSchema = convertJsonSchemaToZod({ + properties: { ["__proto__"]: { type: "number" } }, + propertyNames: { not: { const: "__proto__" } }, + } as any); + + expect(zodSchema.safeParse(JSON.parse('{"ok": 1}')).success).toBe(true); + expect(zodSchema.safeParse(JSON.parse('{"__proto__": 1}')).success).toBe(false); + }); + + it("uses an own __proto__ key as a dependentRequired trigger", () => { + const zodSchema = convertJsonSchemaToZod({ + properties: { ["__proto__"]: { type: "number" } }, + dependentRequired: { ["__proto__"]: ["foo"] }, + } as any); + + expect(zodSchema.safeParse(JSON.parse('{"__proto__": 1, "foo": 2}')).success).toBe(true); + expect(zodSchema.safeParse(JSON.parse('{"__proto__": 1}')).success).toBe(false); + }); + + it("allows an own __proto__ key to satisfy dependentRequired", () => { + const zodSchema = convertJsonSchemaToZod({ + properties: { ["__proto__"]: { type: "number" } }, + dependentRequired: { foo: ["__proto__"] }, + } as any); + + expect(zodSchema.safeParse(JSON.parse('{"foo": 2, "__proto__": 1}')).success).toBe(true); + expect(zodSchema.safeParse(JSON.parse('{"foo": 2}')).success).toBe(false); + }); + + it("uses an own __proto__ key as a dependentSchemas trigger", () => { + const zodSchema = convertJsonSchemaToZod({ + properties: { ["__proto__"]: { type: "number" } }, + dependentSchemas: { ["__proto__"]: { required: ["foo"] } }, + } as any); + + expect(zodSchema.safeParse(JSON.parse('{"__proto__": 1, "foo": 2}')).success).toBe(true); + expect(zodSchema.safeParse(JSON.parse('{"__proto__": 1}')).success).toBe(false); + }); +});