From 48ce9a800e7ea0282515887271a36401077c338b Mon Sep 17 00:00:00 2001 From: Matteo Collina Date: Thu, 20 Aug 2026 02:29:43 +0000 Subject: [PATCH] fix: limit schema compilation depth --- README.md | 6 +- index.js | 293 ++++++++++++++++++++++++++++++-------- test/schema-depth.test.js | 227 +++++++++++++++++++++++++++++ types/index.d.ts | 6 + types/index.tst.ts | 4 + 5 files changed, 477 insertions(+), 59 deletions(-) create mode 100644 test/schema-depth.test.js diff --git a/README.md b/README.md index 63f8a4ed..a59f7a53 100644 --- a/README.md +++ b/README.md @@ -121,13 +121,17 @@ const fastJson = require('fast-json-stringify') const stringify = fastJson(mySchema, { schema: { ... }, ajv: { ... }, - rounding: 'ceil' + rounding: 'ceil', + maxDepth: 100 }) ``` - `schema`: external schemas references by $ref property. [More details](#ref) - `ajv`: [ajv v8 instance's settings](https://ajv.js.org/options.html) for those properties that require `ajv`. [More details](#anyof) - `rounding`: setup how the `integer` types will be rounded when not integers. [More details](#integer) +- `maxDepth`: maximum number of nested schema levels allowed during compilation. +Defaults to `100` and may be set from `0` to `100`. Schemas that exceed this limit +are rejected before compilation. - `largeArrayMechanism`: set the mechanism that should be used to handle large (by default `20000` or more items) arrays. [More details](#largearrays) - `compileValidators`: when `true`, the `ajv` validators used by `anyOf`, `oneOf` and diff --git a/index.js b/index.js index e692d5e9..279a975d 100644 --- a/index.js +++ b/index.js @@ -13,8 +13,32 @@ const mergeSchemas = require('./lib/merge-schemas') let largeArraySize = 2e4 let largeArrayMechanism = 'default' +const DEFAULT_MAX_DEPTH = 100 const NAMED_FRAGMENT_REF = /^#[a-z_][-\w._]*$/i +const singleSchemaKeywords = [ + 'additionalItems', + 'additionalProperties', + 'contains', + 'else', + 'if', + 'not', + 'propertyNames', + 'then' +] + +const arraySchemaKeywords = [ + 'allOf', + 'anyOf', + 'oneOf' +] + +const objectSchemaKeywords = [ + 'definitions', + 'patternProperties', + 'properties' +] + const serializerFns = ` const { asString, @@ -44,6 +68,113 @@ const validLargeArrayMechanisms = new Set([ let schemaIdCounter = 0 +function getMaxDepth (options) { + const maxDepth = options.maxDepth === undefined ? DEFAULT_MAX_DEPTH : options.maxDepth + if (!Number.isInteger(maxDepth) || maxDepth < 0 || maxDepth > DEFAULT_MAX_DEPTH) { + throw new Error(`Unsupported max schema depth ${maxDepth}. Expected an integer between 0 and ${DEFAULT_MAX_DEPTH}.`) + } + return maxDepth +} + +function schemaDepthError (maxDepth, name) { + const prefix = name ? `"${name}" ` : '' + return new Error(`${prefix}schema exceeds maximum depth of ${maxDepth}`) +} + +function assertObjectDepth (schema, maxDepth, name) { + const stack = [{ value: schema, depth: 0, exit: false }] + const path = new WeakSet() + + while (stack.length > 0) { + const entry = stack.pop() + const value = entry.value + + if (typeof value !== 'object' || value === null) continue + + // Schema maps and arrays add at most one container between schema nodes. + // Keep dependency traversals within the same stack-safety envelope while + // still allowing maxDepth schema nodes nested through `properties`. + if (entry.depth > maxDepth * 2) { + throw schemaDepthError(maxDepth, name) + } + + if (entry.exit) { + path.delete(value) + continue + } + + if (path.has(value)) { + throw new Error('schema contains a circular object reference; use $ref instead') + } + path.add(value) + stack.push({ value, depth: entry.depth, exit: true }) + + for (const key in value) { + stack.push({ value: value[key], depth: entry.depth + 1, exit: false }) + } + } +} + +function assertSchemaDepth (schema, maxDepth, name) { + const stack = [{ schema, depth: 0 }] + + while (stack.length > 0) { + const entry = stack.pop() + const currentSchema = entry.schema + + if (entry.depth > maxDepth) { + throw schemaDepthError(maxDepth, name) + } + + if (typeof currentSchema !== 'object' || currentSchema === null) continue + + const nestedDepth = entry.depth + 1 + + for (const keyword of singleSchemaKeywords) { + if (typeof currentSchema[keyword] === 'object' && currentSchema[keyword] !== null) { + stack.push({ schema: currentSchema[keyword], depth: nestedDepth }) + } + } + + for (const keyword of arraySchemaKeywords) { + const schemas = currentSchema[keyword] + if (Array.isArray(schemas)) { + for (const nestedSchema of schemas) { + stack.push({ schema: nestedSchema, depth: nestedDepth }) + } + } + } + + const items = currentSchema.items + if (Array.isArray(items)) { + for (const nestedSchema of items) { + stack.push({ schema: nestedSchema, depth: nestedDepth }) + } + } else if (typeof items === 'object' && items !== null) { + stack.push({ schema: items, depth: nestedDepth }) + } + + for (const keyword of objectSchemaKeywords) { + const schemas = currentSchema[keyword] + if (typeof schemas === 'object' && schemas !== null) { + for (const key of Object.keys(schemas)) { + stack.push({ schema: schemas[key], depth: nestedDepth }) + } + } + } + + const dependencies = currentSchema.dependencies + if (typeof dependencies === 'object' && dependencies !== null) { + for (const key of Object.keys(dependencies)) { + const dependency = dependencies[key] + if (!Array.isArray(dependency)) { + stack.push({ schema: dependency, depth: nestedDepth }) + } + } + } + } +} + function isValidSchema (schema, name) { if (!validate(schema)) { if (name) { @@ -59,27 +190,64 @@ function isValidSchema (schema, name) { } function resolveRef (context, location) { - const ref = location.schema.$ref + const seen = new Set() + let depth = 0 - let hashIndex = ref.indexOf('#') - if (hashIndex === -1) { - hashIndex = ref.length - } + while (location.schema.$ref !== undefined) { + const ref = location.schema.$ref + const locationRef = location.getSchemaRef() + + if (seen.has(locationRef)) { + throw new Error(`Cannot resolve circular reference "${ref}"`) + } + seen.add(locationRef) - const schemaId = ref.slice(0, hashIndex) || location.schemaId - const jsonPointer = ref.slice(hashIndex) || '#' + if (depth++ > context.maxDepth) { + throw schemaDepthError(context.maxDepth) + } + + let hashIndex = ref.indexOf('#') + if (hashIndex === -1) { + hashIndex = ref.length + } + + const schemaId = ref.slice(0, hashIndex) || location.schemaId + const jsonPointer = ref.slice(hashIndex) || '#' + + const schema = context.refResolver.getSchema(schemaId, jsonPointer) + if (schema === null) { + throw new Error(`Cannot find reference "${ref}"`) + } - const schema = context.refResolver.getSchema(schemaId, jsonPointer) - if (schema === null) { - throw new Error(`Cannot find reference "${ref}"`) + location = new Location(schema, schemaId, jsonPointer) } - const newLocation = new Location(schema, schemaId, jsonPointer) - if (schema.$ref !== undefined) { - return resolveRef(context, newLocation) + return location +} + +function getSchemaDependencies (refResolver, schemaId) { + const dependencies = {} + const processed = new Set() + const pending = [schemaId] + + while (pending.length > 0) { + const currentSchemaId = pending.pop() + if (processed.has(currentSchemaId)) continue + processed.add(currentSchemaId) + + for (const ref of refResolver.getSchemaRefs(currentSchemaId)) { + const dependencySchemaId = ref.schemaId + if ( + dependencySchemaId === currentSchemaId || + dependencies[dependencySchemaId] !== undefined + ) continue + + dependencies[dependencySchemaId] = refResolver.getSchema(dependencySchemaId) + pending.push(dependencySchemaId) + } } - return newLocation + return dependencies } function getMergedLocation (context, mergedSchemaId) { @@ -148,10 +316,13 @@ function getValidatorSchemaRef (context, location) { } function build (schema, options) { - isValidSchema(schema) - options = options || {} + const maxDepth = getMaxDepth(options) + assertObjectDepth(schema, maxDepth) + assertSchemaDepth(schema, maxDepth) + isValidSchema(schema) + const context = { functions: [], functionsCounter: 0, @@ -162,6 +333,7 @@ function build (schema, options) { validatorSchemasIds: new Set(), validatorSchemaRefs: new Set(), mergedSchemasIds: new Map(), + maxDepth, recursiveSchemas: new Set(), recursivePaths: new Set(), buildingSet: new Set(), @@ -178,6 +350,8 @@ function build (schema, options) { const schema = options.schema[key] const schemaId = getSchemaId(schema, key) if (!context.refResolver.hasSchema(schemaId)) { + assertObjectDepth(schema, maxDepth, key) + assertSchemaDepth(schema, maxDepth, key) isValidSchema(schema, key) context.refResolver.addSchema(schema, key) } @@ -262,7 +436,7 @@ function build (schema, options) { const schema = context.refResolver.getSchema(schemaId) validator.addSchema(schema, schemaId) - const dependencies = context.refResolver.getSchemaDependencies(schemaId) + const dependencies = getSchemaDependencies(context.refResolver, schemaId) for (const [schemaId, schema] of Object.entries(dependencies)) { validator.addSchema(schema, schemaId) } @@ -1034,9 +1208,13 @@ function buildSingleTypeSerializer (context, location, input) { function detectRecursiveSchemas (context, location) { const pathStack = new Set() - function traverse (location) { + function traverse (location, depth) { const schema = location.schema - if (typeof schema !== 'object' || schema === null) return + if (!schema || typeof schema !== 'object') return + + if (depth > context.maxDepth) { + throw schemaDepthError(context.maxDepth) + } const schemaId = location.schemaId || '' const jsonPointer = location.jsonPointer || '' @@ -1057,65 +1235,64 @@ function detectRecursiveSchemas (context, location) { if (schema.$ref) { try { const res = resolveRef(context, location) - traverse(res) + traverse(res, depth) } catch (err) { + if (!err.message.startsWith('Cannot find reference')) throw err // Validation will handle missing refs later } } - if (schema.properties) { - const propertiesLocation = location.getPropertyLocation('properties') - for (const key in schema.properties) { - traverse(propertiesLocation.getPropertyLocation(key)) - } - } - if (schema.additionalProperties && typeof schema.additionalProperties === 'object') { - traverse(location.getPropertyLocation('additionalProperties')) - } - if (schema.patternProperties) { - const patternPropertiesLocation = location.getPropertyLocation('patternProperties') - for (const key in schema.patternProperties) { - traverse(patternPropertiesLocation.getPropertyLocation(key)) + const nestedDepth = depth + 1 + + for (const keyword of singleSchemaKeywords) { + if (typeof schema[keyword] === 'object' && schema[keyword] !== null) { + traverse(location.getPropertyLocation(keyword), nestedDepth) } } - if (schema.items) { - const itemsLocation = location.getPropertyLocation('items') - if (Array.isArray(schema.items)) { - for (let i = 0; i < schema.items.length; i++) { - traverse(itemsLocation.getPropertyLocation(i)) + + for (const keyword of arraySchemaKeywords) { + const schemas = schema[keyword] + if (Array.isArray(schemas)) { + const schemasLocation = location.getPropertyLocation(keyword) + for (let i = 0; i < schemas.length; i++) { + traverse(schemasLocation.getPropertyLocation(i), nestedDepth) } - } else { - traverse(itemsLocation) } } - if (schema.additionalItems && typeof schema.additionalItems === 'object') { - traverse(location.getPropertyLocation('additionalItems')) - } - if (schema.oneOf) { - const oneOfLocation = location.getPropertyLocation('oneOf') - for (let i = 0; i < schema.oneOf.length; i++) { - traverse(oneOfLocation.getPropertyLocation(i)) + const items = schema.items + if (Array.isArray(items)) { + const itemsLocation = location.getPropertyLocation('items') + for (let i = 0; i < items.length; i++) { + traverse(itemsLocation.getPropertyLocation(i), nestedDepth) } + } else if (typeof items === 'object' && items !== null) { + traverse(location.getPropertyLocation('items'), nestedDepth) } - if (schema.anyOf) { - const anyOfLocation = location.getPropertyLocation('anyOf') - for (let i = 0; i < schema.anyOf.length; i++) { - traverse(anyOfLocation.getPropertyLocation(i)) + + for (const keyword of objectSchemaKeywords) { + const schemas = schema[keyword] + if (typeof schemas === 'object' && schemas !== null) { + const schemasLocation = location.getPropertyLocation(keyword) + for (const key in schemas) { + traverse(schemasLocation.getPropertyLocation(key), nestedDepth) + } } } - if (schema.allOf) { - const allOfLocation = location.getPropertyLocation('allOf') - for (let i = 0; i < schema.allOf.length; i++) { - traverse(allOfLocation.getPropertyLocation(i)) + + const dependencies = schema.dependencies + if (typeof dependencies === 'object' && dependencies !== null) { + const dependenciesLocation = location.getPropertyLocation('dependencies') + for (const key in dependencies) { + if (!Array.isArray(dependencies[key])) { + traverse(dependenciesLocation.getPropertyLocation(key), nestedDepth) + } } } - if (schema.then) traverse(location.getPropertyLocation('then')) - if (schema.else) traverse(location.getPropertyLocation('else')) pathStack.delete(fullPath) } - traverse(location) + traverse(location, 0) } function buildConstSerializer (location, input) { diff --git a/test/schema-depth.test.js b/test/schema-depth.test.js new file mode 100644 index 00000000..1f570dc8 --- /dev/null +++ b/test/schema-depth.test.js @@ -0,0 +1,227 @@ +'use strict' + +const { test } = require('node:test') +const build = require('..') + +function createNestedSchema (depth) { + let schema = { type: 'string' } + + for (let i = 0; i < depth; i++) { + schema = { + type: 'object', + properties: { value: schema } + } + } + + return schema +} + +function createNestedNotSchema (depth) { + let schema = { type: 'string' } + for (let i = 0; i < depth; i++) schema = { not: schema } + return { anyOf: [schema] } +} + +function createReferencedNotSchema (depth) { + const definitions = {} + for (let i = 0; i < depth; i++) { + definitions[`schema-${i}`] = { + not: i + 1 < depth + ? { $ref: `#/definitions/schema-${i + 1}` } + : { type: 'string' } + } + } + return { + definitions, + anyOf: [{ $ref: '#/definitions/schema-0' }] + } +} + +test('rejects schemas deeper than the default maximum without exhausting the stack', (t) => { + t.assert.throws( + () => build(createNestedSchema(101)), + (error) => { + t.assert.equal(error.constructor, Error) + t.assert.equal(error.message, 'schema exceeds maximum depth of 100') + return true + } + ) + t.assert.doesNotThrow(() => build(createNestedSchema(100))) +}) + +test('accepted boundary is safe for eager and lazy Ajv compilation', (t) => { + for (const createSchema of [createNestedNotSchema, createReferencedNotSchema]) { + for (const compileValidators of [false, true]) { + const stringify = build(createSchema(99), { compileValidators }) + t.assert.doesNotThrow(() => stringify(42)) + } + } + + t.assert.throws( + () => build(createReferencedNotSchema(100)), + { message: 'schema exceeds maximum depth of 100' } + ) +}) + +test('rejects deeply nested non-schema values before dependency traversal', (t) => { + let defaultValue = 'value' + for (let i = 0; i < 2000; i++) defaultValue = { value: defaultValue } + + t.assert.throws( + () => build({ allOf: [{ type: 'object', default: defaultValue }] }), + { message: 'schema exceeds maximum depth of 100' } + ) +}) + +test('supports a configurable maximum schema depth', (t) => { + t.assert.throws( + () => build(createNestedSchema(3), { maxDepth: 2 }), + { message: 'schema exceeds maximum depth of 2' } + ) + t.assert.throws( + () => build({ not: { not: { not: { type: 'string' } } } }, { maxDepth: 2 }), + { message: 'schema exceeds maximum depth of 2' } + ) + t.assert.doesNotThrow(() => build(createNestedSchema(3), { maxDepth: 3 })) + t.assert.doesNotThrow(() => build({ type: 'string' }, { maxDepth: 0 })) + t.assert.doesNotThrow(() => build(false, { maxDepth: 0 })) + t.assert.throws( + () => build({ type: 'object', properties: { value: true } }, { maxDepth: 0 }), + { message: 'schema exceeds maximum depth of 0' } + ) +}) + +test('rejects invalid maximum schema depths', (t) => { + t.assert.throws(() => build(null), { message: /^schema is invalid:/ }) + t.assert.throws( + () => build({}, { maxDepth: '100' }), + { message: 'Unsupported max schema depth 100. Expected an integer between 0 and 100.' } + ) + t.assert.throws( + () => build({}, { maxDepth: -1 }), + { message: 'Unsupported max schema depth -1. Expected an integer between 0 and 100.' } + ) + t.assert.throws( + () => build({}, { maxDepth: 101 }), + { message: 'Unsupported max schema depth 101. Expected an integer between 0 and 100.' } + ) +}) + +test('checks schema dependencies without treating property lists as schemas', (t) => { + t.assert.doesNotThrow(() => build({ + type: 'object', + dependencies: { + enabled: { properties: { value: { type: 'string' } } }, + value: ['enabled'] + } + }, { maxDepth: 2 })) +}) + +test('checks external schemas before validating them recursively', (t) => { + t.assert.throws( + () => build({ type: 'string' }, { + maxDepth: 1, + schema: { external: createNestedSchema(2) } + }), + { message: '"external" schema exceeds maximum depth of 1' } + ) +}) + +test('counts nested schema levels reached through external references', (t) => { + const externalSchemas = { + first: { + $id: 'first', + type: 'object', + properties: { value: { $ref: 'second' } } + }, + second: { + $id: 'second', + type: 'object', + properties: { value: { $ref: 'third' } } + }, + third: { + $id: 'third', + type: 'object', + properties: { value: { type: 'string' } } + } + } + + t.assert.throws( + () => build({ $ref: 'first' }, { maxDepth: 1, schema: externalSchemas }), + { message: 'schema exceeds maximum depth of 1' } + ) + t.assert.doesNotThrow(() => build({ $ref: 'first' }, { maxDepth: 3, schema: externalSchemas })) +}) + +test('limits reference chains reached through validator-only keywords', (t) => { + const externalSchemas = {} + const dependencyCount = 1000 + + for (let i = 0; i < dependencyCount; i++) { + externalSchemas[`dependency-${i}`] = { + $id: `dependency-${i}`, + ...(i + 1 < dependencyCount + ? { $ref: `dependency-${i + 1}` } + : { type: 'string' }) + } + } + + const schema = { + anyOf: [{ not: { $ref: 'dependency-0' } }] + } + + for (const compileValidators of [false, true]) { + t.assert.throws( + () => build(schema, { compileValidators, schema: externalSchemas }), + { message: 'schema exceeds maximum depth of 100' } + ) + } +}) + +test('walks references in non-schema values without recursive dependency discovery', (t) => { + const externalSchemas = {} + const dependencyCount = 10000 + + for (let i = 0; i < dependencyCount; i++) { + externalSchemas[`annotation-${i}`] = { + $id: `annotation-${i}`, + $ref: i + 1 < dependencyCount ? `annotation-${i + 1}` : 'root' + } + } + + t.assert.doesNotThrow(() => build({ + $id: 'root', + type: 'string', + anyOf: [{ type: 'string' }], + default: { $ref: 'annotation-0' } + }, { schema: externalSchemas })) +}) + +test('limits and detects reference-only chains', (t) => { + const externalSchemas = { + first: { $id: 'first', $ref: 'second' }, + second: { $id: 'second', $ref: 'third' }, + third: { $id: 'third', type: 'string' } + } + + t.assert.throws( + () => build({ $ref: 'first' }, { maxDepth: 1, schema: externalSchemas }), + { message: 'schema exceeds maximum depth of 1' } + ) + + externalSchemas.third = { $id: 'third', $ref: 'first' } + t.assert.throws( + () => build({ $ref: 'first' }, { schema: externalSchemas }), + { message: 'Cannot resolve circular reference "second"' } + ) +}) + +test('rejects circular object graphs before schema validation', (t) => { + const schema = { type: 'object', properties: {} } + schema.properties.self = schema + + t.assert.throws( + () => build(schema), + { message: 'schema contains a circular object reference; use $ref instead' } + ) +}) diff --git a/types/index.d.ts b/types/index.d.ts index e4a17778..a92c73e6 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -162,6 +162,12 @@ declare namespace build { * @default 'trunc' */ rounding?: 'ceil' | 'floor' | 'round' | 'trunc' + /** + * Maximum number of nested schema levels allowed during compilation (0–100) + * + * @default 100 + */ + maxDepth?: number /** * Running mode of fast-json-stringify */ diff --git a/types/index.tst.ts b/types/index.tst.ts index 1508ef9c..883ffba7 100644 --- a/types/index.tst.ts +++ b/types/index.tst.ts @@ -258,6 +258,10 @@ build({}, { largeArraySize: '2e4' }) build({}, { largeArraySize: 2n }) expect(build).type.not.toBeCallableWith({} as Schema, { largeArraySize: ['asdf'] }) +// maxDepth +build({}, { maxDepth: 100 }) +expect(build).type.not.toBeCallableWith({} as Schema, { maxDepth: '500' }) + // compileValidators build({}, { compileValidators: true }) build({}, { compileValidators: false })