fastify 5.12.5 → 5.12.7

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) |
@@ -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.5'
3
+ const VERSION = '5.12.7'
4
4
 
5
5
  const Avvio = require('avvio')
6
6
  const http = require('node:http')
@@ -253,12 +253,12 @@ function rawBody (request, reply, options, parser, done) {
253
253
  }
254
254
 
255
255
  let receivedLength = 0
256
- let body = asString ? '' : []
256
+ const chunks = []
257
257
  const payload = request[kRequestPayloadStream] || request.raw
258
258
 
259
- if (asString) {
260
- payload.setEncoding('utf8')
261
- }
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.
262
262
 
263
263
  payload.on('data', onData)
264
264
  payload.on('end', onEnd)
@@ -266,7 +266,11 @@ function rawBody (request, reply, options, parser, done) {
266
266
  payload.resume()
267
267
 
268
268
  function onData (chunk) {
269
- 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
270
274
  const { receivedEncodedLength = 0 } = payload
271
275
  // The resulting body length must not exceed bodyLimit (see "zip bomb").
272
276
  // The case when encoded length is larger than received length is rather theoretical,
@@ -279,11 +283,7 @@ function rawBody (request, reply, options, parser, done) {
279
283
  return
280
284
  }
281
285
 
282
- if (asString) {
283
- body += chunk
284
- } else {
285
- body.push(chunk)
286
- }
286
+ chunks.push(chunk)
287
287
  }
288
288
 
289
289
  function onEnd (err) {
@@ -304,8 +304,17 @@ function rawBody (request, reply, options, parser, done) {
304
304
  return
305
305
  }
306
306
 
307
- if (!asString) {
308
- 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)
309
318
  }
310
319
 
311
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
  }
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')) {
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.5",
3
+ "version": "5.12.7",
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
+ })
@@ -35,7 +35,7 @@ const doGet = async function (url) {
35
35
 
36
36
  test('Once called, Reply should return an object with methods', t => {
37
37
  t.plan(16)
38
- const response = { res: 'res' }
38
+ const response = { res: 'res', getHeader: () => undefined }
39
39
  const context = {
40
40
  config: { onSend: [] },
41
41
  schema: {},
@@ -1003,6 +1003,35 @@ test('reply can set multiple instances of same header', async t => {
1003
1003
  t.assert.deepStrictEqual(result.headers.getSetCookie(), ['one', 'two'])
1004
1004
  })
1005
1005
 
1006
+ test('reply.header does not mutate a set-cookie array passed by the caller', async t => {
1007
+ t.plan(5)
1008
+
1009
+ const fastify = require('../../')()
1010
+ const defaults = ['a=1']
1011
+
1012
+ fastify.get('/headers', function (req, reply) {
1013
+ reply
1014
+ .header('set-cookie', defaults)
1015
+ .header('set-cookie', 'b=2')
1016
+ .send({})
1017
+ })
1018
+
1019
+ const fastifyServer = await fastify.listen({ port: 0 })
1020
+ t.after(() => fastify.close())
1021
+
1022
+ const first = await fetch(`${fastifyServer}/headers`)
1023
+ t.assert.deepStrictEqual(first.headers.getSetCookie(), ['a=1', 'b=2'])
1024
+ const second = await fetch(`${fastifyServer}/headers`)
1025
+ t.assert.deepStrictEqual(second.headers.getSetCookie(), ['a=1', 'b=2'])
1026
+ t.assert.deepStrictEqual(defaults, ['a=1'])
1027
+
1028
+ const reply = new Reply({ setHeader () {}, removeHeader () {} }, {}, {})
1029
+ const cookies = ['one']
1030
+ reply.header('set-cookie', cookies).header('set-cookie', 'two')
1031
+ t.assert.deepStrictEqual(reply.getHeader('set-cookie'), ['one', 'two'])
1032
+ t.assert.deepStrictEqual(cookies, ['one'])
1033
+ })
1034
+
1006
1035
  test('reply.hasHeader returns correct values', async t => {
1007
1036
  t.plan(2)
1008
1037
 
@@ -129,3 +129,60 @@ test('reply.mediaType should reflect the last type set', async (t) => {
129
129
  const body = response.body
130
130
  t.assert.strictEqual(body, 'mediaType = text/plain')
131
131
  })
132
+
133
+ test('reply.mediaType should match the content-type set via raw.setHeader', async (t) => {
134
+ t.plan(2)
135
+
136
+ const fastify = Fastify()
137
+
138
+ fastify.get('/', (request, reply) => {
139
+ reply.raw.setHeader('content-type', 'application/json; charset=utf-8')
140
+ t.assert.strictEqual(reply.mediaType, 'application/json')
141
+ reply.send({ mediaType: reply.mediaType })
142
+ })
143
+
144
+ const response = await fastify.inject({
145
+ method: 'GET',
146
+ url: '/'
147
+ })
148
+ const body = await response.json()
149
+ t.assert.strictEqual(body.mediaType, 'application/json')
150
+ })
151
+
152
+ test('reply.mediaType should match case-insensitive content-type set via raw.setHeader', async (t) => {
153
+ t.plan(2)
154
+
155
+ const fastify = Fastify()
156
+
157
+ fastify.get('/', (request, reply) => {
158
+ reply.raw.setHeader('Content-Type', 'text/html; charset=utf-8')
159
+ t.assert.strictEqual(reply.mediaType, 'text/html')
160
+ reply.send(reply.mediaType)
161
+ })
162
+
163
+ const response = await fastify.inject({
164
+ method: 'GET',
165
+ url: '/'
166
+ })
167
+ t.assert.strictEqual(response.body, 'text/html')
168
+ })
169
+
170
+ test('reply.mediaType should prioritize reply.type over raw.setHeader', async (t) => {
171
+ t.plan(2)
172
+
173
+ const fastify = Fastify()
174
+
175
+ fastify.get('/', (request, reply) => {
176
+ reply.raw.setHeader('content-type', 'application/xml')
177
+ reply.type('application/json')
178
+ t.assert.strictEqual(reply.mediaType, 'application/json')
179
+ reply.send({ mediaType: reply.mediaType })
180
+ })
181
+
182
+ const response = await fastify.inject({
183
+ method: 'GET',
184
+ url: '/'
185
+ })
186
+ const body = await response.json()
187
+ t.assert.strictEqual(body.mediaType, 'application/json')
188
+ })
@@ -99,12 +99,9 @@ describe('RFC 10008', () => {
99
99
  t.assert.equal(res.json().code, 'FST_ERR_CTP_INVALID_JSON_BODY')
100
100
  })
101
101
 
102
- test('should return 400 if no content is provided for QUERY method', async (t) => {
103
- // If a media type is specified but is inconsistent
104
- // with the actual request content, a 400 (Bad Request)
105
- // can be returned. That is, a server is not allowed to
106
- // infer a media type from the request content and then
107
- // override a missing or "erroneous" value (i.e., "content sniffing").
102
+ test('should return 200 if body is empty but media type allows it for QUERY method', async (t) => {
103
+ // If a media type is set and allows the body to be empty,
104
+ // the server must allow it.
108
105
  t.plan(2)
109
106
 
110
107
  const fastify = Fastify()
@@ -116,11 +113,11 @@ describe('RFC 10008', () => {
116
113
  const res = await fastify.inject({
117
114
  method: 'QUERY',
118
115
  url: '/query',
119
- headers: { 'Content-Type': 'application/json' }
116
+ headers: { 'Content-Type': 'text/plain' }
120
117
  })
121
118
 
122
- t.assert.equal(res.statusCode, 400)
123
- t.assert.equal(res.json().code, 'FST_ERR_ROUTE_MISSING_CONTENT')
119
+ t.assert.equal(res.statusCode, 200)
120
+ t.assert.equal(res.body, '')
124
121
  })
125
122
 
126
123
  test('should return 415 if unsupported media type is provided for QUERY method', async (t) => {
@@ -8,7 +8,7 @@ const Schema = require('fluent-json-schema')
8
8
  const { waitForCb } = require('./helper')
9
9
  const { kRequestContentType } = require('../lib/symbols')
10
10
  const { spyWarning } = require('process-warning')
11
- const { FSTSEC002 } = require('../lib/warnings')
11
+ const { FSTSEC002, FSTSEC003 } = require('../lib/warnings')
12
12
 
13
13
  const customSchemaCompilers = {
14
14
  body: new AJV({
@@ -1921,6 +1921,52 @@ test('inline and local $ref header schemas do not emit FSTSEC002', async t => {
1921
1921
  t.assert.strictEqual(spyData.callCount(), 0)
1922
1922
  })
1923
1923
 
1924
+ test('header schema with a $ref inside propertyNames emits FSTSEC003 (case-normalization does not reach it)', async t => {
1925
+ const spyData = spyWarning(FSTSEC003)
1926
+ t.after(spyData.restore)
1927
+
1928
+ const fastify = Fastify()
1929
+ fastify.post('/', {
1930
+ schema: {
1931
+ headers: {
1932
+ type: 'object',
1933
+ propertyNames: { $ref: '#/definitions/denied' },
1934
+ definitions: {
1935
+ denied: { not: { enum: ['X-Debug-Override'] } }
1936
+ }
1937
+ }
1938
+ }
1939
+ }, async () => ({ ok: true }))
1940
+
1941
+ await fastify.ready()
1942
+
1943
+ t.assert.strictEqual(spyData.callCount(), 1)
1944
+ t.assert.deepStrictEqual(spyData.calls[0].arguments, ['POST', '/', '#/definitions/denied'])
1945
+ })
1946
+
1947
+ test('header schema detects a $ref inside propertyNames nested in an array', async t => {
1948
+ const spyData = spyWarning(FSTSEC003)
1949
+ t.after(spyData.restore)
1950
+
1951
+ const fastify = Fastify()
1952
+ fastify.post('/', {
1953
+ schema: {
1954
+ headers: {
1955
+ type: 'object',
1956
+ propertyNames: { not: { anyOf: [{ $ref: '#/definitions/denied' }] } },
1957
+ definitions: {
1958
+ denied: { enum: ['X-Debug-Override'] }
1959
+ }
1960
+ }
1961
+ }
1962
+ }, async () => ({ ok: true }))
1963
+
1964
+ await fastify.ready()
1965
+
1966
+ t.assert.strictEqual(spyData.callCount(), 1)
1967
+ t.assert.deepStrictEqual(spyData.calls[0].arguments, ['POST', '/', '#/definitions/denied'])
1968
+ })
1969
+
1924
1970
  test('header schema normalization traverses dependent schema keywords', async t => {
1925
1971
  const fastify = Fastify({
1926
1972
  ajv: { customOptions: { strictSchema: false } }
@@ -1993,3 +2039,207 @@ test('header schema detects an external $ref nested in an array', async t => {
1993
2039
  'http://example.com/nested-admin-headers#'
1994
2040
  ])
1995
2041
  })
2042
+
2043
+ test('header schema propertyNames: not-enum deny-list is lowercased', async t => {
2044
+ const fastify = Fastify()
2045
+
2046
+ // A `propertyNames` subschema validates each header name itself, so strings in
2047
+ // its `enum` are header names. They must be lowercased the same way as
2048
+ // `required` entries, otherwise a deny-list written in the usual header case
2049
+ // never matches the lowercased request object.
2050
+ fastify.get('/', {
2051
+ schema: {
2052
+ headers: {
2053
+ type: 'object',
2054
+ propertyNames: { not: { enum: ['X-Debug-Override'] } }
2055
+ }
2056
+ }
2057
+ }, async () => ({ ok: true }))
2058
+
2059
+ await fastify.ready()
2060
+
2061
+ const blocked = await fastify.inject({
2062
+ method: 'GET',
2063
+ url: '/',
2064
+ headers: { 'X-Debug-Override': '1' }
2065
+ })
2066
+ t.assert.strictEqual(blocked.statusCode, 400)
2067
+ t.assert.strictEqual(blocked.json().code, 'FST_ERR_VALIDATION')
2068
+
2069
+ const ok = await fastify.inject({
2070
+ method: 'GET',
2071
+ url: '/',
2072
+ headers: { 'X-Other': '1' }
2073
+ })
2074
+ t.assert.strictEqual(ok.statusCode, 200)
2075
+ })
2076
+
2077
+ test('header schema propertyNames: non-string enum items are left untouched', async t => {
2078
+ const fastify = Fastify({
2079
+ ajv: { customOptions: { strictSchema: false } }
2080
+ })
2081
+
2082
+ fastify.get('/', {
2083
+ schema: {
2084
+ headers: {
2085
+ type: 'object',
2086
+ propertyNames: { not: { enum: ['X-Debug-Override', 42, null] } }
2087
+ }
2088
+ }
2089
+ }, async () => ({ ok: true }))
2090
+
2091
+ await fastify.ready()
2092
+
2093
+ const blocked = await fastify.inject({
2094
+ method: 'GET',
2095
+ url: '/',
2096
+ headers: { 'X-Debug-Override': '1' }
2097
+ })
2098
+ t.assert.strictEqual(blocked.statusCode, 400)
2099
+
2100
+ const ok = await fastify.inject({
2101
+ method: 'GET',
2102
+ url: '/',
2103
+ headers: { 'X-Other': '1' }
2104
+ })
2105
+ t.assert.strictEqual(ok.statusCode, 200)
2106
+ })
2107
+
2108
+ test('header schema propertyNames: not-const deny-list is lowercased', async t => {
2109
+ const fastify = Fastify()
2110
+
2111
+ fastify.get('/', {
2112
+ schema: {
2113
+ headers: {
2114
+ type: 'object',
2115
+ propertyNames: { not: { const: 'X-Debug-Override' } }
2116
+ }
2117
+ }
2118
+ }, async () => ({ ok: true }))
2119
+
2120
+ await fastify.ready()
2121
+
2122
+ const blocked = await fastify.inject({
2123
+ method: 'GET',
2124
+ url: '/',
2125
+ headers: { 'X-Debug-Override': '1' }
2126
+ })
2127
+ t.assert.strictEqual(blocked.statusCode, 400)
2128
+ t.assert.strictEqual(blocked.json().code, 'FST_ERR_VALIDATION')
2129
+ })
2130
+
2131
+ test('header schema propertyNames: enum/const are lowercased inside combinators', async t => {
2132
+ const fastify = Fastify()
2133
+
2134
+ // The header-name `enum`/`const` can be nested under combinators inside
2135
+ // `propertyNames`; the lowercasing context must propagate through them.
2136
+ fastify.get('/anyOf', {
2137
+ schema: {
2138
+ headers: {
2139
+ type: 'object',
2140
+ propertyNames: { not: { anyOf: [{ enum: ['X-Debug-Override'] }] } }
2141
+ }
2142
+ }
2143
+ }, async () => ({ ok: true }))
2144
+
2145
+ fastify.get('/oneOf', {
2146
+ schema: {
2147
+ headers: {
2148
+ type: 'object',
2149
+ propertyNames: { not: { oneOf: [{ const: 'X-Debug-Override' }] } }
2150
+ }
2151
+ }
2152
+ }, async () => ({ ok: true }))
2153
+
2154
+ await fastify.ready()
2155
+
2156
+ for (const url of ['/anyOf', '/oneOf']) {
2157
+ const blocked = await fastify.inject({
2158
+ method: 'GET',
2159
+ url,
2160
+ headers: { 'X-Debug-Override': '1' }
2161
+ })
2162
+ t.assert.strictEqual(blocked.statusCode, 400, `${url} should block the header`)
2163
+ t.assert.strictEqual(blocked.json().code, 'FST_ERR_VALIDATION')
2164
+ }
2165
+ })
2166
+
2167
+ test('header schema propertyNames: lowercase-equivalent schema behaves identically', async t => {
2168
+ const fastify = Fastify()
2169
+
2170
+ fastify.get('/', {
2171
+ schema: {
2172
+ headers: {
2173
+ type: 'object',
2174
+ propertyNames: { not: { enum: ['x-debug-override'] } }
2175
+ }
2176
+ }
2177
+ }, async () => ({ ok: true }))
2178
+
2179
+ await fastify.ready()
2180
+
2181
+ const blocked = await fastify.inject({
2182
+ method: 'GET',
2183
+ url: '/',
2184
+ headers: { 'X-Debug-Override': '1' }
2185
+ })
2186
+ t.assert.strictEqual(blocked.statusCode, 400)
2187
+ t.assert.strictEqual(blocked.json().code, 'FST_ERR_VALIDATION')
2188
+ })
2189
+
2190
+ test('header schema propertyNames: enum/const values in header value schemas are not lowercased', async t => {
2191
+ const fastify = Fastify()
2192
+
2193
+ // `enum`/`const` inside a property value subschema hold header *values*, not
2194
+ // names, and must be preserved with their original case.
2195
+ fastify.get('/enum', {
2196
+ schema: {
2197
+ headers: {
2198
+ type: 'object',
2199
+ properties: { 'X-Mode': { type: 'string', enum: ['MIXED', 'lowercase'] } }
2200
+ }
2201
+ }
2202
+ }, async () => ({ ok: true }))
2203
+
2204
+ fastify.get('/const', {
2205
+ schema: {
2206
+ headers: {
2207
+ type: 'object',
2208
+ properties: { 'X-Mode': { type: 'string', const: 'MIXED' } }
2209
+ }
2210
+ }
2211
+ }, async () => ({ ok: true }))
2212
+
2213
+ await fastify.ready()
2214
+
2215
+ const enumMixed = await fastify.inject({
2216
+ method: 'GET',
2217
+ url: '/enum',
2218
+ headers: { 'X-Mode': 'MIXED' }
2219
+ })
2220
+ t.assert.strictEqual(enumMixed.statusCode, 200)
2221
+
2222
+ const constMixed = await fastify.inject({
2223
+ method: 'GET',
2224
+ url: '/const',
2225
+ headers: { 'X-Mode': 'MIXED' }
2226
+ })
2227
+ t.assert.strictEqual(constMixed.statusCode, 200)
2228
+ })
2229
+
2230
+ test('header schema propertyNames lowercasing does not mutate the input schema', async t => {
2231
+ const headers = {
2232
+ type: 'object',
2233
+ propertyNames: { not: { enum: ['X-Debug-Override'], const: 'X-Other' } }
2234
+ }
2235
+ const schema = { headers }
2236
+ const schemaBefore = JSON.stringify(schema)
2237
+ const headersBefore = JSON.stringify(headers)
2238
+
2239
+ const fastify = Fastify()
2240
+ fastify.get('/', { schema }, async () => ({ ok: true }))
2241
+ await fastify.ready()
2242
+
2243
+ t.assert.strictEqual(JSON.stringify(schema), schemaBefore)
2244
+ t.assert.strictEqual(JSON.stringify(headers), headersBefore)
2245
+ })
@@ -495,6 +495,12 @@ expect(fastify().hasRoute({
495
495
  method: 'GET'
496
496
  })).type.toBe<boolean>()
497
497
 
498
+ fastify().hasRoute({
499
+ url: '/',
500
+ // @ts-expect-error Type 'string[]' is not assignable to type 'HTTPMethods'.
501
+ method: ['GET', 'POST']
502
+ })
503
+
498
504
  expect(fastify().hasRoute({
499
505
  url: '/',
500
506
  method: 'GET',
@@ -539,6 +545,12 @@ expect(
539
545
  })
540
546
  ).type.toBe<Omit<FindMyWayFindResult<RawServerDefault>, 'store'>>()
541
547
 
548
+ fastify().findRoute({
549
+ url: '/',
550
+ // @ts-expect-error Type 'string[]' is not assignable to type 'HTTPMethods'.
551
+ method: ['GET', 'POST']
552
+ })
553
+
542
554
  // we should not expose store
543
555
  expect(fastify().findRoute({
544
556
  url: '/',
@@ -215,13 +215,13 @@ export interface FastifyInstance<
215
215
  RouteGeneric extends RouteGenericInterface = RouteGenericInterface,
216
216
  ContextConfig = ContextConfigDefault,
217
217
  SchemaCompiler extends FastifySchema = FastifySchema
218
- >(opts: Pick<RouteOptions<RawServer, RawRequest, RawReply, RouteGeneric, ContextConfig, SchemaCompiler, TypeProvider>, 'method' | 'url' | 'constraints'>): boolean;
218
+ >(opts: Pick<RouteOptions<RawServer, RawRequest, RawReply, RouteGeneric, ContextConfig, SchemaCompiler, TypeProvider>, 'url' | 'constraints'> & { method: HTTPMethods }): boolean;
219
219
 
220
220
  findRoute<
221
221
  RouteGeneric extends RouteGenericInterface = RouteGenericInterface,
222
222
  ContextConfig = ContextConfigDefault,
223
223
  SchemaCompiler extends FastifySchema = FastifySchema
224
- >(opts: Pick<RouteOptions<RawServer, RawRequest, RawReply, RouteGeneric, ContextConfig, SchemaCompiler, TypeProvider>, 'method' | 'url' | 'constraints'>): Omit<FindMyWayFindResult<RawServer>, 'store'>;
224
+ >(opts: Pick<RouteOptions<RawServer, RawRequest, RawReply, RouteGeneric, ContextConfig, SchemaCompiler, TypeProvider>, 'url' | 'constraints'> & { method: HTTPMethods }): Omit<FindMyWayFindResult<RawServer>, 'store'>;
225
225
 
226
226
  // addHook: overloads
227
227