UNPKG

fastify

Version:

Fast and low overhead web framework, for Node.js

519 lines (475 loc) • 19.1 kB
'use strict' const { kSchemaHeaders: headersSchema, kSchemaParams: paramsSchema, kSchemaQuerystring: querystringSchema, kSchemaBody: bodySchema, kSchemaResponse: responseSchema } = require('./symbols') const scChecker = /^[1-5](?:\d{2}|xx)$|^default$/ const { FST_ERR_SCH_RESPONSE_SCHEMA_NOT_NESTED_2XX } = require('./errors') const { FSTWRN001, FSTSEC002, FSTSEC003 } = require('./warnings') function compileSchemasForSerialization (context, compile) { if (!context.schema || !context.schema.response) { return } const { method, url } = context.config || {} context[responseSchema] = Object.keys(context.schema.response) .reduce(function (acc, statusCode) { const schema = context.schema.response[statusCode] statusCode = statusCode.toLowerCase() if (!scChecker.test(statusCode)) { throw new FST_ERR_SCH_RESPONSE_SCHEMA_NOT_NESTED_2XX() } if (schema.content) { const contentTypesSchemas = {} for (const mediaName of Object.keys(schema.content)) { const contentSchema = schema.content[mediaName].schema contentTypesSchemas[mediaName] = compile({ schema: contentSchema, url, method, httpStatus: statusCode, contentType: mediaName }) } acc[statusCode] = contentTypesSchemas } else { acc[statusCode] = compile({ schema, url, method, httpStatus: statusCode }) } return acc }, {}) } // Header names are case insensitive (RFC 9110 §5.1), and Node.js stores every // received header name in lowercase in `request.headers` (see the Node.js // `message.headers` docs). The header schema must be normalized the same way // before compilation, otherwise schema keywords that reference header names by // their canonical or mixed case — `properties`, `required`, and the Draft 7 // `dependencies` keyword (triggers and array-form dependent names) — never // match the lowercased request object, silently skipping assertions. // // JSON Schema is recursive, so the transformation walks the whole schema and // lowercases every position where a property name (i.e. a header name) appears, // while recursing into subschemas. This covers `properties` and `required` in // every nested subschema, `dependencies` trigger keys, array-form dependent // property names and subschemas used as dependency values, and subschemas // reachable through `allOf`, `anyOf`, `oneOf`, `not`, `if`/`then`/`else`, local // `$ref` definitions (`definitions`/`$defs`), and the other standard subschema // containers. The input schema is never mutated: a new schema tree is returned. // // A `propertyNames` subschema validates each header name itself, so values held // in `enum` and `const` (and in every subschema reachable from it) are header // names rather than header values. `insidePropertyNames` tracks that context so // those `enum`/`const` string values are lowercased there, and only there. function lowerCaseHeadersSchema (schema, insidePropertyNames = false) { if (Array.isArray(schema)) { return schema.map(item => lowerCaseHeadersSchema(item, insidePropertyNames)) } if (schema === null || typeof schema !== 'object') { return schema } const result = {} for (const key of Object.keys(schema)) { const value = schema[key] switch (key) { case 'properties': { if (value === null || typeof value !== 'object') { result.properties = value break } const normalized = {} for (const prop of Object.keys(value)) { normalized[prop.toLowerCase()] = lowerCaseHeadersSchema(value[prop], insidePropertyNames) } result.properties = normalized break } case 'required': result.required = Array.isArray(value) ? value.map(name => name.toLowerCase()) : value break case 'dependencies': { if (value === null || typeof value !== 'object') { result.dependencies = value break } const normalized = {} for (const dep of Object.keys(value)) { const depValue = value[dep] if (Array.isArray(depValue)) { normalized[dep.toLowerCase()] = depValue.map(name => name.toLowerCase()) } else { normalized[dep.toLowerCase()] = lowerCaseHeadersSchema(depValue, insidePropertyNames) } } result.dependencies = normalized break } case 'dependentSchemas': { if (value === null || typeof value !== 'object') { result.dependentSchemas = value break } const normalized = {} for (const dep of Object.keys(value)) { normalized[dep.toLowerCase()] = lowerCaseHeadersSchema(value[dep], insidePropertyNames) } result.dependentSchemas = normalized break } case 'dependentRequired': { if (value === null || typeof value !== 'object') { result.dependentRequired = value break } const normalized = {} for (const dep of Object.keys(value)) { normalized[dep.toLowerCase()] = value[dep].map(name => name.toLowerCase()) } result.dependentRequired = normalized break } // Arrays of subschemas (`allOf`/`anyOf`/`oneOf`) or a single subschema // (`not`/`if`/`then`/`else`/`items`/`additionalItems`/...). `lowerCaseHeadersSchema` // handles arrays, objects and booleans, so recursing here covers them all. case 'allOf': case 'anyOf': case 'oneOf': case 'not': case 'if': case 'then': case 'else': case 'items': case 'additionalItems': case 'additionalProperties': case 'unevaluatedItems': case 'unevaluatedProperties': case 'contains': case 'contentSchema': result[key] = lowerCaseHeadersSchema(value, insidePropertyNames) break case 'propertyNames': result[key] = lowerCaseHeadersSchema(value, true) break // Maps of subschemas whose keys are not header names (definition names, // regex patterns): keep the keys, normalize the subschema values. // // `patternProperties` keys are regular expressions matched against the // already-lowercased header names and are left as-is: a regular expression // cannot be safely lowercased. The same applies to a `propertyNames` // `pattern`. A header-name pattern written in canonical case will not match // the lowercased request headers, so it must be authored to match // lowercase. See docs/Reference/Validation-and-Serialization.md. case 'definitions': case '$defs': case 'patternProperties': { if (value === null || typeof value !== 'object') { result[key] = value break } const normalized = {} for (const k of Object.keys(value)) { normalized[k] = lowerCaseHeadersSchema(value[k], insidePropertyNames) } result[key] = normalized break } default: // Inside a `propertyNames` subschema, `enum` and `const` values are the // candidate header names themselves, so they must be lowercased to match // the lowercased request headers. Everywhere else these hold header // *values* (or arbitrary constant data) and must be left untouched. if (insidePropertyNames && key === 'enum' && Array.isArray(value)) { result[key] = value.map(item => typeof item === 'string' ? item.toLowerCase() : item) } else if (insidePropertyNames && key === 'const' && typeof value === 'string') { result[key] = value.toLowerCase() } else { result[key] = value } } } return result } // `lowerCaseHeadersSchema` only normalizes the inline schema tree. A header // schema can also pull in a schema registered with `addSchema()` through an // external `$ref` (any ref that does not start with `#`, i.e. not a same // document fragment). Those referenced schemas are resolved by the validator // after normalization and keep their original header-name case, so // case-insensitive assertions in them never match the lowercased request // headers. We cannot safely normalize them here because the same registered // schema is shared with body, querystring and params validation, so we detect // the external reference and warn the developer instead. Returns the first // external `$ref` found, or undefined. function findExternalHeaderRef (schema) { if (Array.isArray(schema)) { for (const item of schema) { const ref = findExternalHeaderRef(item) if (ref !== undefined) return ref } return undefined } if (schema === null || typeof schema !== 'object') { return undefined } if (typeof schema.$ref === 'string' && schema.$ref[0] !== '#') { return schema.$ref } for (const key of Object.keys(schema)) { const ref = findExternalHeaderRef(schema[key]) if (ref !== undefined) return ref } return undefined } // A `$ref` reached inside a `propertyNames` subschema resolves to a definition // that is normalized in its own, non-`propertyNames` context, so header names // held in its `enum`/`const` keep their original case and a case-insensitive // deny-list or allow-list never matches the lowercased request headers. We // cannot safely normalize the referenced definition here because it may be // shared with value contexts, so we detect the reference and warn instead. // Returns the first local `$ref` found inside a `propertyNames` subschema, or // undefined. function findPropertyNamesRef (schema, insidePropertyNames = false) { if (Array.isArray(schema)) { for (const item of schema) { const ref = findPropertyNamesRef(item, insidePropertyNames) if (ref !== undefined) return ref } return undefined } if (schema === null || typeof schema !== 'object') { return undefined } if (insidePropertyNames && typeof schema.$ref === 'string' && schema.$ref[0] === '#') { return schema.$ref } for (const key of Object.keys(schema)) { const ref = findPropertyNamesRef(schema[key], insidePropertyNames || key === 'propertyNames') if (ref !== undefined) return ref } return undefined } function compileSchemasForValidation (context, compile, isCustom) { const { schema } = context if (!schema) { return } const { method, url } = context.config || {} // JSON Schema Draft 7 defines the boolean `false` (and `true`) as a valid // schema. A present boolean must be passed to the compiler unchanged, so // every request-part schema is selected by an explicit `undefined` check // rather than truthiness. FSTWRN001 is preserved only for schemas that are // explicitly set to `undefined`. const headers = schema.headers // the or part is used for backward compatibility if (headers !== undefined) { if (isCustom || typeof headers !== 'object' || headers === null || Object.getPrototypeOf(headers) !== Object.prototype) { // do not mess with schema when custom validator applied, e.g. Joi, Typebox // boolean schemas (true/false) and invalid values are compiled directly context[headersSchema] = compile({ schema: headers, method, url, httpPart: 'headers' }) } else { // The header keys are case insensitive // https://datatracker.ietf.org/doc/html/rfc2616#section-4.2 const headersSchemaLowerCase = lowerCaseHeadersSchema(headers) const externalRef = findExternalHeaderRef(headers) if (externalRef !== undefined) { FSTSEC002(method, url, externalRef) } const propertyNamesRef = findPropertyNamesRef(headers) if (propertyNamesRef !== undefined) { FSTSEC003(method, url, propertyNamesRef) } context[headersSchema] = compile({ schema: headersSchemaLowerCase, method, url, httpPart: 'headers' }) } } else if (Object.hasOwn(schema, 'headers')) { FSTWRN001('headers', method, url) } if (schema.body !== undefined) { const contentProperty = schema.body !== null && typeof schema.body === 'object' ? schema.body.content : undefined if (contentProperty) { const contentTypeSchemas = {} for (const contentType of Object.keys(contentProperty)) { const contentSchema = contentProperty[contentType].schema contentTypeSchemas[contentType] = compile({ schema: contentSchema, method, url, httpPart: 'body', contentType }) } context[bodySchema] = contentTypeSchemas } else { context[bodySchema] = compile({ schema: schema.body, method, url, httpPart: 'body' }) } } else if (Object.hasOwn(schema, 'body')) { FSTWRN001('body', method, url) } if (schema.querystring !== undefined) { context[querystringSchema] = compile({ schema: schema.querystring, method, url, httpPart: 'querystring' }) } else if (Object.hasOwn(schema, 'querystring')) { FSTWRN001('querystring', method, url) } if (schema.params !== undefined) { context[paramsSchema] = compile({ schema: schema.params, method, url, httpPart: 'params' }) } else if (Object.hasOwn(schema, 'params')) { FSTWRN001('params', method, url) } } function validateParam (validatorFunction, request, paramName) { // The headers getter may allocate a new object, so only read it when needed. if (validatorFunction == null) return false const value = request[paramName] let ret try { const data = value === undefined ? null : value // Ajv can only mutate a root value when its parent is provided. if (validatorFunction.schemaEnv) { ret = validatorFunction(data, { parentData: request, parentDataProperty: paramName }) } else { ret = validatorFunction(data) } } catch (err) { // If validator throws synchronously, ensure it propagates as an internal error err.statusCode = 500 return err } if (ret && typeof ret.then === 'function') { // An async validator (for example an `$async` Ajv schema) resolves with the // validated data itself, not a `{ value, error }` result. Its properties must // not be unwrapped the way a synchronous custom compiler result is, otherwise // a payload carrying an `error` or `value` key could inject a validation error // or replace the whole request part. Treat the async result as pass/fail only. return ret .then((res) => { return res === false ? validatorFunction.errors : false }) .catch(err => { return err }) // return as simple error (not throw) } return answer(ret) function answer (ret) { if (ret === false) return validatorFunction.errors if (ret && ret.error) return ret.error if (ret && typeof ret === 'object' && 'value' in ret) request[paramName] = ret.value return false } } function validate (context, request, execution) { const runExecution = execution === undefined if (runExecution && context[paramsSchema] === undefined && context[bodySchema] === undefined && context[querystringSchema] === undefined && context[headersSchema] === undefined) { // the route has no validation schemas return false } if (runExecution || !execution.skipParams) { const params = validateParam(context[paramsSchema], request, 'params') if (params) { if (typeof params.then !== 'function') { return wrapValidationError(params, 'params', context.schemaErrorFormatter) } else { return validateAsyncParams(params, context, request) } } } if (runExecution || !execution.skipBody) { let validatorFunction = null if (typeof context[bodySchema] === 'function') { validatorFunction = context[bodySchema] } else if (context[bodySchema]) { const contentSchema = context[bodySchema][request.mediaType] if (contentSchema) { validatorFunction = contentSchema } } const body = validateParam(validatorFunction, request, 'body') if (body) { if (typeof body.then !== 'function') { return wrapValidationError(body, 'body', context.schemaErrorFormatter) } else { return validateAsyncBody(body, context, request) } } } if (runExecution || !execution.skipQuery) { const query = validateParam(context[querystringSchema], request, 'query') if (query) { if (typeof query.then !== 'function') { return wrapValidationError(query, 'querystring', context.schemaErrorFormatter) } else { return validateAsyncQuery(query, context, request) } } } const headers = validateParam(context[headersSchema], request, 'headers') if (headers) { if (typeof headers.then !== 'function') { return wrapValidationError(headers, 'headers', context.schemaErrorFormatter) } else { return validateAsyncHeaders(headers, context, request) } } return false } function validateAsyncParams (validatePromise, context, request) { return validatePromise .then((paramsResult) => { if (paramsResult) { return wrapValidationError(paramsResult, 'params', context.schemaErrorFormatter) } return validate(context, request, { skipParams: true }) }) } function validateAsyncBody (validatePromise, context, request) { return validatePromise .then((bodyResult) => { if (bodyResult) { return wrapValidationError(bodyResult, 'body', context.schemaErrorFormatter) } return validate(context, request, { skipParams: true, skipBody: true }) }) } function validateAsyncQuery (validatePromise, context, request) { return validatePromise .then((queryResult) => { if (queryResult) { return wrapValidationError(queryResult, 'querystring', context.schemaErrorFormatter) } return validate(context, request, { skipParams: true, skipBody: true, skipQuery: true }) }) } function validateAsyncHeaders (validatePromise, context, request) { return validatePromise .then((headersResult) => { if (headersResult) { return wrapValidationError(headersResult, 'headers', context.schemaErrorFormatter) } return false }) } function wrapValidationError (result, dataVar, schemaErrorFormatter) { if (result instanceof Error) { result.statusCode = result.statusCode || 400 result.code = result.code || 'FST_ERR_VALIDATION' result.validationContext = result.validationContext || dataVar return result } const error = schemaErrorFormatter(result, dataVar) error.statusCode = error.statusCode || 400 error.code = error.code || 'FST_ERR_VALIDATION' error.validation = result error.validationContext = dataVar return error } module.exports = { symbols: { bodySchema, querystringSchema, responseSchema, paramsSchema, headersSchema }, compileSchemasForValidation, compileSchemasForSerialization, validate }