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.
- package/docs/Reference/Errors.md +1 -1
- package/docs/Reference/Reply.md +3 -2
- package/docs/Reference/Routes.md +6 -2
- package/docs/Reference/Validation-and-Serialization.md +16 -0
- package/docs/Reference/Warnings.md +2 -0
- package/fastify.js +1 -1
- package/lib/content-type-parser.js +28 -13
- package/lib/content-type.js +4 -4
- package/lib/errors.js +1 -0
- package/lib/handle-request.js +7 -20
- package/lib/reply.js +10 -3
- package/lib/route.js +6 -2
- package/lib/validation.js +73 -14
- package/lib/warnings.js +9 -0
- package/package.json +1 -1
- package/test/child-logger-factory.test.js +25 -0
- package/test/content-parser.test.js +60 -0
- package/test/content-type-parser-async-context.test.js +76 -0
- package/test/custom-parser.4.test.js +40 -0
- package/test/http2/plain.test.js +45 -0
- package/test/internals/reply.test.js +30 -1
- package/test/internals/validation.test.js +66 -0
- package/test/reply-media-type.test.js +57 -0
- package/test/rfc-10008.test.js +6 -9
- package/test/schema-validation.test.js +251 -1
- package/test/types/route.tst.ts +12 -0
- package/types/instance.d.ts +2 -2
package/docs/Reference/Errors.md
CHANGED
|
@@ -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.
|
|
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) |
|
package/docs/Reference/Reply.md
CHANGED
|
@@ -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
|
-
>
|
|
281
|
-
>
|
|
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
|
package/docs/Reference/Routes.md
CHANGED
|
@@ -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 `
|
|
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
|
|
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
|
@@ -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
|
-
|
|
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
|
-
|
|
256
|
+
const chunks = []
|
|
251
257
|
const payload = request[kRequestPayloadStream] || request.raw
|
|
252
258
|
|
|
253
|
-
|
|
254
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
302
|
-
|
|
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)
|
package/lib/content-type.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
'use strict'
|
|
2
2
|
|
|
3
|
-
const {
|
|
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 {
|
|
64
|
+
* @type {Fifo<ContentType>}
|
|
65
65
|
*/
|
|
66
|
-
const cache = new
|
|
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 {
|
|
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.",
|
package/lib/handle-request.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
//
|
|
610
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
350
|
+
const data = value === undefined ? null : value
|
|
292
351
|
// Ajv can only mutate a root value when its parent is provided.
|
|
293
|
-
if (validatorFunction
|
|
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
|
|
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
|
@@ -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
|
+
})
|