diff --git a/index.js b/index.js index b0f82f93..da148eb8 100644 --- a/index.js +++ b/index.js @@ -15,6 +15,26 @@ let largeArrayMechanism = 'default' const NAMED_FRAGMENT_REF = /^#[a-z_][-\w._]*$/i +const schemaArrayKeywords = ['allOf', 'anyOf', 'oneOf'] + +const schemaMapKeywords = [ + '$defs', + 'definitions', + 'patternProperties', + 'properties' +] + +const schemaValueKeywords = [ + 'additionalItems', + 'additionalProperties', + 'contains', + 'else', + 'if', + 'not', + 'propertyNames', + 'then' +] + const serializerFns = ` const { asString, @@ -94,6 +114,54 @@ function getSchemaId (schema, rootSchemaId) { return rootSchemaId } +function validateSchemaIdsForAjvCodeGeneration (schema, schemaId, seen) { + if (typeof schema !== 'object' || schema === null || seen.has(schema)) return + + seen.add(schema) + // Ajv emits the active schema ID inside a block-comment sourceURL. + const id = schema[schemaId] + if (typeof id === 'string' && id.includes('*/')) { + throw new Error(`Schema ${schemaId} must not contain "*/" when Ajv source code generation is enabled`) + } + + for (const keyword of schemaMapKeywords) { + const schemas = schema[keyword] + if (typeof schemas === 'object' && schemas !== null && !Array.isArray(schemas)) { + for (const nestedSchema of Object.values(schemas)) { + validateSchemaIdsForAjvCodeGeneration(nestedSchema, schemaId, seen) + } + } + } + + for (const keyword of schemaValueKeywords) { + validateSchemaIdsForAjvCodeGeneration(schema[keyword], schemaId, seen) + } + + if (Array.isArray(schema.items)) { + for (const item of schema.items) { + validateSchemaIdsForAjvCodeGeneration(item, schemaId, seen) + } + } else { + validateSchemaIdsForAjvCodeGeneration(schema.items, schemaId, seen) + } + + for (const keyword of schemaArrayKeywords) { + if (Array.isArray(schema[keyword])) { + for (const nestedSchema of schema[keyword]) { + validateSchemaIdsForAjvCodeGeneration(nestedSchema, schemaId, seen) + } + } + } + + if (typeof schema.dependencies === 'object' && schema.dependencies !== null) { + for (const dependency of Object.values(schema.dependencies)) { + if (!Array.isArray(dependency)) { + validateSchemaIdsForAjvCodeGeneration(dependency, schemaId, seen) + } + } + } +} + function getSafeSchemaRef (context, location) { let schemaRef = location.getSchemaRef() || '' if (schemaRef.startsWith(context.rootSchemaId)) { @@ -260,13 +328,26 @@ function build (schema, options) { options.ajv, options.mode === 'standalone' && options.inlineValidators ) + const ajvCodeOptions = options.ajv && options.ajv.code + const validateAjvSchemaIds = ( + (options.mode === 'standalone' && options.inlineValidators) || + (ajvCodeOptions && (ajvCodeOptions.source || ajvCodeOptions.process)) + ) + const ajvSchemaId = options.ajv?.schemaId ?? '$id' + const seenAjvSchemas = new WeakSet() for (const schemaId of context.validatorSchemasIds) { const schema = context.refResolver.getSchema(schemaId) + if (validateAjvSchemaIds) { + validateSchemaIdsForAjvCodeGeneration(schema, ajvSchemaId, seenAjvSchemas) + } validator.addSchema(schema, schemaId) const dependencies = context.refResolver.getSchemaDependencies(schemaId) for (const [schemaId, schema] of Object.entries(dependencies)) { + if (validateAjvSchemaIds) { + validateSchemaIdsForAjvCodeGeneration(schema, ajvSchemaId, seenAjvSchemas) + } validator.addSchema(schema, schemaId) } } @@ -371,7 +452,7 @@ function buildExtraObjectPropertiesSerializer (context, location, addComma, objV const propertyLocation = patternPropertiesLocation.getPropertyLocation(propertyKey) code += ` - if (/${propertyKey.replace(/\\*\//g, '\\/')}/.test(key)) { + if (new RegExp(${JSON.stringify(propertyKey)}).test(key)) { ${addComma} json += asString(key) + JSON_STR_COLONS ${buildValue(context, propertyLocation, 'value')} @@ -426,7 +507,8 @@ function buildInnerObject (context, location, objVar) { for (const key of requiredProperties) { if (!propertiesKeys.includes(key)) { const sanitizedKey = JSON.stringify(key) - code += `if (${objVar}[${sanitizedKey}] === undefined) throw new Error('${sanitizedKey.replace(/'/g, '\\\'')} is required!')\n` + const requiredError = JSON.stringify(`"${key}" is required!`) + code += `if (${objVar}[${sanitizedKey}] === undefined) throw new Error(${requiredError})\n` } } @@ -474,8 +556,9 @@ function buildInnerObject (context, location, objVar) { } ` } else if (isRequired) { + const requiredError = JSON.stringify(`"${key}" is required!`) code += ` else { - throw new Error('${sanitizedKey.replace(/'/g, '\\\'')} is required!') + throw new Error(${requiredError}) } ` } else { @@ -519,8 +602,9 @@ function buildInnerObject (context, location, objVar) { ` } else if (isRequired) { // Should not happen if requiredProperties.length === 0 but safety + const requiredError = JSON.stringify(`"${key}" is required!`) code += ` else { - throw new Error('${sanitizedKey.replace(/'/g, '\\\'')} is required!') + throw new Error(${requiredError}) } ` } else { @@ -620,10 +704,7 @@ function buildObject (context, location, input) { const functionName = generateFuncName(context) context.functionsNamesBySchema.set(schema, functionName) - const schemaRef = getSafeSchemaRef(context, location) - const functionCode = ` - // ${schemaRef} function ${functionName} (input) { const obj = ${toJSON('input')} if (obj === null) return ${nullable ? 'JSON_STR_NULL' : 'JSON_STR_EMPTY_OBJECT'} @@ -680,17 +761,17 @@ function buildArray (context, location, input) { context.functionsNamesBySchema.set(schema, functionName) const schemaRef = getSafeSchemaRef(context, location) + const schemaRefError = JSON.stringify(`The value of '${schemaRef}' does not match schema definition.`) let functionCode = ` function ${functionName} (obj) { - // ${schemaRef} let json = '' ` functionCode += ` if (obj === null) return ${nullable ? 'JSON_STR_NULL' : 'JSON_STR_EMPTY_ARRAY'} if (!Array.isArray(obj)) { - throw new TypeError(\`The value of '${schemaRef}' does not match schema definition.\`) + throw new TypeError(${schemaRefError}) } const arrayLength = obj.length ` @@ -768,14 +849,15 @@ function buildArray (context, location, input) { } context.buildingSet.add(schema) - const safeSchemaRef = getSafeSchemaRef(context, location) + const schemaRef = getSafeSchemaRef(context, location) + const schemaRefError = JSON.stringify(`The value of '${schemaRef}' does not match schema definition.`) const objVar = `obj_${context.uid++}` let inlinedCode = ` const ${objVar} = ${input} if (${objVar} === null) { json += ${nullable ? 'JSON_STR_NULL' : 'JSON_STR_EMPTY_ARRAY'} } else if (!Array.isArray(${objVar})) { - throw new TypeError(\`The value of '${safeSchemaRef}' does not match schema definition.\`) + throw new TypeError(${schemaRefError}) } else { const arrayLength_${objVar} = ${objVar}.length ` @@ -976,8 +1058,9 @@ function buildMultiTypeSerializer (context, location, input) { } } }) + const schemaRef = getSafeSchemaRef(context, location) code += ` - else throw new TypeError(\`The value of '${getSafeSchemaRef(context, location)}' does not match schema definition.\`) + else throw new TypeError(${JSON.stringify(`The value of '${schemaRef}' does not match schema definition.`)}) ` return code @@ -1218,14 +1301,15 @@ function buildOneOf (context, location, input) { context.validatorSchemaRefs.add(schemaRef) code += ` - ${index === 0 ? 'if' : 'else if'}(validator.validate("${schemaRef}", ${input})) { + ${index === 0 ? 'if' : 'else if'}(validator.validate(${JSON.stringify(schemaRef)}, ${input})) { ${nestedResult} } ` } + const schemaRef = getSafeSchemaRef(context, location) code += ` - else throw new TypeError(\`The value of '${getSafeSchemaRef(context, location)}' does not match schema definition.\`) + else throw new TypeError(${JSON.stringify(`The value of '${schemaRef}' does not match schema definition.`)}) ` return code @@ -1268,7 +1352,7 @@ function buildIfThenElse (context, location, input) { if (!elseSchema) { return ` - if (validator.validate("${ifSchemaRef}", ${input})) { + if (validator.validate(${JSON.stringify(ifSchemaRef)}, ${input})) { ${buildValue(context, thenMergedLocation, input)} } else { ${buildValue(context, rootLocation, input)} @@ -1292,7 +1376,7 @@ function buildIfThenElse (context, location, input) { } return ` - if (validator.validate("${ifSchemaRef}", ${input})) { + if (validator.validate(${JSON.stringify(ifSchemaRef)}, ${input})) { ${buildValue(context, thenMergedLocation, input)} } else { ${buildValue(context, elseMergedLocation, input)} diff --git a/lib/location.js b/lib/location.js index 0d9acb2d..d1979cf0 100644 --- a/lib/location.js +++ b/lib/location.js @@ -1,5 +1,20 @@ 'use strict' +function encodeFragmentToken (value) { + const escapedValue = String(value).replace(/~/g, '~0').replace(/\//g, '~1') + let encodedValue = '' + + for (const character of escapedValue) { + const code = character.charCodeAt(0) + // encodeURIComponent throws for lone surrogates, which remain safe in generated string literals. + encodedValue += character.length === 1 && code >= 0xD800 && code <= 0xDFFF + ? character + : encodeURIComponent(character) + } + + return encodedValue +} + class Location { constructor (schema, schemaId, jsonPointer = '#') { this.schema = schema @@ -8,10 +23,11 @@ class Location { } getPropertyLocation (propertyName) { + const escapedPropertyName = encodeFragmentToken(propertyName) const propertyLocation = new Location( this.schema[propertyName], this.schemaId, - this.jsonPointer + '/' + propertyName + this.jsonPointer + '/' + escapedPropertyName ) return propertyLocation } diff --git a/test/code-generation-fallbacks.test.js b/test/code-generation-fallbacks.test.js index 6dbcf703..c5f5e2fa 100644 --- a/test/code-generation-fallbacks.test.js +++ b/test/code-generation-fallbacks.test.js @@ -110,18 +110,20 @@ test('inline array generation without schema IDs', t => { }) test('code generation reference fallbacks', t => { - t.plan(3) + t.plan(4) const buildWithoutPointer = loadBuildWithLocation(LocationWithoutJsonPointer) const stringifyObject = buildWithoutPointer({ type: 'object' }) const stringifyArray = buildWithoutPointer({ type: 'array' }) const buildWithoutRef = loadBuildWithLocation(LocationWithoutSchemaRef) - const stringifyWithoutRef = buildWithoutRef({ type: 'object' }) + const stringifyObjectWithoutRef = buildWithoutRef({ type: 'object' }) + const stringifyArrayWithoutRef = buildWithoutRef({ type: 'array' }) t.assert.equal(stringifyObject({}), '{}') t.assert.equal(stringifyArray([]), '[]') - t.assert.equal(stringifyWithoutRef({}), '{}') + t.assert.equal(stringifyObjectWithoutRef({}), '{}') + t.assert.equal(stringifyArrayWithoutRef([]), '[]') }) test('required-property fallback tolerates unexpected property ordering', t => { diff --git a/test/code-generation-sanitization.test.js b/test/code-generation-sanitization.test.js new file mode 100644 index 00000000..5bbc5468 --- /dev/null +++ b/test/code-generation-sanitization.test.js @@ -0,0 +1,331 @@ +'use strict' + +const { test } = require('node:test') +const Module = require('node:module') +const build = require('..') + +function restoreStandalone (code) { + const standaloneModule = new Module(`${__filename}.standalone.cjs`, module) + standaloneModule.filename = `${__filename}.standalone.cjs` + standaloneModule.paths = module.paths + standaloneModule._compile(code, standaloneModule.filename) + return standaloneModule.exports +} + +const marker = '__fastJsonStringifyCodeGenerationMarker' + +test('schema references with line breaks do not alter generated code', (t) => { + t.after(() => { + delete globalThis[marker] + }) + + const objectPropertyName = `nested\n;globalThis.${marker} = true\n//` + const arrayPropertyName = `list\n;globalThis.${marker} = true\n//` + const surrogatePropertyName = 'surrogate\uD800key' + const input = { + [objectPropertyName]: { + value: 'safe' + }, + [arrayPropertyName]: ['safe'], + [surrogatePropertyName]: 'safe' + } + + const stringify = build({ + type: 'object', + properties: { + [objectPropertyName]: { + type: 'object', + properties: { + value: { type: 'string' } + } + }, + [arrayPropertyName]: { + type: 'array', + items: { type: 'string' } + }, + [surrogatePropertyName]: { type: 'string' } + } + }) + + t.assert.equal(globalThis[marker], undefined) + const output = stringify(input) + t.assert.equal(globalThis[marker], undefined) + t.assert.equal(output, JSON.stringify(input)) +}) + +function testSchemaRefError (name, propertySchema, invalidValue) { + test(`schema references are escaped in generated ${name} errors`, (t) => { + t.after(() => { + delete globalThis[marker] + }) + + const propertyName = `${name}\`,globalThis.${marker}=true,\`` + const stringify = build({ + type: 'object', + properties: { + [propertyName]: propertySchema + } + }) + + const escapedPropertyName = encodeURIComponent( + propertyName.replace(/~/g, '~0').replace(/\//g, '~1') + ) + t.assert.throws( + () => stringify({ [propertyName]: invalidValue }), + new TypeError(`The value of '#/properties/${escapedPropertyName}' does not match schema definition.`) + ) + t.assert.equal(globalThis[marker], undefined) + }) +} + +testSchemaRefError('array', { + type: 'array', + items: { type: 'string' } +}, 'not an array') + +testSchemaRefError('multi-type', { + type: ['string', 'number'] +}, {}) + +testSchemaRefError('anyOf', { + anyOf: [ + { type: 'string' }, + { type: 'number' } + ] +}, false) + +test('required property errors encode the complete message', (t) => { + t.after(() => { + delete globalThis[marker] + }) + + const propertyName = `required'\\\n\u2028\`,globalThis.${marker}=true,\`` + const schemas = [ + { + type: 'object', + required: [propertyName] + }, + { + type: 'object', + properties: { + [propertyName]: { type: 'string' } + }, + required: [propertyName] + } + ] + + for (const schema of schemas) { + const stringifiers = [ + build(schema), + build.restore(build(schema, { mode: 'debug' })), + restoreStandalone(build(schema, { mode: 'standalone' })) + ] + for (const stringify of stringifiers) { + t.assert.throws( + () => stringify({}), + new Error(`"${propertyName}" is required!`) + ) + t.assert.equal(globalThis[marker], undefined) + } + } +}) + +test('pattern property expressions are preserved in generated code', (t) => { + const newlinePattern = '^line\nbreak$' + const slashPattern = '^backslash\\\\/$' + const stringify = build({ + type: 'object', + patternProperties: { + [newlinePattern]: { type: 'string' }, + [slashPattern]: { type: 'string' } + } + }) + + const input = { + 'line\nbreak': 'newline', + 'backslash\\/': 'slash', + 'backslash/': 'does not match' + } + + t.assert.equal(stringify(input), '{"line\\nbreak":"newline","backslash\\\\/":"slash"}') +}) + +test('schema property names are escaped in validator JSON pointers', (t) => { + const propertyNames = [ + 'slash/key', + 'tilde~key', + 'percent%2Fkey', + 'separator\u2028key' + ] + const properties = Object.fromEntries(propertyNames.map(propertyName => [ + propertyName, + { + anyOf: [ + { type: 'string' }, + { type: 'number' } + ] + } + ])) + const input = Object.fromEntries(propertyNames.map(propertyName => [propertyName, 'safe'])) + const schema = { type: 'object', properties } + + const stringify = build(schema) + const restored = build.restore(build(schema, { mode: 'debug' })) + const standalone = restoreStandalone(build(schema, { mode: 'standalone' })) + const inlineStandalone = restoreStandalone(build(schema, { + mode: 'standalone', + inlineValidators: true + })) + + t.assert.equal(stringify(input), JSON.stringify(input)) + t.assert.equal(restored(input), JSON.stringify(input)) + t.assert.equal(standalone(input), JSON.stringify(input)) + t.assert.equal(inlineStandalone(input), JSON.stringify(input)) +}) + +test('unsafe schema ids are rejected when Ajv source generation is enabled', (t) => { + const unsafeId = 'schema*/globalThis.codeGenerationMarker=true;/*' + const schema = { + anyOf: [ + { $ref: `${unsafeId}#` }, + { type: 'number' } + ] + } + const externalSchemas = { + external: { $id: unsafeId, type: 'object' } + } + const expectedError = { + message: 'Schema $id must not contain "*/" when Ajv source code generation is enabled' + } + + t.assert.doesNotThrow(() => build(schema, { schema: externalSchemas })) + t.assert.doesNotThrow(() => build( + { $id: unsafeId, type: 'string' }, + { ajv: { code: { source: true } } } + )) + for (const options of [ + { ajv: { code: { source: true } } }, + { mode: 'debug', ajv: { code: { source: true } } }, + { ajv: { code: { process () {} } } }, + { mode: 'standalone', inlineValidators: true } + ]) { + t.assert.throws( + () => build(schema, { ...options, schema: externalSchemas }), + expectedError + ) + } + + const customIdSchema = { + anyOf: [ + { xid: unsafeId, type: 'object' }, + { type: 'number' } + ] + } + const customIdOptions = { + ajv: { + schemaId: 'xid', + code: { source: true } + } + } + for (const options of [ + customIdOptions, + { ...customIdOptions, mode: 'debug' }, + { + mode: 'standalone', + inlineValidators: true, + ajv: { schemaId: 'xid' } + } + ]) { + t.assert.throws( + () => build(customIdSchema, options), + { message: 'Schema xid must not contain "*/" when Ajv source code generation is enabled' } + ) + } + const sharedSchema = { type: 'string' } + t.assert.doesNotThrow(() => build({ + definitions: { + container: { + items: [sharedSchema], + dependencies: { + schemaDependency: sharedSchema, + propertyDependency: ['value'] + }, + const: { + $id: unsafeId, + xid: unsafeId + }, + default: { xid: unsafeId }, + enum: [{ xid: unsafeId }] + } + }, + anyOf: [ + { type: 'string' }, + { type: 'number' } + ] + }, customIdOptions)) +}) + +test('quoted external schema ids are escaped in anyOf validator calls', (t) => { + const stringSchemaId = 'external"string' + const numberSchemaId = 'external"number' + const stringify = build({ + anyOf: [ + { $ref: `${stringSchemaId}#` }, + { $ref: `${numberSchemaId}#` } + ] + }, { + schema: { + stringSchema: { + $id: stringSchemaId, + type: 'string' + }, + numberSchema: { + $id: numberSchemaId, + type: 'number' + } + } + }) + + t.assert.equal(stringify('safe'), '"safe"') + t.assert.equal(stringify(42), '42') +}) + +test('quoted external schema ids are escaped in if validator calls', (t) => { + const schemaId = 'condition"schema' + const thenSchema = { + properties: { + value: { type: 'string' } + } + } + const options = { + schema: { + condition: { + $id: schemaId, + type: 'object', + properties: { + kind: { const: 'string' } + }, + required: ['kind'] + } + } + } + const stringify = build({ + type: 'object', + if: { $ref: `${schemaId}#` }, + then: thenSchema, + else: { + properties: { + value: { type: 'number' } + } + } + }, options) + const stringifyWithoutElse = build({ + type: 'object', + if: { $ref: `${schemaId}#` }, + then: thenSchema + }, options) + + t.assert.equal(stringify({ kind: 'string', value: 'safe' }), '{"value":"safe"}') + t.assert.equal(stringify({ kind: 'number', value: 42 }), '{"value":42}') + t.assert.equal(stringifyWithoutElse({ kind: 'string', value: 'safe' }), '{"value":"safe"}') +})