fastify 5.12.4 → 5.12.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.
@@ -445,7 +445,7 @@ Below is a table with all the error codes used by Fastify.
445
445
  | <a id="fst_err_route_handler_timeout_option_not_int">FST_ERR_ROUTE_HANDLER_TIMEOUT_OPTION_NOT_INT</a> | `handlerTimeout` option must be a positive integer. | Use a positive integer for the `handlerTimeout` option. | - |
446
446
  | <a id="fst_err_route_rewrite_not_str">FST_ERR_ROUTE_REWRITE_NOT_STR</a> | `rewriteUrl` needs to be of type `string`. | Use a string for the `rewriteUrl`. | [#4554](https://github.com/fastify/fastify/pull/4554) |
447
447
  | <a id="fst_err_route_missing_content_type">FST_ERR_ROUTE_MISSING_CONTENT_TYPE</a> | `Content-Type` header is required for the request. | Send request with `Content-Type` header. | [#6832](https://github.com/fastify/fastify/pull/6832) |
448
- | <a id="fst_err_route_missing_content">FST_ERR_ROUTE_MISSING_CONTENT</a> | Body is required for the request. | Send request with payload. | [#6832](https://github.com/fastify/fastify/pull/6832) |
448
+ | <a id="fst_err_route_missing_content">FST_ERR_ROUTE_MISSING_CONTENT</a> | Body is required for the request. No longer thrown, will be removed in v6. | - | [#6832](https://github.com/fastify/fastify/pull/6832) |
449
449
  | <a id="fst_err_reopened_close_server">FST_ERR_REOPENED_CLOSE_SERVER</a> | Fastify has already been closed and cannot be reopened. | - | [#2415](https://github.com/fastify/fastify/pull/2415) |
450
450
  | <a id="fst_err_reopened_server">FST_ERR_REOPENED_SERVER</a> | Fastify is already listening. | - | [#2415](https://github.com/fastify/fastify/pull/2415) |
451
451
  | <a id="fst_err_plugin_version_mismatch">FST_ERR_PLUGIN_VERSION_MISMATCH</a> | Installed Fastify plugin mismatched expected version. | Use a compatible version of the plugin. | [#2549](https://github.com/fastify/fastify/pull/2549) |
@@ -277,8 +277,9 @@ requires heavy resources to be sent after the `data`, for example,
277
277
  as soon as possible.
278
278
 
279
279
  > ℹ️ Note:
280
- > The header `Transfer-Encoding: chunked` will be added once you use
281
- > the trailer. It is a hard requirement for using trailer in Node.js.
280
+ > For HTTP/1 responses, the header `Transfer-Encoding: chunked` will be added
281
+ > once you use a trailer. HTTP/2 does not use chunked transfer encoding and
282
+ > sends trailers using its native trailing headers support.
282
283
 
283
284
  > ℹ️ Note:
284
285
  > Any error passed to `done` callback will be ignored. If you are interested
@@ -32,7 +32,7 @@ fastify.route(options)
32
32
  <a id="options"></a>
33
33
 
34
34
  * `method`: currently it supports `GET`, `HEAD`, `TRACE`, `DELETE`,
35
- `OPTIONS`, `PATCH`, `PUT` and `POST`. To accept more methods,
35
+ `OPTIONS`, `PATCH`, `PUT`, `POST` and `QUERY`. To accept more methods,
36
36
  the [`addHttpMethod`](./Server.md#addHttpMethod) must be used.
37
37
  It could also be an array of methods.
38
38
  * `url`: the path of the URL to match this route (alias: `path`).
@@ -41,7 +41,7 @@ fastify.route(options)
41
41
  [here](./Validation-and-Serialization.md) for more info.
42
42
 
43
43
  * `body`: validates the body of the request if it is a POST, PUT, PATCH,
44
- TRACE, SEARCH, PROPFIND, PROPPATCH or LOCK method.
44
+ TRACE, SEARCH, PROPFIND, PROPPATCH, LOCK or QUERY method.
45
45
  * `querystring` or `query`: validates the querystring. This can be a complete
46
46
  JSON Schema object, with the property `type` of `object` and `properties`
47
47
  object of parameters, or simply the values of what would be contained in the
@@ -198,6 +198,10 @@ The above route declaration is more *Hapi*-like, but if you prefer an
198
198
 
199
199
  `fastify.patch(path, [options], handler)`
200
200
 
201
+ `fastify.query(path, [options], handler)`
202
+
203
+ > The `QUERY` method (RFC 10008) requires a `Content-Type` header.
204
+
201
205
  Example:
202
206
  ```js
203
207
  const opts = {
@@ -167,6 +167,22 @@ parameters at the top level.
167
167
  > ℹ For using the latest Ajv (v8), refer to the
168
168
  > [`schemaController`](./Server.md#schema-controller) section.
169
169
 
170
+ ##### Header name case
171
+
172
+ Node.js exposes request header names in lowercase. Fastify normalizes header
173
+ names in a `headers` schema to lowercase before compilation, so assertions
174
+ written in the canonical header-name case still match the request, for example
175
+ `required: ['X-Api-Key']`, `dependencies`, and a `propertyNames` deny-list that
176
+ uses `enum` or `const`.
177
+
178
+ Regular-expression constraints on header names are the exception. A
179
+ `patternProperties` key and a `propertyNames` `pattern` are regular expressions
180
+ matched against the already-lowercased header names, and Fastify cannot safely
181
+ rewrite a regular expression. Author them to match lowercase (for example
182
+ `^x-debug-.*$`, not `^X-Debug-.*$`) or make them case-insensitive. A pattern
183
+ written in canonical case will not match, and the assertion it guards will not
184
+ apply.
185
+
170
186
  Example:
171
187
  ```js
172
188
  const bodyJsonSchema = {
@@ -11,6 +11,7 @@
11
11
  - [Fastify Security Codes](#fastify-security-codes)
12
12
  - [FSTSEC001](#FSTSEC001)
13
13
  - [FSTSEC002](#FSTSEC002)
14
+ - [FSTSEC003](#FSTSEC003)
14
15
  - [Fastify Deprecation Codes](#fastify-deprecation-codes)
15
16
  - [FSTDEP022](#FSTDEP022)
16
17
  - [FSTDEP023](#FSTDEP023)
@@ -57,6 +58,7 @@ Disabling warnings is not recommended and may cause unexpected behavior.
57
58
  | ---- | ----------- | ------------ | ---------- |
58
59
  | <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) |
59
60
  | <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`. | - |
61
+ | <a id="FSTSEC003">FSTSEC003</a> | A headers schema uses a `$ref` inside `propertyNames` that is not reached by header case normalization. Header names held in the referenced schema keep their original case and do 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`. | - |
60
62
 
61
63
  ### Fastify Deprecation Codes
62
64
 
package/fastify.js CHANGED
@@ -1,6 +1,6 @@
1
1
  'use strict'
2
2
 
3
- const VERSION = '5.12.4'
3
+ const VERSION = '5.12.5'
4
4
 
5
5
  const Avvio = require('avvio')
6
6
  const http = require('node:http')
@@ -140,6 +140,7 @@ ContentTypeParser.prototype.getParser = function (contentType) {
140
140
  // with that type.
141
141
  parser = this.customParsers.get(contentType.mediaType)
142
142
  if (parser !== undefined) {
143
+ this.cache.set(ct, parser)
143
144
  return parser
144
145
  }
145
146
 
@@ -203,7 +204,12 @@ ContentTypeParser.prototype.run = function (contentType, handler, request, reply
203
204
  }
204
205
 
205
206
  const resource = new AsyncResource('content-type-parser:run', request)
206
- const done = resource.bind(onDone)
207
+
208
+ // Preserve the request's async context without the per-request function
209
+ // properties and wrappers created by AsyncResource.bind().
210
+ function done (error, body) {
211
+ resource.runInAsyncScope(onDone, undefined, error, body)
212
+ }
207
213
 
208
214
  if (parser.asString === true || parser.asBuffer === true) {
209
215
  rawBody(
@@ -247,12 +253,12 @@ function rawBody (request, reply, options, parser, done) {
247
253
  }
248
254
 
249
255
  let receivedLength = 0
250
- let body = asString ? '' : []
256
+ const chunks = []
251
257
  const payload = request[kRequestPayloadStream] || request.raw
252
258
 
253
- if (asString) {
254
- payload.setEncoding('utf8')
255
- }
259
+ // Always read raw Buffers and decode once at the end: setEncoding() would
260
+ // allocate a StringDecoder per request and force a Buffer.byteLength()
261
+ // rescan of every decoded chunk just to enforce the byte limit.
256
262
 
257
263
  payload.on('data', onData)
258
264
  payload.on('end', onEnd)
@@ -260,7 +266,11 @@ function rawBody (request, reply, options, parser, done) {
260
266
  payload.resume()
261
267
 
262
268
  function onData (chunk) {
263
- receivedLength += asString ? Buffer.byteLength(chunk) : chunk.length
269
+ // A custom preParsing stream may still emit strings
270
+ if (typeof chunk === 'string') {
271
+ chunk = Buffer.from(chunk)
272
+ }
273
+ receivedLength += chunk.length
264
274
  const { receivedEncodedLength = 0 } = payload
265
275
  // The resulting body length must not exceed bodyLimit (see "zip bomb").
266
276
  // The case when encoded length is larger than received length is rather theoretical,
@@ -273,11 +283,7 @@ function rawBody (request, reply, options, parser, done) {
273
283
  return
274
284
  }
275
285
 
276
- if (asString) {
277
- body += chunk
278
- } else {
279
- body.push(chunk)
280
- }
286
+ chunks.push(chunk)
281
287
  }
282
288
 
283
289
  function onEnd (err) {
@@ -298,8 +304,17 @@ function rawBody (request, reply, options, parser, done) {
298
304
  return
299
305
  }
300
306
 
301
- if (!asString) {
302
- body = Buffer.concat(body)
307
+ let body
308
+ if (asString) {
309
+ // Decoding the whole body at once also keeps multi-byte characters
310
+ // split across chunks intact
311
+ body = chunks.length === 1
312
+ ? chunks[0].toString('utf8')
313
+ : Buffer.concat(chunks, receivedLength).toString('utf8')
314
+ } else {
315
+ // Always copy so the parser never receives a view over the shared
316
+ // socket read buffer
317
+ body = Buffer.concat(chunks, receivedLength)
303
318
  }
304
319
 
305
320
  const result = parser.fn(request, body, done)
@@ -1,6 +1,6 @@
1
1
  'use strict'
2
2
 
3
- const { LruMap: Lru } = require('toad-cache')
3
+ const { FifoMap: Fifo } = require('toad-cache')
4
4
 
5
5
  /**
6
6
  * keyValuePairsReg matches a single `parameter-name "=" parameter-value`
@@ -61,9 +61,9 @@ const subtypeNameReg = /^[\w!#$%&'*+.^`|~-]+\s*$/
61
61
 
62
62
  /**
63
63
  * Content-Type internal shared cache
64
- * @type {Lru<ContentType>}
64
+ * @type {Fifo<ContentType>}
65
65
  */
66
- const cache = new Lru(100)
66
+ const cache = new Fifo(100)
67
67
 
68
68
  /**
69
69
  * ContentType parses and represents the value of the content-type header.
@@ -82,7 +82,7 @@ class ContentType {
82
82
  /**
83
83
  * The shared cache of ContentType instances. The cache is used to avoid
84
84
  * creating multiple instances of ContentType for the same header value.
85
- * @type {Lru<ContentType>}
85
+ * @type {Fifo<ContentType>}
86
86
  */
87
87
  static get cache () { return cache }
88
88
 
package/lib/errors.js CHANGED
@@ -460,6 +460,7 @@ const codes = {
460
460
  "Method '%s' must provide a 'Content-Type' header.",
461
461
  400
462
462
  ),
463
+ // No longer thrown, kept exported until v6 to avoid a breaking change
463
464
  FST_ERR_ROUTE_MISSING_CONTENT: createError(
464
465
  'FST_ERR_ROUTE_MISSING_CONTENT',
465
466
  "Method '%s' must provide a request body.",
@@ -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
  }
@@ -606,8 +610,11 @@ function onSendEnd (reply, payload) {
606
610
  header += trailerName
607
611
  }
608
612
  if (header !== '') {
609
- // it must be chunked for trailer to work
610
- reply.header('Transfer-Encoding', 'chunked')
613
+ // HTTP/1 must use chunked encoding for trailers. HTTP/2 forbids
614
+ // the transfer-encoding header and handles trailers natively.
615
+ if (!isHttp2Reply(reply)) {
616
+ reply.header('Transfer-Encoding', 'chunked')
617
+ }
611
618
  reply.header('Trailer', header.trim())
612
619
  } else {
613
620
  reply[kReplyTrailers] = null
package/lib/route.js CHANGED
@@ -467,8 +467,12 @@ function buildRouting (options) {
467
467
  // the child logger creation altogether
468
468
  childLogger = logger
469
469
  } else {
470
- const loggerOpts = {
471
- level: context.logLevel
470
+ const loggerOpts = {}
471
+
472
+ // An empty level means "inherit". Passing it would make pino treat it
473
+ // as a level change and rebuild every log method of each request child.
474
+ if (context.logLevel) {
475
+ loggerOpts.level = context.logLevel
472
476
  }
473
477
 
474
478
  if (context.logSerializers) {
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')) {
@@ -284,19 +341,21 @@ function compileSchemasForValidation (context, compile, isCustom) {
284
341
  }
285
342
 
286
343
  function validateParam (validatorFunction, request, paramName) {
287
- const isUndefined = request[paramName] === undefined
344
+ // The headers getter may allocate a new object, so only read it when needed.
345
+ if (validatorFunction == null) return false
346
+ const value = request[paramName]
288
347
  let ret
289
348
 
290
349
  try {
291
- const data = isUndefined ? null : request[paramName]
350
+ const data = value === undefined ? null : value
292
351
  // Ajv can only mutate a root value when its parent is provided.
293
- if (validatorFunction?.schemaEnv) {
352
+ if (validatorFunction.schemaEnv) {
294
353
  ret = validatorFunction(data, {
295
354
  parentData: request,
296
355
  parentDataProperty: paramName
297
356
  })
298
357
  } else {
299
- ret = validatorFunction?.(data)
358
+ ret = validatorFunction(data)
300
359
  }
301
360
  } catch (err) {
302
361
  // If validator throws synchronously, ensure it propagates as an internal error
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
  * - FSTDEP022
11
12
  * - FSTDEP023
12
13
  * - FSTDEP024
@@ -52,6 +53,13 @@ const FSTSEC002 = createWarning({
52
53
  unlimited: true
53
54
  })
54
55
 
56
+ const FSTSEC003 = createWarning({
57
+ name: 'FastifySecurity',
58
+ code: 'FSTSEC003',
59
+ 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.',
60
+ unlimited: true
61
+ })
62
+
55
63
  const FSTDEP022 = createWarning({
56
64
  name: 'FastifyWarning',
57
65
  code: 'FSTDEP022',
@@ -86,6 +94,7 @@ module.exports = {
86
94
  FSTWRN004,
87
95
  FSTSEC001,
88
96
  FSTSEC002,
97
+ FSTSEC003,
89
98
  FSTDEP022,
90
99
  FSTDEP023,
91
100
  FSTDEP024,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fastify",
3
- "version": "5.12.4",
3
+ "version": "5.12.6",
4
4
  "description": "Fast and low overhead web framework, for Node.js",
5
5
  "main": "fastify.js",
6
6
  "type": "commonjs",
@@ -126,3 +126,28 @@ test('should throw error if invalid logger is returned', (t, done) => {
126
126
  }, { code: 'FST_ERR_LOG_INVALID_LOGGER' })
127
127
  })
128
128
  })
129
+
130
+ test('request child loggers only receive a level when the route sets one', async t => {
131
+ t.plan(3)
132
+
133
+ const fastify = Fastify({ logger: { level: 'info', stream: { write () {} } } })
134
+ const levels = []
135
+ fastify.setChildLoggerFactory(function (logger, bindings, opts) {
136
+ // an empty level would make pino reset the level of every request child
137
+ levels.push(Object.hasOwn(opts, 'level') ? opts.level : 'inherited')
138
+ return logger.child(bindings, opts)
139
+ })
140
+
141
+ fastify.get('/', (req, reply) => {
142
+ reply.send(req.log.level)
143
+ })
144
+ fastify.get('/warn', { logLevel: 'warn' }, (req, reply) => {
145
+ reply.send(req.log.level)
146
+ })
147
+
148
+ t.after(() => fastify.close())
149
+
150
+ t.assert.strictEqual((await fastify.inject('/')).body, 'info')
151
+ t.assert.strictEqual((await fastify.inject('/warn')).body, 'warn')
152
+ t.assert.deepStrictEqual(levels, ['inherited', 'warn'])
153
+ })
@@ -2,6 +2,7 @@
2
2
 
3
3
  const { test } = require('node:test')
4
4
  const { spyWarning } = require('process-warning')
5
+ const { Readable } = require('node:stream')
5
6
  const Fastify = require('..')
6
7
  const keys = require('../lib/symbols')
7
8
  const { FST_ERR_CTP_ALREADY_PRESENT, FST_ERR_CTP_INVALID_TYPE, FST_ERR_CTP_INVALID_MEDIA_TYPE } = require('../lib/errors')
@@ -767,3 +768,62 @@ test('content-type fail when not a valid type', async t => {
767
768
  t.assert.equal(error.message, 'The content type should be a string or a RegExp')
768
769
  }
769
770
  })
771
+
772
+ test('string body keeps multi-byte characters split across chunks', async t => {
773
+ t.plan(2)
774
+
775
+ const fastify = Fastify()
776
+ const encoded = Buffer.from(JSON.stringify({ hello: 'wörld ✓ 😀' }))
777
+
778
+ fastify.addHook('preParsing', async () => {
779
+ // Emit one byte per chunk so every multi-byte character is split
780
+ return Readable.from(Array.from(encoded, byte => Buffer.from([byte])), { objectMode: false })
781
+ })
782
+ fastify.post('/', async (request) => request.body)
783
+
784
+ const res = await fastify.inject({
785
+ method: 'POST',
786
+ url: '/',
787
+ headers: { 'content-type': 'application/json' },
788
+ payload: encoded
789
+ })
790
+ t.assert.strictEqual(res.statusCode, 200)
791
+ t.assert.deepStrictEqual(res.json(), { hello: 'wörld ✓ 😀' })
792
+ })
793
+
794
+ test('string body accepts string chunks from a preParsing stream', async t => {
795
+ t.plan(2)
796
+
797
+ const fastify = Fastify()
798
+
799
+ fastify.addHook('preParsing', async (request, reply, payload) => {
800
+ payload.setEncoding('utf8')
801
+ return payload
802
+ })
803
+ fastify.post('/', async (request) => request.body)
804
+
805
+ const res = await fastify.inject({
806
+ method: 'POST',
807
+ url: '/',
808
+ headers: { 'content-type': 'text/plain' },
809
+ payload: 'hellö'
810
+ })
811
+ t.assert.strictEqual(res.statusCode, 200)
812
+ t.assert.strictEqual(res.body, 'hellö')
813
+ })
814
+
815
+ test('string body with invalid UTF-8 is not treated as a content-length mismatch', async t => {
816
+ t.plan(2)
817
+
818
+ const fastify = Fastify()
819
+ fastify.post('/', async (request) => request.body)
820
+
821
+ const res = await fastify.inject({
822
+ method: 'POST',
823
+ url: '/',
824
+ headers: { 'content-type': 'text/plain' },
825
+ payload: Buffer.from([0x61, 0xff, 0x62])
826
+ })
827
+ t.assert.strictEqual(res.statusCode, 200)
828
+ t.assert.strictEqual(res.body, 'a�b')
829
+ })