Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
1 change: 0 additions & 1 deletion failing-tests-skip-list.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
9 changes: 7 additions & 2 deletions src/core/converter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -95,7 +95,6 @@ const refinementHandlers: RefinementHandler[] = [
new RefHandler(),

// Handle special cases first
new ProtoRequiredHandler(),
new EnumComplexHandler(),
new ConstComplexHandler(),

Expand All @@ -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(),
];
Expand Down
7 changes: 3 additions & 4 deletions src/handlers/primitive/object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
6 changes: 3 additions & 3 deletions src/handlers/refinement/objectProperties.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
212 changes: 212 additions & 0 deletions src/handlers/refinement/protoProperty.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> {
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<string, JSONSchema.BaseSchema | boolean | undefined>;
};
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));
}
}
36 changes: 0 additions & 36 deletions src/handlers/refinement/protoRequired.ts

This file was deleted.

Loading
Loading