fastify 6.0.0-alpha.1 → 6.0.0-alpha.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -226,6 +226,8 @@ section.
226
226
  - [`@pompelmi/fastify-plugin`](https://github.com/pompelmi/pompelmi/tree/main/packages/fastify-plugin)
227
227
  In-process file upload scanning for Fastify with MIME/magic-byte
228
228
  validation, ZIP bomb protection, size limits, and optional YARA.
229
+ - [`@punkish/fastify-better-sqlite3`](https://github.com/punkish/fastify-better-sqlite3)
230
+ Plugin for better-sqlite3.
229
231
  - [`@pybot/fastify-autoload`](https://github.com/kunal097/fastify-autoload)
230
232
  Plugin to generate routes automatically with valid json content
231
233
  - [`@scalar/fastify-api-reference`](https://github.com/scalar/scalar/tree/main/integrations/fastify)
@@ -309,8 +311,6 @@ section.
309
311
  development servers that require Babel transformations of JavaScript sources.
310
312
  - [`fastify-bcrypt`](https://github.com/beliven-it/fastify-bcrypt) A Bcrypt hash
311
313
  generator & checker.
312
- - [`fastify-better-sqlite3`](https://github.com/punkish/fastify-better-sqlite3)
313
- Plugin for better-sqlite3.
314
314
  - [`fastify-blipp`](https://github.com/PavelPolyakov/fastify-blipp) Prints your
315
315
  routes to the console, so you definitely know which endpoints are available.
316
316
  - [`fastify-bookshelf`](https://github.com/butlerx/fastify-bookshelfjs) Fastify
@@ -698,9 +698,6 @@ middlewares into Fastify plugins
698
698
  [uws](https://github.com/uNetworking/uWebSockets).
699
699
  - [`fastify-xml-body-parser`](https://github.com/NaturalIntelligence/fastify-xml-body-parser)
700
700
  Parse XML payload / request body into JS / JSON object.
701
- - [`http-wizard`](https://github.com/flodlc/http-wizard)
702
- Exports a typescript API client for your Fastify API and ensures fullstack type
703
- safety for your project.
704
701
  - [`i18next-http-middleware`](https://github.com/i18next/i18next-http-middleware#fastify-usage)
705
702
  An [i18next](https://www.i18next.com) based i18n (internationalization)
706
703
  middleware to be used with Node.js web frameworks like Express or Fastify and
@@ -13,7 +13,8 @@ This guide is for anyone who loves to build with Fastify or wants to contribute
13
13
  to our documentation. You do not need to be an expert in writing technical
14
14
  documentation. This guide is here to help you.
15
15
 
16
- Visit the [contribute](https://fastify.dev/contribute/) page on our website or
16
+ Visit the [contribute](https://fastify.dev/docs/latest/Guides/Contributing/)
17
+ page on our website or
17
18
  read the
18
19
  [CONTRIBUTING.md](https://github.com/fastify/fastify/blob/main/CONTRIBUTING.md)
19
20
  file on GitHub to join our Open Source folks.
@@ -224,18 +225,18 @@ hyperlink should look:
224
225
  <!-- More like this -->
225
226
 
226
227
  // Add clear & brief description
227
- [Fastify Plugins] (https://fastify.dev/docs/latest/Plugins/)
228
+ [Fastify Plugins] (https://fastify.dev/docs/latest/Reference/Plugins/)
228
229
 
229
230
  <!--Less like this -->
230
231
 
231
232
  // incomplete description
232
- [Fastify] (https://fastify.dev/docs/latest/Plugins/)
233
+ [Fastify] (https://fastify.dev/docs/latest/Reference/Plugins/)
233
234
 
234
235
  // Adding title in link brackets
235
- [](https://fastify.dev/docs/latest/Plugins/ "fastify plugin")
236
+ [](https://fastify.dev/docs/latest/Reference/Plugins/ "fastify plugin")
236
237
 
237
238
  // Empty title
238
- [](https://fastify.dev/docs/latest/Plugins/)
239
+ [](https://fastify.dev/docs/latest/Reference/Plugins/)
239
240
 
240
241
  // Adding links localhost URLs instead of using code strings (``)
241
242
  [http://localhost:3000/](http://localhost:3000/)
@@ -5,7 +5,10 @@ Fastify natively supports `'application/json'` and `'text/plain'` content types
5
5
  with a default charset of `utf-8`. These default parsers can be changed or
6
6
  removed.
7
7
 
8
- Unsupported content types will throw an `FST_ERR_CTP_INVALID_MEDIA_TYPE` error.
8
+ Unsupported or [syntactically invalid](https://httpwg.org/specs/rfc9110.html#media.type)
9
+ content types result in a `415 Unsupported Media Type` response with an
10
+ [`FST_ERR_CTP_INVALID_MEDIA_TYPE`](./Errors.md#fst_err_ctp_invalid_media_type)
11
+ error.
9
12
 
10
13
  To support other content types, use the `addContentTypeParser` API or an
11
14
  existing [plugin](https://fastify.dev/ecosystem/).
@@ -45,6 +48,25 @@ parsed.
45
48
  > by the regex has a corresponding entry in the schema's `content` map. See
46
49
  > [Validation and Serialization](./Validation-and-Serialization.md) for details.
47
50
 
51
+ > ℹ️ Note:
52
+ > Fastify validates the request's `Content-Type` header before selecting a body
53
+ > parser. String, `RegExp`, and [catch-all](#catch-all) parsers therefore will
54
+ > never handle an invalid header. An `onRequest` hook can rewrite a malformed
55
+ > header prior to validation:
56
+ >
57
+ > ```js
58
+ > fastify.addHook('onRequest', async function rewriteContentType (request) {
59
+ > if (request.headers['content-type'] === 'application/json,application/json') {
60
+ > request.headers['content-type'] = 'application/json'
61
+ > }
62
+ > })
63
+ > ```
64
+ >
65
+ > Apply only unambiguous rewrites and reject other malformed values. An invalid
66
+ > media type can select a different body parser or `schema.body.content`
67
+ > validation schema. Rewrite the header before accessing `request.mediaType`,
68
+ > which parses and caches the current value.
69
+
48
70
  ### Usage
49
71
  ```js
50
72
  fastify.addContentTypeParser('application/jsoff', function (request, payload, done) {
@@ -167,10 +189,6 @@ fastify.addContentTypeParser('text/xml', function (request, payload, done) {
167
189
  })
168
190
  ```
169
191
 
170
- > ℹ️ Note:
171
- > `function(req, done)` and `async function(req)` are
172
- > still supported but deprecated.
173
-
174
192
  #### Body Parser
175
193
  The request body can be parsed in two ways. First, add a custom content type
176
194
  parser and handle the request stream. Or second, use the `parseAs` option in the
@@ -638,9 +638,6 @@ invocation of `reply.send()` once the handler promise resolve should be skipped.
638
638
  By calling `reply.hijack()`, an application claims full responsibility for the
639
639
  low-level request and response. Moreover, hooks will not be invoked.
640
640
 
641
- *Modifying the `.sent` property directly is deprecated. Please use the
642
- aforementioned `.hijack()` method to achieve the same effect.*
643
-
644
641
  ### .hijack()
645
642
  <a name="hijack"></a>
646
643
 
@@ -72,10 +72,6 @@ Request is a core Fastify object containing the following fields:
72
72
  for cooperative cancellation. On timeout, `signal.reason` is the
73
73
  `FST_ERR_HANDLER_TIMEOUT` error; on client disconnect it is a generic
74
74
  `AbortError`. Check `signal.reason.code` to distinguish the two cases.
75
- - `context` - Deprecated, use `request.routeOptions.config` instead. A Fastify
76
- internal object. Do not use or modify it directly. It is useful to access one
77
- special key:
78
- - `context.config` - The route [`config`](./Routes.md#routes-config) object.
79
75
  - `routeOptions` - The route [`option`](./Routes.md#routes-options) object.
80
76
  - `bodyLimit` - Either server limit or route limit.
81
77
  - `handlerTimeout` - The handler timeout configured for this route.
@@ -560,11 +560,14 @@ const fastify = require('fastify')({
560
560
  + `string`: Trust only given IP/CIDR (e.g. `'127.0.0.1'`). May be a list of
561
561
  comma separated values (e.g. `'127.0.0.1,192.168.1.1/24'`).
562
562
  + `Array<string>`: Trust only given IP/CIDR list (e.g. `['127.0.0.1']`).
563
- + `number`: Trust the nth hop from the front-facing proxy server as the client.
563
+ + `number`: Hop-count-only trust is disabled because it cannot validate the
564
+ immediate peer and lets direct clients spoof `X-Forwarded-*` values. Use an
565
+ IP/CIDR string, array, or custom function that validates the proxy address
566
+ instead.
564
567
  + `Function`: Custom trust function that takes `address` as first argument
565
568
  ```js
566
569
  function myTrustFn(address, hop) {
567
- return address === '1.2.3.4' || hop === 1
570
+ return address === '1.2.3.4' && hop === 0
568
571
  }
569
572
  ```
570
573
 
@@ -586,7 +589,9 @@ You may access the `ip`, `ips`, `host` and `protocol` values on the
586
589
  > These values are derived from socket/forwarding metadata and must be treated
587
590
  > as untrusted input unless your proxy chain is explicitly trusted and
588
591
  > validated. Do not use them directly for authorization or other
589
- > security-sensitive decisions without explicit validation.
592
+ > security-sensitive decisions without explicit validation. Custom trust
593
+ > functions must validate the `address` argument; hop-count-only checks such as
594
+ > `hop < 1` are unsafe when the Fastify origin can be reached directly.
590
595
 
591
596
  ```js
592
597
  fastify.get('/', (request, reply) => {
@@ -628,6 +628,17 @@ This pattern ensures validators work correctly with both sync and async
628
628
  `preValidation` hooks, preventing unhandled promise rejections that can crash
629
629
  an application.
630
630
 
631
+ > [!IMPORTANT]
632
+ > The `{ value, error }` result convention applies to **synchronous** validator
633
+ > compilers only. An **asynchronous** validator (a compiler that returns a
634
+ > promise, including an [`$async` Ajv schema](https://ajv.js.org/guide/async-validation.html))
635
+ > is treated as pass/fail only: its resolved value is not unwrapped as
636
+ > `{ value, error }`. An async schema resolves with the validated data itself, so
637
+ > unwrapping it would let a payload that carries `value` or `error` keys replace
638
+ > the request part or inject a validation error. An async validator must signal
639
+ > failure by rejecting (throwing), and it cannot coerce the request part through
640
+ > a resolved `value`.
641
+
631
642
  ##### .statusCode property
632
643
 
633
644
  All validation errors have a `.statusCode` property set to `400`, ensuring the
@@ -7,6 +7,9 @@
7
7
  - [Fastify Warning Codes](#fastify-warning-codes)
8
8
  - [FSTWRN001](#FSTWRN001)
9
9
  - [FSTWRN003](#FSTWRN003)
10
+ - [Fastify Security Codes](#fastify-security-codes)
11
+ - [FSTSEC001](#FSTSEC001)
12
+ - [FSTSEC002](#FSTSEC002)
10
13
  - [Fastify Deprecation Codes](#fastify-deprecation-codes)
11
14
 
12
15
  ## Warnings
@@ -42,6 +45,13 @@ Disabling warnings is not recommended and may cause unexpected behavior.
42
45
  | <a id="FSTWRN001">FSTWRN001</a> | The specified schema for a route is missing. This may indicate the schema is not well specified. | Check the schema for the route. | [#4647](https://github.com/fastify/fastify/pull/4647) |
43
46
  | <a id="FSTWRN003">FSTWRN003</a> | The `%s` plugin mixes async and callback styles, which may lead to unhandled rejections. | Do not mix async and callback style. | [#6011](https://github.com/fastify/fastify/pull/6011) |
44
47
 
48
+ ### Fastify Security Codes
49
+
50
+ | Code | Description | How to solve | Discussion |
51
+ | ---- | ----------- | ------------ | ---------- |
52
+ | <a id="FSTSEC001">FSTSEC001</a> | A `RegExp` Content-Type is used that may be vulnerable to a CORS bypass, because it does not anchor the essence MIME type. | Start the `RegExp` with `^` or include `;?` so the essence MIME type is detected correctly. | [#4450](https://github.com/fastify/fastify/pull/4450) |
53
+ | <a id="FSTSEC002">FSTSEC002</a> | A headers schema references an external `$ref` (a schema registered with `addSchema`) that is not reached by header case normalization. Header names in the referenced schema keep their original case and do not match the lowercased request headers, so case-insensitive assertions such as `required` and `dependencies` may not apply. | Inline the header schema instead of referencing it with an external `$ref`. | - |
54
+
45
55
  ### Fastify Deprecation Codes
46
56
 
47
57
  Deprecation codes are supported by the Node.js CLI options:
package/fastify.d.ts CHANGED
@@ -144,7 +144,7 @@ declare namespace fastify {
144
144
  serverFactory?: FastifyServerFactory<RawServer>,
145
145
  requestIdHeader?: string | false,
146
146
  genReqId?: (req: RawRequestDefaultExpression<RawServer>) => string,
147
- trustProxy?: boolean | string | string[] | number | TrustProxyFunction,
147
+ trustProxy?: boolean | string | string[] | TrustProxyFunction,
148
148
  schemaController?: {
149
149
  bucket?: (parentSchemas?: unknown) => {
150
150
  add(schema: unknown): FastifyInstance;
package/fastify.js CHANGED
@@ -1,6 +1,6 @@
1
1
  'use strict'
2
2
 
3
- const VERSION = '6.0.0-alpha.1'
3
+ const VERSION = '6.0.0-alpha.3'
4
4
 
5
5
  const Avvio = require('avvio')
6
6
  const http = require('node:http')
@@ -600,7 +600,7 @@ function fastify (serverOptions) {
600
600
 
601
601
  if (name === 'onClose') {
602
602
  this.onClose(fn.bind(this))
603
- } else if (name === 'onReady' || name === 'onListen' || name === 'onRoute') {
603
+ } else if (name === 'onReady' || name === 'onListen' || name === 'onRoute' || name === 'preClose') {
604
604
  this[kHooks].add(name, fn)
605
605
  } else {
606
606
  this.after((err, done) => {
@@ -34,11 +34,10 @@ function fourOhFour (options) {
34
34
 
35
35
  // 404 router, used for handling encapsulated 404 handlers
36
36
  const router = FindMyWay({
37
- onBadUrl: createRouteEventHandler(),
38
- onMaxParamLength: createRouteEventHandler(),
37
+ onBadUrl: options.routerOptions.onBadUrl,
38
+ onMaxParamLength: options.routerOptions.onMaxParamLength,
39
39
  defaultRoute: fourOhFourFallBack
40
40
  })
41
- let _routeEventHandler = null
42
41
 
43
42
  return { router, setNotFoundHandler, setContext, arrange404 }
44
43
 
@@ -47,8 +46,6 @@ function fourOhFour (options) {
47
46
  instance[kFourOhFourLevelInstance] = instance
48
47
  instance[kCanSetNotFoundHandler] = true
49
48
  // we need to bind instance for the context
50
- router.onBadUrl = router.onBadUrl.bind(instance)
51
- router.onMaxParamLength = router.onMaxParamLength.bind(instance)
52
49
  router.defaultRoute = router.defaultRoute.bind(instance)
53
50
  }
54
51
 
@@ -62,20 +59,15 @@ function fourOhFour (options) {
62
59
  })
63
60
  }
64
61
 
65
- function createRouteEventHandler () {
66
- return function onRouteEvent (path, req, res) {
67
- const fourOhFourContext = this[kFourOhFourLevelInstance][kFourOhFourContext]
68
- const id = getGenReqId(fourOhFourContext.server, req)
69
- const childLogger = createChildLogger(fourOhFourContext, logger, req, id)
70
- const request = new Request(id, null, req, null, childLogger, fourOhFourContext)
71
- const reply = new Reply(res, request, childLogger)
72
-
73
- _routeEventHandler(request, reply)
74
- }
75
- }
76
-
77
62
  function setContext (instance, context) {
78
- const _404Context = Object.assign({}, instance[kFourOhFourContext])
63
+ // Use the selected not-found context as the prototype rather than taking a
64
+ // detached shallow snapshot. The selected context's lifecycle hooks are
65
+ // populated during preReady (potentially after this snapshot is taken when
66
+ // the route is registered first), and prototype lookup keeps the
67
+ // route-specific 404 context linked to the live one so hooks like
68
+ // preHandler are not lost. The route's own onSend is preserved as an own
69
+ // property.
70
+ const _404Context = Object.create(instance[kFourOhFourContext])
79
71
  _404Context.onSend = context.onSend
80
72
  context[kFourOhFourContext] = _404Context
81
73
  }
@@ -123,12 +115,8 @@ function fourOhFour (options) {
123
115
  if (handler) {
124
116
  this[kFourOhFourLevelInstance][kCanSetNotFoundHandler] = false
125
117
  handler = handler.bind(this)
126
- // update route event handler
127
- _routeEventHandler = handler
128
118
  } else {
129
119
  handler = basic404
130
- // update route event handler
131
- _routeEventHandler = basic404
132
120
  }
133
121
 
134
122
  this.after((notHandledErr, done) => {
package/lib/request.js CHANGED
@@ -49,8 +49,9 @@ function getTrustProxyFn (tp) {
49
49
  return function () { return true }
50
50
  }
51
51
  if (typeof tp === 'number') {
52
- // Support trusting hop count
53
- return function (a, i) { return i < tp }
52
+ // Hop-count-only trust cannot validate the immediate peer. Fail closed so
53
+ // direct clients cannot spoof X-Forwarded-* values by supplying enough hops.
54
+ return function () { return false }
54
55
  }
55
56
  if (typeof tp === 'string') {
56
57
  // Support comma-separated tps
package/lib/route.js CHANGED
@@ -289,6 +289,7 @@ function buildRouting (options) {
289
289
  opts.attachValidation = false
290
290
  }
291
291
 
292
+ const routeConfigUrl = prefixing ? prefix : url
292
293
  if (prefixing === false) {
293
294
  // run 'onRoute' hooks
294
295
  for (const hook of this[kHooks].onRoute) {
@@ -327,7 +328,7 @@ function buildRouting (options) {
327
328
  const constraints = opts.constraints || {}
328
329
  const config = {
329
330
  ...opts.config,
330
- url,
331
+ url: routeConfigUrl,
331
332
  method: opts.method
332
333
  }
333
334
 
@@ -409,10 +410,13 @@ function buildRouting (options) {
409
410
  context.schema = normalizeSchema(context.schema, this.initialConfig)
410
411
 
411
412
  const schemaController = this[kSchemaController]
412
- const hasValidationSchema = opts.schema.body ||
413
- opts.schema.headers ||
414
- opts.schema.querystring ||
415
- opts.schema.params
413
+ // A JSON Schema boolean (`false` or `true`) is a valid schema and
414
+ // must still trigger the validator compiler setup, so presence is
415
+ // determined from an explicit `undefined` check rather than truthiness.
416
+ const hasValidationSchema = opts.schema.body !== undefined ||
417
+ opts.schema.headers !== undefined ||
418
+ opts.schema.querystring !== undefined ||
419
+ opts.schema.params !== undefined
416
420
  if (!opts.validatorCompiler && hasValidationSchema) {
417
421
  schemaController.setupValidator(this[kOptions])
418
422
  }
package/lib/schemas.js CHANGED
@@ -61,9 +61,12 @@ function normalizeSchema (routeSchemas, serverOptions) {
61
61
  }
62
62
 
63
63
  // alias query to querystring schema
64
- if (routeSchemas.query) {
64
+ // A JSON Schema boolean (`false` or `true`) is a valid schema and must still
65
+ // be aliased, so presence is determined from an explicit `undefined` check
66
+ // rather than truthiness.
67
+ if (routeSchemas.query !== undefined) {
65
68
  // check if our schema has both querystring and query
66
- if (routeSchemas.querystring) {
69
+ if (routeSchemas.querystring !== undefined) {
67
70
  throw new FST_ERR_SCH_DUPLICATE('querystring')
68
71
  }
69
72
  routeSchemas.querystring = routeSchemas.query
package/lib/validation.js CHANGED
@@ -13,7 +13,7 @@ const {
13
13
  FST_ERR_SCH_RESPONSE_SCHEMA_NOT_NESTED_2XX
14
14
  } = require('./errors')
15
15
 
16
- const { FSTWRN001 } = require('./warnings')
16
+ const { FSTWRN001, FSTSEC002 } = require('./warnings')
17
17
 
18
18
  function compileSchemasForSerialization (context, compile) {
19
19
  if (!context.schema || !context.schema.response) {
@@ -54,6 +54,169 @@ function compileSchemasForSerialization (context, compile) {
54
54
  }, {})
55
55
  }
56
56
 
57
+ // Header names are case insensitive (RFC 9110 §5.1), and Node.js stores every
58
+ // received header name in lowercase in `request.headers` (see the Node.js
59
+ // `message.headers` docs). The header schema must be normalized the same way
60
+ // before compilation, otherwise schema keywords that reference header names by
61
+ // their canonical or mixed case — `properties`, `required`, and the Draft 7
62
+ // `dependencies` keyword (triggers and array-form dependent names) — never
63
+ // match the lowercased request object, silently skipping assertions.
64
+ //
65
+ // JSON Schema is recursive, so the transformation walks the whole schema and
66
+ // lowercases every position where a property name (i.e. a header name) appears,
67
+ // while recursing into subschemas. This covers `properties` and `required` in
68
+ // every nested subschema, `dependencies` trigger keys, array-form dependent
69
+ // property names and subschemas used as dependency values, and subschemas
70
+ // reachable through `allOf`, `anyOf`, `oneOf`, `not`, `if`/`then`/`else`, local
71
+ // `$ref` definitions (`definitions`/`$defs`), and the other standard subschema
72
+ // containers. The input schema is never mutated: a new schema tree is returned.
73
+ function lowerCaseHeadersSchema (schema) {
74
+ if (Array.isArray(schema)) {
75
+ return schema.map(lowerCaseHeadersSchema)
76
+ }
77
+ if (schema === null || typeof schema !== 'object') {
78
+ return schema
79
+ }
80
+
81
+ const result = {}
82
+
83
+ for (const key of Object.keys(schema)) {
84
+ const value = schema[key]
85
+
86
+ switch (key) {
87
+ case 'properties': {
88
+ if (value === null || typeof value !== 'object') {
89
+ result.properties = value
90
+ break
91
+ }
92
+ const normalized = {}
93
+ for (const prop of Object.keys(value)) {
94
+ normalized[prop.toLowerCase()] = lowerCaseHeadersSchema(value[prop])
95
+ }
96
+ result.properties = normalized
97
+ break
98
+ }
99
+ case 'required':
100
+ result.required = Array.isArray(value)
101
+ ? value.map(name => name.toLowerCase())
102
+ : value
103
+ break
104
+ case 'dependencies': {
105
+ if (value === null || typeof value !== 'object') {
106
+ result.dependencies = value
107
+ break
108
+ }
109
+ const normalized = {}
110
+ for (const dep of Object.keys(value)) {
111
+ const depValue = value[dep]
112
+ if (Array.isArray(depValue)) {
113
+ normalized[dep.toLowerCase()] = depValue.map(name => name.toLowerCase())
114
+ } else {
115
+ normalized[dep.toLowerCase()] = lowerCaseHeadersSchema(depValue)
116
+ }
117
+ }
118
+ result.dependencies = normalized
119
+ break
120
+ }
121
+ case 'dependentSchemas': {
122
+ if (value === null || typeof value !== 'object') {
123
+ result.dependentSchemas = value
124
+ break
125
+ }
126
+ const normalized = {}
127
+ for (const dep of Object.keys(value)) {
128
+ normalized[dep.toLowerCase()] = lowerCaseHeadersSchema(value[dep])
129
+ }
130
+ result.dependentSchemas = normalized
131
+ break
132
+ }
133
+ case 'dependentRequired': {
134
+ if (value === null || typeof value !== 'object') {
135
+ result.dependentRequired = value
136
+ break
137
+ }
138
+ const normalized = {}
139
+ for (const dep of Object.keys(value)) {
140
+ normalized[dep.toLowerCase()] = value[dep].map(name => name.toLowerCase())
141
+ }
142
+ result.dependentRequired = normalized
143
+ break
144
+ }
145
+ // Arrays of subschemas (`allOf`/`anyOf`/`oneOf`) or a single subschema
146
+ // (`not`/`if`/`then`/`else`/`items`/`additionalItems`/...). `lowerCaseHeadersSchema`
147
+ // handles arrays, objects and booleans, so recursing here covers them all.
148
+ case 'allOf':
149
+ case 'anyOf':
150
+ case 'oneOf':
151
+ case 'not':
152
+ case 'if':
153
+ case 'then':
154
+ case 'else':
155
+ case 'items':
156
+ case 'additionalItems':
157
+ case 'additionalProperties':
158
+ case 'unevaluatedItems':
159
+ case 'unevaluatedProperties':
160
+ case 'contains':
161
+ case 'propertyNames':
162
+ case 'contentSchema':
163
+ result[key] = lowerCaseHeadersSchema(value)
164
+ break
165
+ // Maps of subschemas whose keys are not header names (definition names,
166
+ // regex patterns): keep the keys, normalize the subschema values.
167
+ case 'definitions':
168
+ case '$defs':
169
+ case 'patternProperties': {
170
+ if (value === null || typeof value !== 'object') {
171
+ result[key] = value
172
+ break
173
+ }
174
+ const normalized = {}
175
+ for (const k of Object.keys(value)) {
176
+ normalized[k] = lowerCaseHeadersSchema(value[k])
177
+ }
178
+ result[key] = normalized
179
+ break
180
+ }
181
+ default:
182
+ result[key] = value
183
+ }
184
+ }
185
+
186
+ return result
187
+ }
188
+
189
+ // `lowerCaseHeadersSchema` only normalizes the inline schema tree. A header
190
+ // schema can also pull in a schema registered with `addSchema()` through an
191
+ // external `$ref` (any ref that does not start with `#`, i.e. not a same
192
+ // document fragment). Those referenced schemas are resolved by the validator
193
+ // after normalization and keep their original header-name case, so
194
+ // case-insensitive assertions in them never match the lowercased request
195
+ // headers. We cannot safely normalize them here because the same registered
196
+ // schema is shared with body, querystring and params validation, so we detect
197
+ // the external reference and warn the developer instead. Returns the first
198
+ // external `$ref` found, or undefined.
199
+ function findExternalHeaderRef (schema) {
200
+ if (Array.isArray(schema)) {
201
+ for (const item of schema) {
202
+ const ref = findExternalHeaderRef(item)
203
+ if (ref !== undefined) return ref
204
+ }
205
+ return undefined
206
+ }
207
+ if (schema === null || typeof schema !== 'object') {
208
+ return undefined
209
+ }
210
+ if (typeof schema.$ref === 'string' && schema.$ref[0] !== '#') {
211
+ return schema.$ref
212
+ }
213
+ for (const key of Object.keys(schema)) {
214
+ const ref = findExternalHeaderRef(schema[key])
215
+ if (ref !== undefined) return ref
216
+ }
217
+ return undefined
218
+ }
219
+
57
220
  function compileSchemasForValidation (context, compile, isCustom) {
58
221
  const { schema } = context
59
222
  if (!schema) {
@@ -62,32 +225,37 @@ function compileSchemasForValidation (context, compile, isCustom) {
62
225
 
63
226
  const { method, url } = context.config || {}
64
227
 
228
+ // JSON Schema Draft 7 defines the boolean `false` (and `true`) as a valid
229
+ // schema. A present boolean must be passed to the compiler unchanged, so
230
+ // every request-part schema is selected by an explicit `undefined` check
231
+ // rather than truthiness. FSTWRN001 is preserved only for schemas that are
232
+ // explicitly set to `undefined`.
233
+
65
234
  const headers = schema.headers
66
235
  // the or part is used for backward compatibility
67
- if (headers && (isCustom || Object.getPrototypeOf(headers) !== Object.prototype)) {
68
- // do not mess with schema when custom validator applied, e.g. Joi, Typebox
69
- context[headersSchema] = compile({ schema: headers, method, url, httpPart: 'headers' })
70
- } else if (headers) {
71
- // The header keys are case insensitive
72
- // https://datatracker.ietf.org/doc/html/rfc2616#section-4.2
73
- const headersSchemaLowerCase = {}
74
- Object.keys(headers).forEach(k => { headersSchemaLowerCase[k] = headers[k] })
75
- if (headersSchemaLowerCase.required instanceof Array) {
76
- headersSchemaLowerCase.required = headersSchemaLowerCase.required.map(h => h.toLowerCase())
77
- }
78
- if (headers.properties) {
79
- headersSchemaLowerCase.properties = {}
80
- Object.keys(headers.properties).forEach(k => {
81
- headersSchemaLowerCase.properties[k.toLowerCase()] = headers.properties[k]
82
- })
236
+ if (headers !== undefined) {
237
+ if (isCustom || typeof headers !== 'object' || headers === null || Object.getPrototypeOf(headers) !== Object.prototype) {
238
+ // do not mess with schema when custom validator applied, e.g. Joi, Typebox
239
+ // boolean schemas (true/false) and invalid values are compiled directly
240
+ context[headersSchema] = compile({ schema: headers, method, url, httpPart: 'headers' })
241
+ } else {
242
+ // The header keys are case insensitive
243
+ // https://datatracker.ietf.org/doc/html/rfc2616#section-4.2
244
+ const headersSchemaLowerCase = lowerCaseHeadersSchema(headers)
245
+ const externalRef = findExternalHeaderRef(headers)
246
+ if (externalRef !== undefined) {
247
+ FSTSEC002(method, url, externalRef)
248
+ }
249
+ context[headersSchema] = compile({ schema: headersSchemaLowerCase, method, url, httpPart: 'headers' })
83
250
  }
84
- context[headersSchema] = compile({ schema: headersSchemaLowerCase, method, url, httpPart: 'headers' })
85
251
  } else if (Object.hasOwn(schema, 'headers')) {
86
252
  FSTWRN001('headers', method, url)
87
253
  }
88
254
 
89
- if (schema.body) {
90
- const contentProperty = schema.body.content
255
+ if (schema.body !== undefined) {
256
+ const contentProperty = schema.body !== null && typeof schema.body === 'object'
257
+ ? schema.body.content
258
+ : undefined
91
259
  if (contentProperty) {
92
260
  const contentTypeSchemas = {}
93
261
  for (const contentType of Object.keys(contentProperty)) {
@@ -102,13 +270,13 @@ function compileSchemasForValidation (context, compile, isCustom) {
102
270
  FSTWRN001('body', method, url)
103
271
  }
104
272
 
105
- if (schema.querystring) {
273
+ if (schema.querystring !== undefined) {
106
274
  context[querystringSchema] = compile({ schema: schema.querystring, method, url, httpPart: 'querystring' })
107
275
  } else if (Object.hasOwn(schema, 'querystring')) {
108
276
  FSTWRN001('querystring', method, url)
109
277
  }
110
278
 
111
- if (schema.params) {
279
+ if (schema.params !== undefined) {
112
280
  context[paramsSchema] = compile({ schema: schema.params, method, url, httpPart: 'params' })
113
281
  } else if (Object.hasOwn(schema, 'params')) {
114
282
  FSTWRN001('params', method, url)
@@ -120,7 +288,16 @@ function validateParam (validatorFunction, request, paramName) {
120
288
  let ret
121
289
 
122
290
  try {
123
- ret = validatorFunction?.(isUndefined ? null : request[paramName])
291
+ const data = isUndefined ? null : request[paramName]
292
+ // Ajv can only mutate a root value when its parent is provided.
293
+ if (validatorFunction?.schemaEnv) {
294
+ ret = validatorFunction(data, {
295
+ parentData: request,
296
+ parentDataProperty: paramName
297
+ })
298
+ } else {
299
+ ret = validatorFunction?.(data)
300
+ }
124
301
  } catch (err) {
125
302
  // If validator throws synchronously, ensure it propagates as an internal error
126
303
  err.statusCode = 500
@@ -128,8 +305,13 @@ function validateParam (validatorFunction, request, paramName) {
128
305
  }
129
306
 
130
307
  if (ret && typeof ret.then === 'function') {
308
+ // An async validator (for example an `$async` Ajv schema) resolves with the
309
+ // validated data itself, not a `{ value, error }` result. Its properties must
310
+ // not be unwrapped the way a synchronous custom compiler result is, otherwise
311
+ // a payload carrying an `error` or `value` key could inject a validation error
312
+ // or replace the whole request part. Treat the async result as pass/fail only.
131
313
  return ret
132
- .then((res) => { return answer(res) })
314
+ .then((res) => { return res === false ? validatorFunction.errors : false })
133
315
  .catch(err => { return err }) // return as simple error (not throw)
134
316
  }
135
317