fastify 5.12.1 → 5.12.2
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/Validation-and-Serialization.md +11 -0
- package/docs/Reference/Warnings.md +11 -0
- package/lib/four-oh-four.js +10 -22
- package/lib/route.js +7 -4
- package/lib/schemas.js +5 -2
- package/lib/validation.js +196 -23
- package/lib/warnings.js +10 -1
- package/package.json +1 -1
- package/test/404s.test.js +105 -7
- package/test/router-options.test.js +24 -0
- package/test/schema-feature.test.js +158 -0
- package/test/schema-special-usage.test.js +81 -0
- package/test/schema-validation.test.js +378 -0
- package/.pi/self-learning-memory/README.md +0 -8
- package/.pi/self-learning-memory/core/CORE.md +0 -33
- package/.pi/self-learning-memory/core/index.json +0 -735
- package/.pi/self-learning-memory/daily/2026-07-06.md +0 -22
- package/.pi/self-learning-memory/daily/2026-07-29.md +0 -50
- package/.pi/self-learning-memory/daily/2026-08-04.md +0 -41
- package/.pi/self-learning-memory/daily/2026-08-18.md +0 -40
- package/.pi/self-learning-memory/long-term-memory.md +0 -89
- package/AGENTS.md +0 -290
|
@@ -628,6 +628,17 @@ This pattern ensures validators work correctly with both sync and async
|
|
|
628
628
|
`preValidation` hooks, preventing unhandled promise rejections that can crash
|
|
629
629
|
an application.
|
|
630
630
|
|
|
631
|
+
> [!IMPORTANT]
|
|
632
|
+
> The `{ value, error }` result convention applies to **synchronous** validator
|
|
633
|
+
> compilers only. An **asynchronous** validator (a compiler that returns a
|
|
634
|
+
> promise, including an [`$async` Ajv schema](https://ajv.js.org/guide/async-validation.html))
|
|
635
|
+
> is treated as pass/fail only: its resolved value is not unwrapped as
|
|
636
|
+
> `{ value, error }`. An async schema resolves with the validated data itself, so
|
|
637
|
+
> unwrapping it would let a payload that carries `value` or `error` keys replace
|
|
638
|
+
> the request part or inject a validation error. An async validator must signal
|
|
639
|
+
> failure by rejecting (throwing), and it cannot coerce the request part through
|
|
640
|
+
> a resolved `value`.
|
|
641
|
+
|
|
631
642
|
##### .statusCode property
|
|
632
643
|
|
|
633
644
|
All validation errors have a `.statusCode` property set to `400`, ensuring the
|
|
@@ -8,6 +8,9 @@
|
|
|
8
8
|
- [FSTWRN001](#FSTWRN001)
|
|
9
9
|
- [FSTWRN003](#FSTWRN003)
|
|
10
10
|
- [FSTWRN004](#FSTWRN004)
|
|
11
|
+
- [Fastify Security Codes](#fastify-security-codes)
|
|
12
|
+
- [FSTSEC001](#FSTSEC001)
|
|
13
|
+
- [FSTSEC002](#FSTSEC002)
|
|
11
14
|
- [Fastify Deprecation Codes](#fastify-deprecation-codes)
|
|
12
15
|
- [FSTDEP022](#FSTDEP022)
|
|
13
16
|
- [FSTDEP023](#FSTDEP023)
|
|
@@ -47,6 +50,14 @@ Disabling warnings is not recommended and may cause unexpected behavior.
|
|
|
47
50
|
| <a id="FSTWRN001">FSTWRN001</a> | The specified schema for a route is missing. This may indicate the schema is not well specified. | Check the schema for the route. | [#4647](https://github.com/fastify/fastify/pull/4647) |
|
|
48
51
|
| <a id="FSTWRN003">FSTWRN003</a> | The `%s` plugin mixes async and callback styles, which may lead to unhandled rejections. | Do not mix async and callback style. | [#6011](https://github.com/fastify/fastify/pull/6011) |
|
|
49
52
|
| <a id="FSTWRN004">FSTWRN004</a> | An `errorHandler` is being overridden in the same scope, which can lead to subtle bugs. | Avoid calling `setErrorHandler` more than once in the same scope. For more information, see [Server documentation](https://fastify.dev/docs/latest/Reference/Server/#allowerrorhandleroverride). | [#6104](https://github.com/fastify/fastify/pull/6104) |
|
|
53
|
+
|
|
54
|
+
### Fastify Security Codes
|
|
55
|
+
|
|
56
|
+
| Code | Description | How to solve | Discussion |
|
|
57
|
+
| ---- | ----------- | ------------ | ---------- |
|
|
58
|
+
| <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
|
+
| <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`. | - |
|
|
60
|
+
|
|
50
61
|
### Fastify Deprecation Codes
|
|
51
62
|
|
|
52
63
|
Deprecation codes are supported by the Node.js CLI options:
|
package/lib/four-oh-four.js
CHANGED
|
@@ -34,11 +34,10 @@ function fourOhFour (options) {
|
|
|
34
34
|
|
|
35
35
|
// 404 router, used for handling encapsulated 404 handlers
|
|
36
36
|
const router = FindMyWay({
|
|
37
|
-
onBadUrl:
|
|
38
|
-
onMaxParamLength:
|
|
37
|
+
onBadUrl: options.routerOptions.onBadUrl,
|
|
38
|
+
onMaxParamLength: options.routerOptions.onMaxParamLength,
|
|
39
39
|
defaultRoute: fourOhFourFallBack
|
|
40
40
|
})
|
|
41
|
-
let _routeEventHandler = null
|
|
42
41
|
|
|
43
42
|
return { router, setNotFoundHandler, setContext, arrange404 }
|
|
44
43
|
|
|
@@ -47,8 +46,6 @@ function fourOhFour (options) {
|
|
|
47
46
|
instance[kFourOhFourLevelInstance] = instance
|
|
48
47
|
instance[kCanSetNotFoundHandler] = true
|
|
49
48
|
// we need to bind instance for the context
|
|
50
|
-
router.onBadUrl = router.onBadUrl.bind(instance)
|
|
51
|
-
router.onMaxParamLength = router.onMaxParamLength.bind(instance)
|
|
52
49
|
router.defaultRoute = router.defaultRoute.bind(instance)
|
|
53
50
|
}
|
|
54
51
|
|
|
@@ -62,20 +59,15 @@ function fourOhFour (options) {
|
|
|
62
59
|
})
|
|
63
60
|
}
|
|
64
61
|
|
|
65
|
-
function createRouteEventHandler () {
|
|
66
|
-
return function onRouteEvent (path, req, res) {
|
|
67
|
-
const fourOhFourContext = this[kFourOhFourLevelInstance][kFourOhFourContext]
|
|
68
|
-
const id = getGenReqId(fourOhFourContext.server, req)
|
|
69
|
-
const childLogger = createChildLogger(fourOhFourContext, logger, req, id)
|
|
70
|
-
const request = new Request(id, null, req, null, childLogger, fourOhFourContext)
|
|
71
|
-
const reply = new Reply(res, request, childLogger)
|
|
72
|
-
|
|
73
|
-
_routeEventHandler(request, reply)
|
|
74
|
-
}
|
|
75
|
-
}
|
|
76
|
-
|
|
77
62
|
function setContext (instance, context) {
|
|
78
|
-
|
|
63
|
+
// Use the selected not-found context as the prototype rather than taking a
|
|
64
|
+
// detached shallow snapshot. The selected context's lifecycle hooks are
|
|
65
|
+
// populated during preReady (potentially after this snapshot is taken when
|
|
66
|
+
// the route is registered first), and prototype lookup keeps the
|
|
67
|
+
// route-specific 404 context linked to the live one so hooks like
|
|
68
|
+
// preHandler are not lost. The route's own onSend is preserved as an own
|
|
69
|
+
// property.
|
|
70
|
+
const _404Context = Object.create(instance[kFourOhFourContext])
|
|
79
71
|
_404Context.onSend = context.onSend
|
|
80
72
|
context[kFourOhFourContext] = _404Context
|
|
81
73
|
}
|
|
@@ -123,12 +115,8 @@ function fourOhFour (options) {
|
|
|
123
115
|
if (handler) {
|
|
124
116
|
this[kFourOhFourLevelInstance][kCanSetNotFoundHandler] = false
|
|
125
117
|
handler = handler.bind(this)
|
|
126
|
-
// update route event handler
|
|
127
|
-
_routeEventHandler = handler
|
|
128
118
|
} else {
|
|
129
119
|
handler = basic404
|
|
130
|
-
// update route event handler
|
|
131
|
-
_routeEventHandler = basic404
|
|
132
120
|
}
|
|
133
121
|
|
|
134
122
|
this.after((notHandledErr, done) => {
|
package/lib/route.js
CHANGED
|
@@ -411,10 +411,13 @@ function buildRouting (options) {
|
|
|
411
411
|
context.schema = normalizeSchema(context.schema, this.initialConfig)
|
|
412
412
|
|
|
413
413
|
const schemaController = this[kSchemaController]
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
414
|
+
// A JSON Schema boolean (`false` or `true`) is a valid schema and
|
|
415
|
+
// must still trigger the validator compiler setup, so presence is
|
|
416
|
+
// determined from an explicit `undefined` check rather than truthiness.
|
|
417
|
+
const hasValidationSchema = opts.schema.body !== undefined ||
|
|
418
|
+
opts.schema.headers !== undefined ||
|
|
419
|
+
opts.schema.querystring !== undefined ||
|
|
420
|
+
opts.schema.params !== undefined
|
|
418
421
|
if (!opts.validatorCompiler && hasValidationSchema) {
|
|
419
422
|
schemaController.setupValidator(this[kOptions])
|
|
420
423
|
}
|
package/lib/schemas.js
CHANGED
|
@@ -61,9 +61,12 @@ function normalizeSchema (routeSchemas, serverOptions) {
|
|
|
61
61
|
}
|
|
62
62
|
|
|
63
63
|
// alias query to querystring schema
|
|
64
|
-
|
|
64
|
+
// A JSON Schema boolean (`false` or `true`) is a valid schema and must still
|
|
65
|
+
// be aliased, so presence is determined from an explicit `undefined` check
|
|
66
|
+
// rather than truthiness.
|
|
67
|
+
if (routeSchemas.query !== undefined) {
|
|
65
68
|
// check if our schema has both querystring and query
|
|
66
|
-
if (routeSchemas.querystring) {
|
|
69
|
+
if (routeSchemas.querystring !== undefined) {
|
|
67
70
|
throw new FST_ERR_SCH_DUPLICATE('querystring')
|
|
68
71
|
}
|
|
69
72
|
routeSchemas.querystring = routeSchemas.query
|
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 } = require('./warnings')
|
|
16
|
+
const { FSTWRN001, FSTSEC002 } = require('./warnings')
|
|
17
17
|
|
|
18
18
|
function compileSchemasForSerialization (context, compile) {
|
|
19
19
|
if (!context.schema || !context.schema.response) {
|
|
@@ -54,6 +54,169 @@ function compileSchemasForSerialization (context, compile) {
|
|
|
54
54
|
}, {})
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
+
// Header names are case insensitive (RFC 9110 §5.1), and Node.js stores every
|
|
58
|
+
// received header name in lowercase in `request.headers` (see the Node.js
|
|
59
|
+
// `message.headers` docs). The header schema must be normalized the same way
|
|
60
|
+
// before compilation, otherwise schema keywords that reference header names by
|
|
61
|
+
// their canonical or mixed case — `properties`, `required`, and the Draft 7
|
|
62
|
+
// `dependencies` keyword (triggers and array-form dependent names) — never
|
|
63
|
+
// match the lowercased request object, silently skipping assertions.
|
|
64
|
+
//
|
|
65
|
+
// JSON Schema is recursive, so the transformation walks the whole schema and
|
|
66
|
+
// lowercases every position where a property name (i.e. a header name) appears,
|
|
67
|
+
// while recursing into subschemas. This covers `properties` and `required` in
|
|
68
|
+
// every nested subschema, `dependencies` trigger keys, array-form dependent
|
|
69
|
+
// property names and subschemas used as dependency values, and subschemas
|
|
70
|
+
// reachable through `allOf`, `anyOf`, `oneOf`, `not`, `if`/`then`/`else`, local
|
|
71
|
+
// `$ref` definitions (`definitions`/`$defs`), and the other standard subschema
|
|
72
|
+
// containers. The input schema is never mutated: a new schema tree is returned.
|
|
73
|
+
function lowerCaseHeadersSchema (schema) {
|
|
74
|
+
if (Array.isArray(schema)) {
|
|
75
|
+
return schema.map(lowerCaseHeadersSchema)
|
|
76
|
+
}
|
|
77
|
+
if (schema === null || typeof schema !== 'object') {
|
|
78
|
+
return schema
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const result = {}
|
|
82
|
+
|
|
83
|
+
for (const key of Object.keys(schema)) {
|
|
84
|
+
const value = schema[key]
|
|
85
|
+
|
|
86
|
+
switch (key) {
|
|
87
|
+
case 'properties': {
|
|
88
|
+
if (value === null || typeof value !== 'object') {
|
|
89
|
+
result.properties = value
|
|
90
|
+
break
|
|
91
|
+
}
|
|
92
|
+
const normalized = {}
|
|
93
|
+
for (const prop of Object.keys(value)) {
|
|
94
|
+
normalized[prop.toLowerCase()] = lowerCaseHeadersSchema(value[prop])
|
|
95
|
+
}
|
|
96
|
+
result.properties = normalized
|
|
97
|
+
break
|
|
98
|
+
}
|
|
99
|
+
case 'required':
|
|
100
|
+
result.required = Array.isArray(value)
|
|
101
|
+
? value.map(name => name.toLowerCase())
|
|
102
|
+
: value
|
|
103
|
+
break
|
|
104
|
+
case 'dependencies': {
|
|
105
|
+
if (value === null || typeof value !== 'object') {
|
|
106
|
+
result.dependencies = value
|
|
107
|
+
break
|
|
108
|
+
}
|
|
109
|
+
const normalized = {}
|
|
110
|
+
for (const dep of Object.keys(value)) {
|
|
111
|
+
const depValue = value[dep]
|
|
112
|
+
if (Array.isArray(depValue)) {
|
|
113
|
+
normalized[dep.toLowerCase()] = depValue.map(name => name.toLowerCase())
|
|
114
|
+
} else {
|
|
115
|
+
normalized[dep.toLowerCase()] = lowerCaseHeadersSchema(depValue)
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
result.dependencies = normalized
|
|
119
|
+
break
|
|
120
|
+
}
|
|
121
|
+
case 'dependentSchemas': {
|
|
122
|
+
if (value === null || typeof value !== 'object') {
|
|
123
|
+
result.dependentSchemas = value
|
|
124
|
+
break
|
|
125
|
+
}
|
|
126
|
+
const normalized = {}
|
|
127
|
+
for (const dep of Object.keys(value)) {
|
|
128
|
+
normalized[dep.toLowerCase()] = lowerCaseHeadersSchema(value[dep])
|
|
129
|
+
}
|
|
130
|
+
result.dependentSchemas = normalized
|
|
131
|
+
break
|
|
132
|
+
}
|
|
133
|
+
case 'dependentRequired': {
|
|
134
|
+
if (value === null || typeof value !== 'object') {
|
|
135
|
+
result.dependentRequired = value
|
|
136
|
+
break
|
|
137
|
+
}
|
|
138
|
+
const normalized = {}
|
|
139
|
+
for (const dep of Object.keys(value)) {
|
|
140
|
+
normalized[dep.toLowerCase()] = value[dep].map(name => name.toLowerCase())
|
|
141
|
+
}
|
|
142
|
+
result.dependentRequired = normalized
|
|
143
|
+
break
|
|
144
|
+
}
|
|
145
|
+
// Arrays of subschemas (`allOf`/`anyOf`/`oneOf`) or a single subschema
|
|
146
|
+
// (`not`/`if`/`then`/`else`/`items`/`additionalItems`/...). `lowerCaseHeadersSchema`
|
|
147
|
+
// handles arrays, objects and booleans, so recursing here covers them all.
|
|
148
|
+
case 'allOf':
|
|
149
|
+
case 'anyOf':
|
|
150
|
+
case 'oneOf':
|
|
151
|
+
case 'not':
|
|
152
|
+
case 'if':
|
|
153
|
+
case 'then':
|
|
154
|
+
case 'else':
|
|
155
|
+
case 'items':
|
|
156
|
+
case 'additionalItems':
|
|
157
|
+
case 'additionalProperties':
|
|
158
|
+
case 'unevaluatedItems':
|
|
159
|
+
case 'unevaluatedProperties':
|
|
160
|
+
case 'contains':
|
|
161
|
+
case 'propertyNames':
|
|
162
|
+
case 'contentSchema':
|
|
163
|
+
result[key] = lowerCaseHeadersSchema(value)
|
|
164
|
+
break
|
|
165
|
+
// Maps of subschemas whose keys are not header names (definition names,
|
|
166
|
+
// regex patterns): keep the keys, normalize the subschema values.
|
|
167
|
+
case 'definitions':
|
|
168
|
+
case '$defs':
|
|
169
|
+
case 'patternProperties': {
|
|
170
|
+
if (value === null || typeof value !== 'object') {
|
|
171
|
+
result[key] = value
|
|
172
|
+
break
|
|
173
|
+
}
|
|
174
|
+
const normalized = {}
|
|
175
|
+
for (const k of Object.keys(value)) {
|
|
176
|
+
normalized[k] = lowerCaseHeadersSchema(value[k])
|
|
177
|
+
}
|
|
178
|
+
result[key] = normalized
|
|
179
|
+
break
|
|
180
|
+
}
|
|
181
|
+
default:
|
|
182
|
+
result[key] = value
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
return result
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// `lowerCaseHeadersSchema` only normalizes the inline schema tree. A header
|
|
190
|
+
// schema can also pull in a schema registered with `addSchema()` through an
|
|
191
|
+
// external `$ref` (any ref that does not start with `#`, i.e. not a same
|
|
192
|
+
// document fragment). Those referenced schemas are resolved by the validator
|
|
193
|
+
// after normalization and keep their original header-name case, so
|
|
194
|
+
// case-insensitive assertions in them never match the lowercased request
|
|
195
|
+
// headers. We cannot safely normalize them here because the same registered
|
|
196
|
+
// schema is shared with body, querystring and params validation, so we detect
|
|
197
|
+
// the external reference and warn the developer instead. Returns the first
|
|
198
|
+
// external `$ref` found, or undefined.
|
|
199
|
+
function findExternalHeaderRef (schema) {
|
|
200
|
+
if (Array.isArray(schema)) {
|
|
201
|
+
for (const item of schema) {
|
|
202
|
+
const ref = findExternalHeaderRef(item)
|
|
203
|
+
if (ref !== undefined) return ref
|
|
204
|
+
}
|
|
205
|
+
return undefined
|
|
206
|
+
}
|
|
207
|
+
if (schema === null || typeof schema !== 'object') {
|
|
208
|
+
return undefined
|
|
209
|
+
}
|
|
210
|
+
if (typeof schema.$ref === 'string' && schema.$ref[0] !== '#') {
|
|
211
|
+
return schema.$ref
|
|
212
|
+
}
|
|
213
|
+
for (const key of Object.keys(schema)) {
|
|
214
|
+
const ref = findExternalHeaderRef(schema[key])
|
|
215
|
+
if (ref !== undefined) return ref
|
|
216
|
+
}
|
|
217
|
+
return undefined
|
|
218
|
+
}
|
|
219
|
+
|
|
57
220
|
function compileSchemasForValidation (context, compile, isCustom) {
|
|
58
221
|
const { schema } = context
|
|
59
222
|
if (!schema) {
|
|
@@ -62,32 +225,37 @@ function compileSchemasForValidation (context, compile, isCustom) {
|
|
|
62
225
|
|
|
63
226
|
const { method, url } = context.config || {}
|
|
64
227
|
|
|
228
|
+
// JSON Schema Draft 7 defines the boolean `false` (and `true`) as a valid
|
|
229
|
+
// schema. A present boolean must be passed to the compiler unchanged, so
|
|
230
|
+
// every request-part schema is selected by an explicit `undefined` check
|
|
231
|
+
// rather than truthiness. FSTWRN001 is preserved only for schemas that are
|
|
232
|
+
// explicitly set to `undefined`.
|
|
233
|
+
|
|
65
234
|
const headers = schema.headers
|
|
66
235
|
// the or part is used for backward compatibility
|
|
67
|
-
if (headers
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
headersSchemaLowerCase.properties[k.toLowerCase()] = headers.properties[k]
|
|
82
|
-
})
|
|
236
|
+
if (headers !== undefined) {
|
|
237
|
+
if (isCustom || typeof headers !== 'object' || headers === null || Object.getPrototypeOf(headers) !== Object.prototype) {
|
|
238
|
+
// do not mess with schema when custom validator applied, e.g. Joi, Typebox
|
|
239
|
+
// boolean schemas (true/false) and invalid values are compiled directly
|
|
240
|
+
context[headersSchema] = compile({ schema: headers, method, url, httpPart: 'headers' })
|
|
241
|
+
} else {
|
|
242
|
+
// The header keys are case insensitive
|
|
243
|
+
// https://datatracker.ietf.org/doc/html/rfc2616#section-4.2
|
|
244
|
+
const headersSchemaLowerCase = lowerCaseHeadersSchema(headers)
|
|
245
|
+
const externalRef = findExternalHeaderRef(headers)
|
|
246
|
+
if (externalRef !== undefined) {
|
|
247
|
+
FSTSEC002(method, url, externalRef)
|
|
248
|
+
}
|
|
249
|
+
context[headersSchema] = compile({ schema: headersSchemaLowerCase, method, url, httpPart: 'headers' })
|
|
83
250
|
}
|
|
84
|
-
context[headersSchema] = compile({ schema: headersSchemaLowerCase, method, url, httpPart: 'headers' })
|
|
85
251
|
} else if (Object.hasOwn(schema, 'headers')) {
|
|
86
252
|
FSTWRN001('headers', method, url)
|
|
87
253
|
}
|
|
88
254
|
|
|
89
|
-
if (schema.body) {
|
|
90
|
-
const contentProperty = schema.body.
|
|
255
|
+
if (schema.body !== undefined) {
|
|
256
|
+
const contentProperty = schema.body !== null && typeof schema.body === 'object'
|
|
257
|
+
? schema.body.content
|
|
258
|
+
: undefined
|
|
91
259
|
if (contentProperty) {
|
|
92
260
|
const contentTypeSchemas = {}
|
|
93
261
|
for (const contentType of Object.keys(contentProperty)) {
|
|
@@ -102,13 +270,13 @@ function compileSchemasForValidation (context, compile, isCustom) {
|
|
|
102
270
|
FSTWRN001('body', method, url)
|
|
103
271
|
}
|
|
104
272
|
|
|
105
|
-
if (schema.querystring) {
|
|
273
|
+
if (schema.querystring !== undefined) {
|
|
106
274
|
context[querystringSchema] = compile({ schema: schema.querystring, method, url, httpPart: 'querystring' })
|
|
107
275
|
} else if (Object.hasOwn(schema, 'querystring')) {
|
|
108
276
|
FSTWRN001('querystring', method, url)
|
|
109
277
|
}
|
|
110
278
|
|
|
111
|
-
if (schema.params) {
|
|
279
|
+
if (schema.params !== undefined) {
|
|
112
280
|
context[paramsSchema] = compile({ schema: schema.params, method, url, httpPart: 'params' })
|
|
113
281
|
} else if (Object.hasOwn(schema, 'params')) {
|
|
114
282
|
FSTWRN001('params', method, url)
|
|
@@ -137,8 +305,13 @@ function validateParam (validatorFunction, request, paramName) {
|
|
|
137
305
|
}
|
|
138
306
|
|
|
139
307
|
if (ret && typeof ret.then === 'function') {
|
|
308
|
+
// An async validator (for example an `$async` Ajv schema) resolves with the
|
|
309
|
+
// validated data itself, not a `{ value, error }` result. Its properties must
|
|
310
|
+
// not be unwrapped the way a synchronous custom compiler result is, otherwise
|
|
311
|
+
// a payload carrying an `error` or `value` key could inject a validation error
|
|
312
|
+
// or replace the whole request part. Treat the async result as pass/fail only.
|
|
140
313
|
return ret
|
|
141
|
-
.then((res) => { return
|
|
314
|
+
.then((res) => { return res === false ? validatorFunction.errors : false })
|
|
142
315
|
.catch(err => { return err }) // return as simple error (not throw)
|
|
143
316
|
}
|
|
144
317
|
|
package/lib/warnings.js
CHANGED
|
@@ -3,9 +3,10 @@
|
|
|
3
3
|
const { createWarning } = require('process-warning')
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
|
-
*
|
|
6
|
+
* Warning codes:
|
|
7
7
|
* - FSTWRN001
|
|
8
8
|
* - FSTSEC001
|
|
9
|
+
* - FSTSEC002
|
|
9
10
|
* - FSTDEP022
|
|
10
11
|
* - FSTDEP023
|
|
11
12
|
* - FSTDEP024
|
|
@@ -44,6 +45,13 @@ const FSTSEC001 = createWarning({
|
|
|
44
45
|
unlimited: true
|
|
45
46
|
})
|
|
46
47
|
|
|
48
|
+
const FSTSEC002 = createWarning({
|
|
49
|
+
name: 'FastifySecurity',
|
|
50
|
+
code: 'FSTSEC002',
|
|
51
|
+
message: 'The headers schema for %s: %s references an external $ref (%s) that is not case-normalized. Header names in the referenced schema keep their original case and will 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.',
|
|
52
|
+
unlimited: true
|
|
53
|
+
})
|
|
54
|
+
|
|
47
55
|
const FSTDEP022 = createWarning({
|
|
48
56
|
name: 'FastifyWarning',
|
|
49
57
|
code: 'FSTDEP022',
|
|
@@ -77,6 +85,7 @@ module.exports = {
|
|
|
77
85
|
FSTWRN003,
|
|
78
86
|
FSTWRN004,
|
|
79
87
|
FSTSEC001,
|
|
88
|
+
FSTSEC002,
|
|
80
89
|
FSTDEP022,
|
|
81
90
|
FSTDEP023,
|
|
82
91
|
FSTDEP024,
|
package/package.json
CHANGED
package/test/404s.test.js
CHANGED
|
@@ -1226,7 +1226,7 @@ test('onSend hooks run when an encapsulated route invokes the notFound handler',
|
|
|
1226
1226
|
|
|
1227
1227
|
// https://github.com/fastify/fastify/issues/713
|
|
1228
1228
|
test('preHandler option for setNotFoundHandler', async t => {
|
|
1229
|
-
t.plan(
|
|
1229
|
+
t.plan(11)
|
|
1230
1230
|
|
|
1231
1231
|
await t.test('preHandler option', (t, done) => {
|
|
1232
1232
|
t.plan(2)
|
|
@@ -1283,6 +1283,39 @@ test('preHandler option for setNotFoundHandler', async t => {
|
|
|
1283
1283
|
})
|
|
1284
1284
|
})
|
|
1285
1285
|
|
|
1286
|
+
// https://github.com/fastify/fastify/security/advisories/GHSA-gm8r-x7h4-fm5r
|
|
1287
|
+
await t.test('preHandler hook in setNotFoundHandler should be called when callNotFound and the route is registered first', (t, done) => {
|
|
1288
|
+
t.plan(3)
|
|
1289
|
+
const fastify = Fastify()
|
|
1290
|
+
|
|
1291
|
+
// Register the route before the not-found handler so that the route's
|
|
1292
|
+
// preReady callback snapshots the not-found context before its lifecycle
|
|
1293
|
+
// hooks are populated.
|
|
1294
|
+
fastify.post('/', function (req, reply) {
|
|
1295
|
+
t.assert.strictEqual(reply.callNotFound(), reply)
|
|
1296
|
+
})
|
|
1297
|
+
|
|
1298
|
+
fastify.setNotFoundHandler({
|
|
1299
|
+
preHandler: (req, reply, done) => {
|
|
1300
|
+
req.body.preHandler = true
|
|
1301
|
+
done()
|
|
1302
|
+
}
|
|
1303
|
+
}, function (req, reply) {
|
|
1304
|
+
reply.code(404).send(req.body)
|
|
1305
|
+
})
|
|
1306
|
+
|
|
1307
|
+
fastify.inject({
|
|
1308
|
+
method: 'POST',
|
|
1309
|
+
url: '/',
|
|
1310
|
+
payload: { hello: 'world' }
|
|
1311
|
+
}, (err, res) => {
|
|
1312
|
+
t.assert.ifError(err)
|
|
1313
|
+
const payload = JSON.parse(res.payload)
|
|
1314
|
+
t.assert.deepStrictEqual(payload, { preHandler: true, hello: 'world' })
|
|
1315
|
+
done()
|
|
1316
|
+
})
|
|
1317
|
+
})
|
|
1318
|
+
|
|
1286
1319
|
await t.test('preHandler hook in setNotFoundHandler should accept an array of functions and be called when callNotFound', (t, done) => {
|
|
1287
1320
|
t.plan(2)
|
|
1288
1321
|
const fastify = Fastify()
|
|
@@ -1800,11 +1833,12 @@ test('400 in case of bad url (pre find-my-way v2.2.0 was a 404)', async t => {
|
|
|
1800
1833
|
method: 'GET'
|
|
1801
1834
|
}, (err, response) => {
|
|
1802
1835
|
t.assert.ifError(err)
|
|
1803
|
-
t.assert.strictEqual(response.statusCode,
|
|
1836
|
+
t.assert.strictEqual(response.statusCode, 400)
|
|
1804
1837
|
t.assert.deepStrictEqual(JSON.parse(response.payload), {
|
|
1805
|
-
error: '
|
|
1806
|
-
message: '
|
|
1807
|
-
statusCode:
|
|
1838
|
+
error: 'Bad Request',
|
|
1839
|
+
message: "'/%c0' is not a valid url component",
|
|
1840
|
+
statusCode: 400,
|
|
1841
|
+
code: 'FST_ERR_BAD_URL'
|
|
1808
1842
|
})
|
|
1809
1843
|
done()
|
|
1810
1844
|
})
|
|
@@ -1840,12 +1874,76 @@ test('400 in case of bad url (pre find-my-way v2.2.0 was a 404)', async t => {
|
|
|
1840
1874
|
method: 'GET'
|
|
1841
1875
|
}, (err, response) => {
|
|
1842
1876
|
t.assert.ifError(err)
|
|
1843
|
-
t.assert.strictEqual(response.statusCode,
|
|
1844
|
-
t.assert.deepStrictEqual(response.payload,
|
|
1877
|
+
t.assert.strictEqual(response.statusCode, 400)
|
|
1878
|
+
t.assert.deepStrictEqual(JSON.parse(response.payload), {
|
|
1879
|
+
error: 'Bad Request',
|
|
1880
|
+
message: "'/%c0' is not a valid url component",
|
|
1881
|
+
statusCode: 400,
|
|
1882
|
+
code: 'FST_ERR_BAD_URL'
|
|
1883
|
+
})
|
|
1845
1884
|
done()
|
|
1846
1885
|
})
|
|
1847
1886
|
})
|
|
1848
1887
|
|
|
1888
|
+
await t.test('Bad URL does not invoke an encapsulated not found handler', async t => {
|
|
1889
|
+
const fastify = Fastify()
|
|
1890
|
+
let publicHandlerCalls = 0
|
|
1891
|
+
let privatePreHandlerCalls = 0
|
|
1892
|
+
let privateHandlerCalls = 0
|
|
1893
|
+
|
|
1894
|
+
fastify.register(async function (instance) {
|
|
1895
|
+
instance.get('/existing', () => 'public')
|
|
1896
|
+
instance.setNotFoundHandler(function (_request, reply) {
|
|
1897
|
+
publicHandlerCalls++
|
|
1898
|
+
reply.code(404).send('public not found')
|
|
1899
|
+
})
|
|
1900
|
+
}, { prefix: '/public' })
|
|
1901
|
+
|
|
1902
|
+
fastify.register(async function (instance) {
|
|
1903
|
+
instance.setNotFoundHandler({
|
|
1904
|
+
preHandler: function (request, reply, done) {
|
|
1905
|
+
privatePreHandlerCalls++
|
|
1906
|
+
if (request.headers.authorization !== 'Bearer valid') {
|
|
1907
|
+
reply.code(401).send('unauthorized')
|
|
1908
|
+
}
|
|
1909
|
+
done()
|
|
1910
|
+
}
|
|
1911
|
+
}, function (_request, reply) {
|
|
1912
|
+
privateHandlerCalls++
|
|
1913
|
+
reply.send('private canary')
|
|
1914
|
+
})
|
|
1915
|
+
}, { prefix: '/private' })
|
|
1916
|
+
|
|
1917
|
+
const malformed = await fastify.inject({
|
|
1918
|
+
method: 'DELETE',
|
|
1919
|
+
url: '/public/%c0'
|
|
1920
|
+
})
|
|
1921
|
+
t.assert.strictEqual(malformed.statusCode, 400)
|
|
1922
|
+
t.assert.strictEqual(JSON.parse(malformed.payload).code, 'FST_ERR_BAD_URL')
|
|
1923
|
+
t.assert.strictEqual(publicHandlerCalls, 0)
|
|
1924
|
+
t.assert.strictEqual(privatePreHandlerCalls, 0)
|
|
1925
|
+
t.assert.strictEqual(privateHandlerCalls, 0)
|
|
1926
|
+
|
|
1927
|
+
const unauthorized = await fastify.inject({
|
|
1928
|
+
method: 'DELETE',
|
|
1929
|
+
url: '/private/missing'
|
|
1930
|
+
})
|
|
1931
|
+
t.assert.strictEqual(unauthorized.statusCode, 401)
|
|
1932
|
+
t.assert.strictEqual(unauthorized.payload, 'unauthorized')
|
|
1933
|
+
t.assert.strictEqual(privatePreHandlerCalls, 1)
|
|
1934
|
+
t.assert.strictEqual(privateHandlerCalls, 0)
|
|
1935
|
+
|
|
1936
|
+
const authorized = await fastify.inject({
|
|
1937
|
+
method: 'DELETE',
|
|
1938
|
+
url: '/private/missing',
|
|
1939
|
+
headers: { authorization: 'Bearer valid' }
|
|
1940
|
+
})
|
|
1941
|
+
t.assert.strictEqual(authorized.statusCode, 200)
|
|
1942
|
+
t.assert.strictEqual(authorized.payload, 'private canary')
|
|
1943
|
+
t.assert.strictEqual(privatePreHandlerCalls, 2)
|
|
1944
|
+
t.assert.strictEqual(privateHandlerCalls, 1)
|
|
1945
|
+
})
|
|
1946
|
+
|
|
1849
1947
|
await t.test('Bad URL with special characters should be properly JSON escaped', (t, done) => {
|
|
1850
1948
|
t.plan(3)
|
|
1851
1949
|
const fastify = Fastify()
|
|
@@ -825,6 +825,30 @@ test('Should honor routerOptions.onBadUrl', async t => {
|
|
|
825
825
|
t.assert.strictEqual(res.payload, 'Bad URL: /hello/%world')
|
|
826
826
|
})
|
|
827
827
|
|
|
828
|
+
test('Should honor routerOptions.onBadUrl when no method tree exists', async t => {
|
|
829
|
+
t.plan(3)
|
|
830
|
+
const fastify = Fastify({
|
|
831
|
+
routerOptions: {
|
|
832
|
+
onBadUrl: function (path, _, res) {
|
|
833
|
+
t.assert.ok('bad url called')
|
|
834
|
+
res.statusCode = 400
|
|
835
|
+
res.end(`Bad URL: ${path}`)
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
})
|
|
839
|
+
|
|
840
|
+
fastify.get('/hello/:id', (req, res) => {
|
|
841
|
+
res.send({ hello: 'world' })
|
|
842
|
+
})
|
|
843
|
+
|
|
844
|
+
const res = await fastify.inject({
|
|
845
|
+
method: 'DELETE',
|
|
846
|
+
url: '/hello/%world'
|
|
847
|
+
})
|
|
848
|
+
t.assert.strictEqual(res.statusCode, 400)
|
|
849
|
+
t.assert.strictEqual(res.payload, 'Bad URL: /hello/%world')
|
|
850
|
+
})
|
|
851
|
+
|
|
828
852
|
test('Should honor routerOptions.onMaxParamLength', async t => {
|
|
829
853
|
t.plan(3)
|
|
830
854
|
const fastify = Fastify({
|