fastify 6.0.0-alpha.4 → 6.0.0-alpha.6

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.
Files changed (63) hide show
  1. package/README.md +1 -0
  2. package/SECURITY.md +6 -0
  3. package/build/build-validation.js +2 -0
  4. package/docs/Guides/Ecosystem.md +12 -53
  5. package/docs/Guides/Migration-Guide-V5.md +2 -2
  6. package/docs/Guides/Serverless.md +33 -10
  7. package/docs/Reference/Errors.md +13 -2
  8. package/docs/Reference/Hooks.md +13 -0
  9. package/docs/Reference/Reply.md +787 -681
  10. package/docs/Reference/Request.md +308 -104
  11. package/docs/Reference/Routes.md +6 -2
  12. package/docs/Reference/Server.md +41 -4
  13. package/docs/Reference/Validation-and-Serialization.md +48 -1
  14. package/docs/Reference/Warnings.md +2 -0
  15. package/fastify.d.ts +1 -0
  16. package/fastify.js +35 -1
  17. package/lib/config-validator.js +102 -73
  18. package/lib/content-type-parser.js +35 -14
  19. package/lib/content-type.js +4 -4
  20. package/lib/context.js +2 -1
  21. package/lib/decorate.js +4 -3
  22. package/lib/error-handler.js +21 -4
  23. package/lib/error-status.js +4 -2
  24. package/lib/errors.js +15 -14
  25. package/lib/handle-request.js +7 -20
  26. package/lib/reply.js +18 -13
  27. package/lib/request.js +12 -7
  28. package/lib/route.js +40 -7
  29. package/lib/schemas.js +44 -32
  30. package/lib/validation.js +67 -10
  31. package/lib/warnings.js +10 -1
  32. package/package.json +8 -3
  33. package/test/child-logger-factory.test.js +25 -0
  34. package/test/close-disposable.test.js +154 -0
  35. package/test/constrained-routes.test.js +4 -2
  36. package/test/content-parser.test.js +99 -0
  37. package/test/decorator.test.js +29 -0
  38. package/test/diagnostics-channel/error-status.test.js +35 -2
  39. package/test/handler-timeout.test.js +224 -0
  40. package/test/internals/all.test.js +5 -0
  41. package/test/internals/errors.test.js +19 -20
  42. package/test/internals/initial-config.test.js +2 -0
  43. package/test/internals/reply-serialize.test.js +20 -0
  44. package/test/internals/reply.test.js +30 -1
  45. package/test/internals/request.test.js +50 -0
  46. package/test/internals/server.test.js +6 -4
  47. package/test/reply-error.test.js +82 -0
  48. package/test/reply-media-type.test.js +57 -0
  49. package/test/reply-trailers.test.js +159 -1
  50. package/test/rfc-10008.test.js +6 -9
  51. package/test/router-compile.test.js +241 -0
  52. package/test/schema-feature.test.js +1 -1
  53. package/test/schema-serialization.test.js +120 -7
  54. package/test/schema-validation.test.js +251 -1
  55. package/test/types/errors.tst.ts +0 -1
  56. package/test/types/fastify.tst.ts +9 -0
  57. package/test/types/instance.tst.ts +1 -1
  58. package/test/types/route.tst.ts +37 -0
  59. package/test/types/using.tst.ts +20 -0
  60. package/test/validation-error-handling.test.js +21 -1
  61. package/types/errors.d.ts +0 -1
  62. package/types/instance.d.ts +9 -5
  63. package/types/route.d.ts +4 -2
package/lib/errors.js CHANGED
@@ -2,6 +2,12 @@
2
2
 
3
3
  const createError = require('@fastify/error')
4
4
 
5
+ // Errors created when Fastify rejects an invalid request. They have no stack
6
+ // trace, see "Errors In Input Data" in docs/Reference/Errors.md.
7
+ function createRequestError (code, message, statusCode, Base = Error) {
8
+ return createError(code, message, statusCode, Base, false)
9
+ }
10
+
5
11
  const codes = {
6
12
  /**
7
13
  * Basic
@@ -47,7 +53,7 @@ const codes = {
47
53
  500,
48
54
  TypeError
49
55
  ),
50
- FST_ERR_VALIDATION: createError(
56
+ FST_ERR_VALIDATION: createRequestError(
51
57
  'FST_ERR_VALIDATION',
52
58
  '%s',
53
59
  400
@@ -102,29 +108,29 @@ const codes = {
102
108
  500,
103
109
  TypeError
104
110
  ),
105
- FST_ERR_CTP_BODY_TOO_LARGE: createError(
111
+ FST_ERR_CTP_BODY_TOO_LARGE: createRequestError(
106
112
  'FST_ERR_CTP_BODY_TOO_LARGE',
107
113
  'Request body is too large',
108
114
  413,
109
115
  RangeError
110
116
  ),
111
- FST_ERR_CTP_INVALID_MEDIA_TYPE: createError(
117
+ FST_ERR_CTP_INVALID_MEDIA_TYPE: createRequestError(
112
118
  'FST_ERR_CTP_INVALID_MEDIA_TYPE',
113
119
  'Unsupported Media Type',
114
120
  415
115
121
  ),
116
- FST_ERR_CTP_INVALID_CONTENT_LENGTH: createError(
122
+ FST_ERR_CTP_INVALID_CONTENT_LENGTH: createRequestError(
117
123
  'FST_ERR_CTP_INVALID_CONTENT_LENGTH',
118
124
  'Request body size did not match Content-Length',
119
125
  400,
120
126
  RangeError
121
127
  ),
122
- FST_ERR_CTP_EMPTY_JSON_BODY: createError(
128
+ FST_ERR_CTP_EMPTY_JSON_BODY: createRequestError(
123
129
  'FST_ERR_CTP_EMPTY_JSON_BODY',
124
130
  "Body cannot be empty when content-type is set to 'application/json'",
125
131
  400
126
132
  ),
127
- FST_ERR_CTP_INVALID_JSON_BODY: createError(
133
+ FST_ERR_CTP_INVALID_JSON_BODY: createRequestError(
128
134
  'FST_ERR_CTP_INVALID_JSON_BODY',
129
135
  "Body is not valid JSON but content-type is set to 'application/json'",
130
136
  400
@@ -365,13 +371,13 @@ const codes = {
365
371
  'FST_ERR_DUPLICATED_ROUTE',
366
372
  "Method '%s' already declared for route '%s'"
367
373
  ),
368
- FST_ERR_BAD_URL: createError(
374
+ FST_ERR_BAD_URL: createRequestError(
369
375
  'FST_ERR_BAD_URL',
370
376
  "'%s' is not a valid url component",
371
377
  400,
372
378
  URIError
373
379
  ),
374
- FST_ERR_MAX_PARAM_LENGTH: createError(
380
+ FST_ERR_MAX_PARAM_LENGTH: createRequestError(
375
381
  'FST_ERR_MAX_PARAM_LENGTH',
376
382
  "'%s' is exceeding the max param length",
377
383
  414,
@@ -461,16 +467,11 @@ const codes = {
461
467
  500,
462
468
  TypeError
463
469
  ),
464
- FST_ERR_ROUTE_MISSING_CONTENT_TYPE: createError(
470
+ FST_ERR_ROUTE_MISSING_CONTENT_TYPE: createRequestError(
465
471
  'FST_ERR_ROUTE_MISSING_CONTENT_TYPE',
466
472
  "Method '%s' must provide a 'Content-Type' header.",
467
473
  400
468
474
  ),
469
- FST_ERR_ROUTE_MISSING_CONTENT: createError(
470
- 'FST_ERR_ROUTE_MISSING_CONTENT',
471
- "Method '%s' must provide a request body.",
472
- 400
473
- ),
474
475
 
475
476
  /**
476
477
  * again listen when close server
@@ -6,8 +6,7 @@ const { validate: validateSchema } = require('./validation')
6
6
  const { preValidationHookRunner, preHandlerHookRunner } = require('./hooks')
7
7
  const {
8
8
  FST_ERR_CTP_INVALID_MEDIA_TYPE,
9
- FST_ERR_ROUTE_MISSING_CONTENT_TYPE,
10
- FST_ERR_ROUTE_MISSING_CONTENT
9
+ FST_ERR_ROUTE_MISSING_CONTENT_TYPE
11
10
  } = require('./errors')
12
11
  const { setErrorStatusCode } = require('./error-status')
13
12
  const {
@@ -51,20 +50,15 @@ function handleRequest (err, request, reply) {
51
50
  reply.status(400).send(new FST_ERR_ROUTE_MISSING_CONTENT_TYPE(method))
52
51
  return
53
52
  }
54
-
55
- if (isEmptyBody(headers)) {
56
- // https://datatracker.ietf.org/doc/html/rfc10008#section-2
57
- // Servers MUST fail the request if the Content-Type request field
58
- // ([HTTP], Section 8.3) is missing or is inconsistent with the
59
- // request content.
60
- reply[kReplyIsError] = true
61
- reply.status(400).send(new FST_ERR_ROUTE_MISSING_CONTENT(method))
62
- return
63
- }
64
53
  }
65
54
 
66
55
  if (ctHeader === undefined) {
67
- if (isEmptyBody(headers)) {
56
+ const contentLength = headers['content-length']
57
+ const transferEncoding = headers['transfer-encoding']
58
+ const isEmptyBody = transferEncoding === undefined &&
59
+ (contentLength === undefined || contentLength === '0')
60
+
61
+ if (isEmptyBody) {
68
62
  // Request has no body to parse
69
63
  handler(request, reply)
70
64
  return
@@ -109,13 +103,6 @@ function handler (request, reply) {
109
103
  }
110
104
  }
111
105
 
112
- function isEmptyBody (headers) {
113
- const contentLength = headers['content-length']
114
- const transferEncoding = headers['transfer-encoding']
115
- return transferEncoding === undefined &&
116
- (contentLength === undefined || contentLength === '0')
117
- }
118
-
119
106
  function preValidationCallback (err, request, reply) {
120
107
  if (reply.sent === true) return
121
108
 
package/lib/reply.js CHANGED
@@ -95,7 +95,7 @@ Object.defineProperties(Reply.prototype, {
95
95
  },
96
96
  mediaType: {
97
97
  get () {
98
- const contentTypeHeader = this[kReplyHeaders]['content-type']
98
+ const contentTypeHeader = this.getHeader('content-type')
99
99
  return ContentType.from(contentTypeHeader).mediaType
100
100
  }
101
101
  },
@@ -284,6 +284,10 @@ Reply.prototype.header = function (key, value = '') {
284
284
  } else {
285
285
  this[kReplyHeaders][key].push(value)
286
286
  }
287
+ } else if (key === 'set-cookie' && Array.isArray(value)) {
288
+ // Copy the array: later calls push into the stored one, and the caller's
289
+ // array (often a module-level constant) must not grow across requests.
290
+ this[kReplyHeaders][key] = value.slice()
287
291
  } else {
288
292
  this[kReplyHeaders][key] = value
289
293
  }
@@ -370,7 +374,7 @@ Reply.prototype.getSerializationFunction = function (schemaOrStatus, contentType
370
374
  return serialize
371
375
  }
372
376
 
373
- Reply.prototype.compileSerializationSchema = function (schema, httpStatus = null, contentType = null) {
377
+ Reply.prototype.compileSerializationSchema = function (schema, httpStatus, contentType) {
374
378
  const { request } = this
375
379
  const { method, url } = request
376
380
 
@@ -839,9 +843,6 @@ function sendStream (payload, res, reply) {
839
843
  let sourceOpen = true
840
844
  let errorLogged = false
841
845
 
842
- // set trailer when stream ended
843
- sendStreamTrailer(payload, res, reply)
844
-
845
846
  eos(payload, { readable: true, writable: false }, function (err) {
846
847
  sourceOpen = false
847
848
  if (err != null) {
@@ -854,8 +855,12 @@ function sendStream (payload, res, reply) {
854
855
  } else {
855
856
  onErrorHook(reply, err)
856
857
  }
858
+ return
859
+ }
860
+ // eos fires even for a stream that already ended
861
+ if (reply[kReplyTrailers] !== null) {
862
+ sendTrailer(null, res, reply)
857
863
  }
858
- // there is nothing to do if there is not an error
859
864
  })
860
865
 
861
866
  eos(res, function (err) {
@@ -887,7 +892,12 @@ function sendStream (payload, res, reply) {
887
892
  } else {
888
893
  reply.log.warn('response will send, but you shouldn\'t use res.writeHead in stream mode')
889
894
  }
890
- payload.pipe(res)
895
+ // trailers set: the eos listener above ends the response, not pipe
896
+ if (reply[kReplyTrailers] !== null) {
897
+ payload.pipe(res, { end: false })
898
+ } else {
899
+ payload.pipe(res)
900
+ }
891
901
  }
892
902
 
893
903
  function sendTrailer (payload, res, reply) {
@@ -947,11 +957,6 @@ function sendTrailer (payload, res, reply) {
947
957
  if (skipped) res.end(null, null, null) // avoid ArgumentsAdaptorTrampoline from V8
948
958
  }
949
959
 
950
- function sendStreamTrailer (payload, res, reply) {
951
- if (reply[kReplyTrailers] === null) return
952
- payload.on('end', () => sendTrailer(null, res, reply))
953
- }
954
-
955
960
  function onErrorHook (reply, error, cb) {
956
961
  if (reply[kRouteContext].onError !== null && !reply[kReplyNextErrorHandler]) {
957
962
  reply[kReplyIsRunningOnErrorHook] = true
@@ -1079,7 +1084,7 @@ function notFound (reply) {
1079
1084
  * @returns {string} the serialized payload
1080
1085
  */
1081
1086
  function serialize (context, data, statusCode, contentType) {
1082
- const fnSerialize = getSchemaSerializer(context, statusCode, contentType)
1087
+ const fnSerialize = getSchemaSerializer(context, statusCode, contentType, { failClosed: true })
1083
1088
  if (fnSerialize) {
1084
1089
  return fnSerialize(data)
1085
1090
  }
package/lib/request.js CHANGED
@@ -20,6 +20,8 @@ const { FST_ERR_REQ_INVALID_VALIDATION_INVOCATION, FST_ERR_DEC_UNDECLARED } = re
20
20
  const decorators = require('./decorate')
21
21
  const ContentType = require('./content-type')
22
22
 
23
+ const PORT_REGEX = /(?<port>:\d+)$/
24
+
23
25
  const HTTP_PART_SYMBOL_MAP = {
24
26
  body: kSchemaBody,
25
27
  headers: kSchemaHeaders,
@@ -122,8 +124,10 @@ function buildRequestWithTrustProxy (R, trustProxy) {
122
124
  host: {
123
125
  get () {
124
126
  const socket = this.raw.socket
125
- if (this.headers['x-forwarded-host'] && socket != null && proxyFn(socket.remoteAddress, 0)) {
126
- return getLastEntryInMultiHeaderValue(this.headers['x-forwarded-host'])
127
+ // The headers getter may allocate a new object, so read it once.
128
+ const headers = this.headers
129
+ if (headers['x-forwarded-host'] && socket != null && proxyFn(socket.remoteAddress, 0)) {
130
+ return getLastEntryInMultiHeaderValue(headers['x-forwarded-host'])
127
131
  }
128
132
  /**
129
133
  * The last fallback supports the following cases:
@@ -131,14 +135,16 @@ function buildRequestWithTrustProxy (R, trustProxy) {
131
135
  * 2. HTTP/1.0 without a Host Header
132
136
  * 3. Headers schema that may remove the Host Header
133
137
  */
134
- return this.headers.host ?? this.headers[':authority'] ?? ''
138
+ return headers.host ?? headers[':authority'] ?? ''
135
139
  }
136
140
  },
137
141
  protocol: {
138
142
  get () {
139
143
  const socket = this.raw.socket
140
- if (this.headers['x-forwarded-proto'] && socket != null && proxyFn(socket.remoteAddress, 0)) {
141
- return getLastEntryInMultiHeaderValue(this.headers['x-forwarded-proto'])
144
+ // The headers getter may allocate a new object, so read it once.
145
+ const headers = this.headers
146
+ if (headers['x-forwarded-proto'] && socket != null && proxyFn(socket.remoteAddress, 0)) {
147
+ return getLastEntryInMultiHeaderValue(headers['x-forwarded-proto'])
142
148
  }
143
149
  if (this.socket) {
144
150
  return this.socket.encrypted ? 'https' : 'http'
@@ -259,8 +265,7 @@ Object.defineProperties(Request.prototype, {
259
265
  },
260
266
  port: {
261
267
  get () {
262
- const portReg = /(?<port>:\d+)$/
263
- const matches = portReg.exec(this.host)
268
+ const matches = PORT_REGEX.exec(this.host)
264
269
  if (matches === null || matches[1] === undefined) {
265
270
  return null
266
271
  }
package/lib/route.js CHANGED
@@ -110,7 +110,10 @@ function buildRouting (options) {
110
110
  return503OnClosing = Object.hasOwn(options, 'return503OnClosing') ? options.return503OnClosing : true
111
111
  keepAliveConnections = fastifyArgs.keepAliveConnections
112
112
  },
113
- routing: router.lookup.bind(router), // router func to find the right handler to call
113
+ // router func to find the right handler to call, not bound because
114
+ // router.compile() replaces router.lookup
115
+ routing: (req, res, done) => router.lookup(req, res, done),
116
+ compileRoutes: () => router.compile(),
114
117
  route, // configure a route in the fastify instance
115
118
  hasRoute,
116
119
  prepareRoute,
@@ -466,8 +469,12 @@ function buildRouting (options) {
466
469
  // the child logger creation altogether
467
470
  childLogger = logger
468
471
  } else {
469
- const loggerOpts = {
470
- level: context.logLevel
472
+ const loggerOpts = {}
473
+
474
+ // An empty level means "inherit". Passing it would make pino treat it
475
+ // as a level change and rebuild every log method of each request child.
476
+ if (context.logLevel) {
477
+ loggerOpts.level = context.logLevel
471
478
  }
472
479
 
473
480
  if (context.logSerializers) {
@@ -531,12 +538,35 @@ function buildRouting (options) {
531
538
  request[kRequestSignal] = ac
532
539
 
533
540
  request[kTimeoutTimer] = setTimeout(() => {
534
- if (!reply.sent) {
535
- const err = new FST_ERR_HANDLER_TIMEOUT(handlerTimeout, context.config?.url)
536
- ac.abort(err)
541
+ // If the reply has already ended (or was hijacked) the response
542
+ // lifecycle is managed elsewhere and there is nothing left to do.
543
+ if (reply.sent === true) {
544
+ return
545
+ }
546
+
547
+ const err = new FST_ERR_HANDLER_TIMEOUT(handlerTimeout, context.config?.url)
548
+ ac.abort(err)
549
+
550
+ // Aborting runs any request.signal listener synchronously; if a
551
+ // listener sent or hijacked the response, leave it intact rather than
552
+ // destroying it, which would reset the connection.
553
+ if (reply.sent === true) {
554
+ return
555
+ }
556
+
557
+ if (reply.raw.headersSent === true) {
558
+ // The response has already started transmitting, e.g. an active
559
+ // stream that has written its headers/body before the timeout.
560
+ // Sending an error response now would raise an uncaught
561
+ // ERR_HTTP_HEADERS_SENT and terminate the process, so abort the
562
+ // in-flight response instead.
537
563
  reply[kReplyIsError] = true
538
- reply.send(err)
564
+ reply.raw.destroy(err)
565
+ return
539
566
  }
567
+
568
+ reply[kReplyIsError] = true
569
+ reply.send(err)
540
570
  }, handlerTimeout)
541
571
 
542
572
  const onAbort = () => {
@@ -674,6 +704,9 @@ function buildRouterOptions (options, defaultOptions) {
674
704
  }
675
705
  }
676
706
 
707
+ // compile is only accepted in routerOptions, not as a top-level option
708
+ routerOptions.compile ??= defaultInitOptions.routerOptions.compile
709
+
677
710
  return routerOptions
678
711
  }
679
712
 
package/lib/schemas.js CHANGED
@@ -8,7 +8,8 @@ const {
8
8
  FST_ERR_SCH_MISSING_ID,
9
9
  FST_ERR_SCH_ALREADY_PRESENT,
10
10
  FST_ERR_SCH_DUPLICATE,
11
- FST_ERR_SCH_CONTENT_MISSING_SCHEMA
11
+ FST_ERR_SCH_CONTENT_MISSING_SCHEMA,
12
+ FST_ERR_MISSING_CONTENTTYPE_SERIALIZATION_FN
12
13
  } = require('./errors')
13
14
  const ContentType = require('./content-type')
14
15
 
@@ -134,6 +135,40 @@ function generateFluentSchema (schema) {
134
135
  }
135
136
  }
136
137
 
138
+ /**
139
+ * Resolve the right JSON Schema serialization function for a per-media-type
140
+ * (`content`) response schema map, given the response media type.
141
+ *
142
+ * @param {object} contentSchema the compiled per-content-type schema map
143
+ * @param {string} mediaType the response media type (lowercased)
144
+ * @param {string|number} statusCode the resolved http status key
145
+ * @param {boolean} failClosed whether to throw when no schema matches (used
146
+ * for route response serialization) or return false (used by the error
147
+ * handler, which falls back to the default error serializer)
148
+ * @returns {function|false} the serializer function, or false when no schema
149
+ * applies and failClosed is false
150
+ */
151
+ function getContentSerializer (contentSchema, mediaType, statusCode, failClosed) {
152
+ if (contentSchema[mediaType]) {
153
+ return contentSchema[mediaType]
154
+ }
155
+
156
+ // fallback to match all media-types
157
+ if (contentSchema['*/*']) {
158
+ return contentSchema['*/*']
159
+ }
160
+
161
+ // The response content-type is not declared in the route's content map. When
162
+ // serializing a route response, do not fall back to a raw JSON dump (which
163
+ // would bypass the response schema and expose every field); surface the
164
+ // misconfiguration instead.
165
+ if (failClosed) {
166
+ throw new FST_ERR_MISSING_CONTENTTYPE_SERIALIZATION_FN(statusCode, mediaType)
167
+ }
168
+
169
+ return false
170
+ }
171
+
137
172
  /**
138
173
  * Search for the right JSON schema compiled function in the request context
139
174
  * setup by the route configuration `schema.response`.
@@ -142,11 +177,15 @@ function generateFluentSchema (schema) {
142
177
  * @param {object} context the request context
143
178
  * @param {number} statusCode the http status code
144
179
  * @param {string} [contentType] the reply content type
180
+ * @param {object} [opts] serialization options
181
+ * @param {boolean} [opts.failClosed] when true, throw when the response
182
+ * content-type is not declared in the content map; otherwise return false
145
183
  * @returns {function|false} the right JSON Schema function to serialize
146
184
  * the reply or false if it is not set
147
185
  */
148
- function getSchemaSerializer (context, statusCode, contentType) {
186
+ function getSchemaSerializer (context, statusCode, contentType, opts = {}) {
149
187
  const responseSchemaDef = context[kSchemaResponse]
188
+ const failClosed = opts.failClosed === true
150
189
  if (!responseSchemaDef) {
151
190
  return false
152
191
  }
@@ -154,16 +193,7 @@ function getSchemaSerializer (context, statusCode, contentType) {
154
193
  if (responseSchemaDef[statusCode].constructor === Object) {
155
194
  const ct = ContentType.from(contentType)
156
195
  if (ct.isValid) {
157
- if (responseSchemaDef[statusCode][ct.mediaType]) {
158
- return responseSchemaDef[statusCode][ct.mediaType]
159
- }
160
-
161
- // fallback to match all media-type
162
- if (responseSchemaDef[statusCode]['*/*']) {
163
- return responseSchemaDef[statusCode]['*/*']
164
- }
165
-
166
- return false
196
+ return getContentSerializer(responseSchemaDef[statusCode], ct.mediaType, statusCode, failClosed)
167
197
  }
168
198
  }
169
199
  return responseSchemaDef[statusCode]
@@ -173,16 +203,7 @@ function getSchemaSerializer (context, statusCode, contentType) {
173
203
  if (responseSchemaDef[fallbackStatusCode].constructor === Object) {
174
204
  const ct = ContentType.from(contentType)
175
205
  if (ct.isValid) {
176
- if (responseSchemaDef[fallbackStatusCode][ct.mediaType]) {
177
- return responseSchemaDef[fallbackStatusCode][ct.mediaType]
178
- }
179
-
180
- // fallback to match all media-type
181
- if (responseSchemaDef[fallbackStatusCode]['*/*']) {
182
- return responseSchemaDef[fallbackStatusCode]['*/*']
183
- }
184
-
185
- return false
206
+ return getContentSerializer(responseSchemaDef[fallbackStatusCode], ct.mediaType, fallbackStatusCode, failClosed)
186
207
  }
187
208
  }
188
209
 
@@ -192,16 +213,7 @@ function getSchemaSerializer (context, statusCode, contentType) {
192
213
  if (responseSchemaDef.default.constructor === Object) {
193
214
  const ct = ContentType.from(contentType)
194
215
  if (ct.isValid) {
195
- if (responseSchemaDef.default[ct.mediaType]) {
196
- return responseSchemaDef.default[ct.mediaType]
197
- }
198
-
199
- // fallback to match all media-type
200
- if (responseSchemaDef.default['*/*']) {
201
- return responseSchemaDef.default['*/*']
202
- }
203
-
204
- return false
216
+ return getContentSerializer(responseSchemaDef.default, ct.mediaType, 'default', failClosed)
205
217
  }
206
218
  }
207
219
 
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, FSTSEC002 } = require('./warnings')
16
+ const { FSTWRN001, FSTSEC002, FSTSEC003 } = require('./warnings')
17
17
 
18
18
  function compileSchemasForSerialization (context, compile) {
19
19
  if (!context.schema || !context.schema.response) {
@@ -70,9 +70,14 @@ function compileSchemasForSerialization (context, compile) {
70
70
  // reachable through `allOf`, `anyOf`, `oneOf`, `not`, `if`/`then`/`else`, local
71
71
  // `$ref` definitions (`definitions`/`$defs`), and the other standard subschema
72
72
  // containers. The input schema is never mutated: a new schema tree is returned.
73
- function lowerCaseHeadersSchema (schema) {
73
+ //
74
+ // A `propertyNames` subschema validates each header name itself, so values held
75
+ // in `enum` and `const` (and in every subschema reachable from it) are header
76
+ // names rather than header values. `insidePropertyNames` tracks that context so
77
+ // those `enum`/`const` string values are lowercased there, and only there.
78
+ function lowerCaseHeadersSchema (schema, insidePropertyNames = false) {
74
79
  if (Array.isArray(schema)) {
75
- return schema.map(lowerCaseHeadersSchema)
80
+ return schema.map(item => lowerCaseHeadersSchema(item, insidePropertyNames))
76
81
  }
77
82
  if (schema === null || typeof schema !== 'object') {
78
83
  return schema
@@ -91,7 +96,7 @@ function lowerCaseHeadersSchema (schema) {
91
96
  }
92
97
  const normalized = {}
93
98
  for (const prop of Object.keys(value)) {
94
- normalized[prop.toLowerCase()] = lowerCaseHeadersSchema(value[prop])
99
+ normalized[prop.toLowerCase()] = lowerCaseHeadersSchema(value[prop], insidePropertyNames)
95
100
  }
96
101
  result.properties = normalized
97
102
  break
@@ -112,7 +117,7 @@ function lowerCaseHeadersSchema (schema) {
112
117
  if (Array.isArray(depValue)) {
113
118
  normalized[dep.toLowerCase()] = depValue.map(name => name.toLowerCase())
114
119
  } else {
115
- normalized[dep.toLowerCase()] = lowerCaseHeadersSchema(depValue)
120
+ normalized[dep.toLowerCase()] = lowerCaseHeadersSchema(depValue, insidePropertyNames)
116
121
  }
117
122
  }
118
123
  result.dependencies = normalized
@@ -125,7 +130,7 @@ function lowerCaseHeadersSchema (schema) {
125
130
  }
126
131
  const normalized = {}
127
132
  for (const dep of Object.keys(value)) {
128
- normalized[dep.toLowerCase()] = lowerCaseHeadersSchema(value[dep])
133
+ normalized[dep.toLowerCase()] = lowerCaseHeadersSchema(value[dep], insidePropertyNames)
129
134
  }
130
135
  result.dependentSchemas = normalized
131
136
  break
@@ -158,12 +163,21 @@ function lowerCaseHeadersSchema (schema) {
158
163
  case 'unevaluatedItems':
159
164
  case 'unevaluatedProperties':
160
165
  case 'contains':
161
- case 'propertyNames':
162
166
  case 'contentSchema':
163
- result[key] = lowerCaseHeadersSchema(value)
167
+ result[key] = lowerCaseHeadersSchema(value, insidePropertyNames)
168
+ break
169
+ case 'propertyNames':
170
+ result[key] = lowerCaseHeadersSchema(value, true)
164
171
  break
165
172
  // Maps of subschemas whose keys are not header names (definition names,
166
173
  // regex patterns): keep the keys, normalize the subschema values.
174
+ //
175
+ // `patternProperties` keys are regular expressions matched against the
176
+ // already-lowercased header names and are left as-is: a regular expression
177
+ // cannot be safely lowercased. The same applies to a `propertyNames`
178
+ // `pattern`. A header-name pattern written in canonical case will not match
179
+ // the lowercased request headers, so it must be authored to match
180
+ // lowercase. See docs/Reference/Validation-and-Serialization.md.
167
181
  case 'definitions':
168
182
  case '$defs':
169
183
  case 'patternProperties': {
@@ -173,13 +187,23 @@ function lowerCaseHeadersSchema (schema) {
173
187
  }
174
188
  const normalized = {}
175
189
  for (const k of Object.keys(value)) {
176
- normalized[k] = lowerCaseHeadersSchema(value[k])
190
+ normalized[k] = lowerCaseHeadersSchema(value[k], insidePropertyNames)
177
191
  }
178
192
  result[key] = normalized
179
193
  break
180
194
  }
181
195
  default:
182
- result[key] = value
196
+ // Inside a `propertyNames` subschema, `enum` and `const` values are the
197
+ // candidate header names themselves, so they must be lowercased to match
198
+ // the lowercased request headers. Everywhere else these hold header
199
+ // *values* (or arbitrary constant data) and must be left untouched.
200
+ if (insidePropertyNames && key === 'enum' && Array.isArray(value)) {
201
+ result[key] = value.map(item => typeof item === 'string' ? item.toLowerCase() : item)
202
+ } else if (insidePropertyNames && key === 'const' && typeof value === 'string') {
203
+ result[key] = value.toLowerCase()
204
+ } else {
205
+ result[key] = value
206
+ }
183
207
  }
184
208
  }
185
209
 
@@ -217,6 +241,35 @@ function findExternalHeaderRef (schema) {
217
241
  return undefined
218
242
  }
219
243
 
244
+ // A `$ref` reached inside a `propertyNames` subschema resolves to a definition
245
+ // that is normalized in its own, non-`propertyNames` context, so header names
246
+ // held in its `enum`/`const` keep their original case and a case-insensitive
247
+ // deny-list or allow-list never matches the lowercased request headers. We
248
+ // cannot safely normalize the referenced definition here because it may be
249
+ // shared with value contexts, so we detect the reference and warn instead.
250
+ // Returns the first local `$ref` found inside a `propertyNames` subschema, or
251
+ // undefined.
252
+ function findPropertyNamesRef (schema, insidePropertyNames = false) {
253
+ if (Array.isArray(schema)) {
254
+ for (const item of schema) {
255
+ const ref = findPropertyNamesRef(item, insidePropertyNames)
256
+ if (ref !== undefined) return ref
257
+ }
258
+ return undefined
259
+ }
260
+ if (schema === null || typeof schema !== 'object') {
261
+ return undefined
262
+ }
263
+ if (insidePropertyNames && typeof schema.$ref === 'string' && schema.$ref[0] === '#') {
264
+ return schema.$ref
265
+ }
266
+ for (const key of Object.keys(schema)) {
267
+ const ref = findPropertyNamesRef(schema[key], insidePropertyNames || key === 'propertyNames')
268
+ if (ref !== undefined) return ref
269
+ }
270
+ return undefined
271
+ }
272
+
220
273
  function compileSchemasForValidation (context, compile, isCustom) {
221
274
  const { schema } = context
222
275
  if (!schema) {
@@ -246,6 +299,10 @@ function compileSchemasForValidation (context, compile, isCustom) {
246
299
  if (externalRef !== undefined) {
247
300
  FSTSEC002(method, url, externalRef)
248
301
  }
302
+ const propertyNamesRef = findPropertyNamesRef(headers)
303
+ if (propertyNamesRef !== undefined) {
304
+ FSTSEC003(method, url, propertyNamesRef)
305
+ }
249
306
  context[headersSchema] = compile({ schema: headersSchemaLowerCase, method, url, httpPart: 'headers' })
250
307
  }
251
308
  } else if (Object.hasOwn(schema, 'headers')) {
package/lib/warnings.js CHANGED
@@ -7,6 +7,7 @@ const { createWarning } = require('process-warning')
7
7
  * - FSTWRN001
8
8
  * - FSTSEC001
9
9
  * - FSTSEC002
10
+ * - FSTSEC003
10
11
  *
11
12
  * Deprecation Codes FSTDEP001 - FSTDEP021 were used by v4 and MUST NOT be reused.
12
13
  * - FSTDEP022 - FSTDEP025 are used by v5 and MUST NOT be reused.
@@ -41,9 +42,17 @@ const FSTSEC002 = createWarning({
41
42
  unlimited: true
42
43
  })
43
44
 
45
+ const FSTSEC003 = createWarning({
46
+ name: 'FastifySecurity',
47
+ code: 'FSTSEC003',
48
+ message: 'The headers schema for %s: %s uses a $ref (%s) inside propertyNames that is not case-normalized. Header names held in the referenced schema keep their original case and will not match the lowercased request headers, so a case-insensitive deny-list or allow-list on header names may not apply. Inline the propertyNames constraint instead of referencing it with a $ref.',
49
+ unlimited: true
50
+ })
51
+
44
52
  module.exports = {
45
53
  FSTWRN001,
46
54
  FSTWRN003,
47
55
  FSTSEC001,
48
- FSTSEC002
56
+ FSTSEC002,
57
+ FSTSEC003
49
58
  }