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.
@@ -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:
@@ -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: createRouteEventHandler(),
38
- onMaxParamLength: createRouteEventHandler(),
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
- const _404Context = Object.assign({}, instance[kFourOhFourContext])
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
- const hasValidationSchema = opts.schema.body ||
415
- opts.schema.headers ||
416
- opts.schema.querystring ||
417
- opts.schema.params
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
- if (routeSchemas.query) {
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 && (isCustom || Object.getPrototypeOf(headers) !== Object.prototype)) {
68
- // do not mess with schema when custom validator applied, e.g. Joi, Typebox
69
- context[headersSchema] = compile({ schema: headers, method, url, httpPart: 'headers' })
70
- } else if (headers) {
71
- // The header keys are case insensitive
72
- // https://datatracker.ietf.org/doc/html/rfc2616#section-4.2
73
- const headersSchemaLowerCase = {}
74
- Object.keys(headers).forEach(k => { headersSchemaLowerCase[k] = headers[k] })
75
- if (headersSchemaLowerCase.required instanceof Array) {
76
- headersSchemaLowerCase.required = headersSchemaLowerCase.required.map(h => h.toLowerCase())
77
- }
78
- if (headers.properties) {
79
- headersSchemaLowerCase.properties = {}
80
- Object.keys(headers.properties).forEach(k => {
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.content
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 answer(res) })
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
- * Deprecation codes:
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fastify",
3
- "version": "5.12.1",
3
+ "version": "5.12.2",
4
4
  "description": "Fast and low overhead web framework, for Node.js",
5
5
  "main": "fastify.js",
6
6
  "type": "commonjs",
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(10)
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, 404)
1836
+ t.assert.strictEqual(response.statusCode, 400)
1804
1837
  t.assert.deepStrictEqual(JSON.parse(response.payload), {
1805
- error: 'Not Found',
1806
- message: 'Route GET:/%c0 not found',
1807
- statusCode: 404
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, 404)
1844
- t.assert.deepStrictEqual(response.payload, 'this was not found')
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({