fastify 5.12.5 → 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/Routes.md +6 -2
- package/docs/Reference/Validation-and-Serialization.md +16 -0
- package/docs/Reference/Warnings.md +2 -0
- package/lib/content-type-parser.js +21 -12
- package/lib/content-type.js +4 -4
- package/lib/errors.js +1 -0
- package/lib/handle-request.js +7 -20
- package/lib/reply.js +5 -1
- package/lib/route.js +6 -2
- package/lib/validation.js +67 -10
- 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/internals/reply.test.js +30 -1
- 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/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
|
|
|
@@ -253,12 +253,12 @@ function rawBody (request, reply, options, parser, done) {
|
|
|
253
253
|
}
|
|
254
254
|
|
|
255
255
|
let receivedLength = 0
|
|
256
|
-
|
|
256
|
+
const chunks = []
|
|
257
257
|
const payload = request[kRequestPayloadStream] || request.raw
|
|
258
258
|
|
|
259
|
-
|
|
260
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
308
|
-
|
|
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)
|
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
|
}
|
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')) {
|
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
|
+
})
|
|
@@ -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
|
+
})
|
package/test/rfc-10008.test.js
CHANGED
|
@@ -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
|
|
103
|
-
// If a media type is
|
|
104
|
-
//
|
|
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': '
|
|
116
|
+
headers: { 'Content-Type': 'text/plain' }
|
|
120
117
|
})
|
|
121
118
|
|
|
122
|
-
t.assert.equal(res.statusCode,
|
|
123
|
-
t.assert.equal(res.
|
|
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
|
+
})
|
package/test/types/route.tst.ts
CHANGED
|
@@ -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: '/',
|
package/types/instance.d.ts
CHANGED
|
@@ -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>, '
|
|
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>, '
|
|
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
|
|