fastify 6.0.0-alpha.4 → 6.0.0-alpha.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/README.md +1 -0
- package/SECURITY.md +6 -0
- package/build/build-validation.js +2 -0
- package/docs/Guides/Ecosystem.md +12 -53
- package/docs/Guides/Migration-Guide-V5.md +2 -2
- package/docs/Guides/Serverless.md +33 -10
- package/docs/Reference/Errors.md +13 -2
- package/docs/Reference/Hooks.md +13 -0
- package/docs/Reference/Reply.md +787 -681
- package/docs/Reference/Request.md +308 -104
- package/docs/Reference/Routes.md +6 -2
- package/docs/Reference/Server.md +41 -4
- package/docs/Reference/Validation-and-Serialization.md +48 -1
- package/docs/Reference/Warnings.md +2 -0
- package/fastify.d.ts +1 -0
- package/fastify.js +35 -1
- package/lib/config-validator.js +102 -73
- package/lib/content-type-parser.js +35 -14
- package/lib/content-type.js +4 -4
- package/lib/context.js +2 -1
- package/lib/decorate.js +4 -3
- package/lib/error-handler.js +21 -4
- package/lib/error-status.js +4 -2
- package/lib/errors.js +15 -14
- package/lib/handle-request.js +7 -20
- package/lib/reply.js +18 -13
- package/lib/request.js +12 -7
- package/lib/route.js +40 -7
- package/lib/schemas.js +44 -32
- package/lib/validation.js +67 -10
- package/lib/warnings.js +10 -1
- package/package.json +8 -3
- package/test/child-logger-factory.test.js +25 -0
- package/test/close-disposable.test.js +154 -0
- package/test/constrained-routes.test.js +4 -2
- package/test/content-parser.test.js +99 -0
- package/test/decorator.test.js +29 -0
- package/test/diagnostics-channel/error-status.test.js +35 -2
- package/test/handler-timeout.test.js +224 -0
- package/test/internals/all.test.js +5 -0
- package/test/internals/errors.test.js +19 -20
- package/test/internals/initial-config.test.js +2 -0
- package/test/internals/reply-serialize.test.js +20 -0
- package/test/internals/reply.test.js +30 -1
- package/test/internals/request.test.js +50 -0
- package/test/internals/server.test.js +6 -4
- package/test/reply-error.test.js +82 -0
- package/test/reply-media-type.test.js +57 -0
- package/test/reply-trailers.test.js +159 -1
- package/test/rfc-10008.test.js +6 -9
- package/test/router-compile.test.js +241 -0
- package/test/schema-feature.test.js +1 -1
- package/test/schema-serialization.test.js +120 -7
- package/test/schema-validation.test.js +251 -1
- package/test/types/errors.tst.ts +0 -1
- package/test/types/fastify.tst.ts +9 -0
- package/test/types/instance.tst.ts +1 -1
- package/test/types/route.tst.ts +37 -0
- package/test/types/using.tst.ts +20 -0
- package/test/validation-error-handling.test.js +21 -1
- package/types/errors.d.ts +0 -1
- package/types/instance.d.ts +9 -5
- package/types/route.d.ts +4 -2
package/lib/errors.js
CHANGED
|
@@ -2,6 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
const createError = require('@fastify/error')
|
|
4
4
|
|
|
5
|
+
// Errors created when Fastify rejects an invalid request. They have no stack
|
|
6
|
+
// trace, see "Errors In Input Data" in docs/Reference/Errors.md.
|
|
7
|
+
function createRequestError (code, message, statusCode, Base = Error) {
|
|
8
|
+
return createError(code, message, statusCode, Base, false)
|
|
9
|
+
}
|
|
10
|
+
|
|
5
11
|
const codes = {
|
|
6
12
|
/**
|
|
7
13
|
* Basic
|
|
@@ -47,7 +53,7 @@ const codes = {
|
|
|
47
53
|
500,
|
|
48
54
|
TypeError
|
|
49
55
|
),
|
|
50
|
-
FST_ERR_VALIDATION:
|
|
56
|
+
FST_ERR_VALIDATION: createRequestError(
|
|
51
57
|
'FST_ERR_VALIDATION',
|
|
52
58
|
'%s',
|
|
53
59
|
400
|
|
@@ -102,29 +108,29 @@ const codes = {
|
|
|
102
108
|
500,
|
|
103
109
|
TypeError
|
|
104
110
|
),
|
|
105
|
-
FST_ERR_CTP_BODY_TOO_LARGE:
|
|
111
|
+
FST_ERR_CTP_BODY_TOO_LARGE: createRequestError(
|
|
106
112
|
'FST_ERR_CTP_BODY_TOO_LARGE',
|
|
107
113
|
'Request body is too large',
|
|
108
114
|
413,
|
|
109
115
|
RangeError
|
|
110
116
|
),
|
|
111
|
-
FST_ERR_CTP_INVALID_MEDIA_TYPE:
|
|
117
|
+
FST_ERR_CTP_INVALID_MEDIA_TYPE: createRequestError(
|
|
112
118
|
'FST_ERR_CTP_INVALID_MEDIA_TYPE',
|
|
113
119
|
'Unsupported Media Type',
|
|
114
120
|
415
|
|
115
121
|
),
|
|
116
|
-
FST_ERR_CTP_INVALID_CONTENT_LENGTH:
|
|
122
|
+
FST_ERR_CTP_INVALID_CONTENT_LENGTH: createRequestError(
|
|
117
123
|
'FST_ERR_CTP_INVALID_CONTENT_LENGTH',
|
|
118
124
|
'Request body size did not match Content-Length',
|
|
119
125
|
400,
|
|
120
126
|
RangeError
|
|
121
127
|
),
|
|
122
|
-
FST_ERR_CTP_EMPTY_JSON_BODY:
|
|
128
|
+
FST_ERR_CTP_EMPTY_JSON_BODY: createRequestError(
|
|
123
129
|
'FST_ERR_CTP_EMPTY_JSON_BODY',
|
|
124
130
|
"Body cannot be empty when content-type is set to 'application/json'",
|
|
125
131
|
400
|
|
126
132
|
),
|
|
127
|
-
FST_ERR_CTP_INVALID_JSON_BODY:
|
|
133
|
+
FST_ERR_CTP_INVALID_JSON_BODY: createRequestError(
|
|
128
134
|
'FST_ERR_CTP_INVALID_JSON_BODY',
|
|
129
135
|
"Body is not valid JSON but content-type is set to 'application/json'",
|
|
130
136
|
400
|
|
@@ -365,13 +371,13 @@ const codes = {
|
|
|
365
371
|
'FST_ERR_DUPLICATED_ROUTE',
|
|
366
372
|
"Method '%s' already declared for route '%s'"
|
|
367
373
|
),
|
|
368
|
-
FST_ERR_BAD_URL:
|
|
374
|
+
FST_ERR_BAD_URL: createRequestError(
|
|
369
375
|
'FST_ERR_BAD_URL',
|
|
370
376
|
"'%s' is not a valid url component",
|
|
371
377
|
400,
|
|
372
378
|
URIError
|
|
373
379
|
),
|
|
374
|
-
FST_ERR_MAX_PARAM_LENGTH:
|
|
380
|
+
FST_ERR_MAX_PARAM_LENGTH: createRequestError(
|
|
375
381
|
'FST_ERR_MAX_PARAM_LENGTH',
|
|
376
382
|
"'%s' is exceeding the max param length",
|
|
377
383
|
414,
|
|
@@ -461,16 +467,11 @@ const codes = {
|
|
|
461
467
|
500,
|
|
462
468
|
TypeError
|
|
463
469
|
),
|
|
464
|
-
FST_ERR_ROUTE_MISSING_CONTENT_TYPE:
|
|
470
|
+
FST_ERR_ROUTE_MISSING_CONTENT_TYPE: createRequestError(
|
|
465
471
|
'FST_ERR_ROUTE_MISSING_CONTENT_TYPE',
|
|
466
472
|
"Method '%s' must provide a 'Content-Type' header.",
|
|
467
473
|
400
|
|
468
474
|
),
|
|
469
|
-
FST_ERR_ROUTE_MISSING_CONTENT: createError(
|
|
470
|
-
'FST_ERR_ROUTE_MISSING_CONTENT',
|
|
471
|
-
"Method '%s' must provide a request body.",
|
|
472
|
-
400
|
|
473
|
-
),
|
|
474
475
|
|
|
475
476
|
/**
|
|
476
477
|
* again listen when close server
|
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
|
}
|
|
@@ -370,7 +374,7 @@ Reply.prototype.getSerializationFunction = function (schemaOrStatus, contentType
|
|
|
370
374
|
return serialize
|
|
371
375
|
}
|
|
372
376
|
|
|
373
|
-
Reply.prototype.compileSerializationSchema = function (schema, httpStatus
|
|
377
|
+
Reply.prototype.compileSerializationSchema = function (schema, httpStatus, contentType) {
|
|
374
378
|
const { request } = this
|
|
375
379
|
const { method, url } = request
|
|
376
380
|
|
|
@@ -839,9 +843,6 @@ function sendStream (payload, res, reply) {
|
|
|
839
843
|
let sourceOpen = true
|
|
840
844
|
let errorLogged = false
|
|
841
845
|
|
|
842
|
-
// set trailer when stream ended
|
|
843
|
-
sendStreamTrailer(payload, res, reply)
|
|
844
|
-
|
|
845
846
|
eos(payload, { readable: true, writable: false }, function (err) {
|
|
846
847
|
sourceOpen = false
|
|
847
848
|
if (err != null) {
|
|
@@ -854,8 +855,12 @@ function sendStream (payload, res, reply) {
|
|
|
854
855
|
} else {
|
|
855
856
|
onErrorHook(reply, err)
|
|
856
857
|
}
|
|
858
|
+
return
|
|
859
|
+
}
|
|
860
|
+
// eos fires even for a stream that already ended
|
|
861
|
+
if (reply[kReplyTrailers] !== null) {
|
|
862
|
+
sendTrailer(null, res, reply)
|
|
857
863
|
}
|
|
858
|
-
// there is nothing to do if there is not an error
|
|
859
864
|
})
|
|
860
865
|
|
|
861
866
|
eos(res, function (err) {
|
|
@@ -887,7 +892,12 @@ function sendStream (payload, res, reply) {
|
|
|
887
892
|
} else {
|
|
888
893
|
reply.log.warn('response will send, but you shouldn\'t use res.writeHead in stream mode')
|
|
889
894
|
}
|
|
890
|
-
|
|
895
|
+
// trailers set: the eos listener above ends the response, not pipe
|
|
896
|
+
if (reply[kReplyTrailers] !== null) {
|
|
897
|
+
payload.pipe(res, { end: false })
|
|
898
|
+
} else {
|
|
899
|
+
payload.pipe(res)
|
|
900
|
+
}
|
|
891
901
|
}
|
|
892
902
|
|
|
893
903
|
function sendTrailer (payload, res, reply) {
|
|
@@ -947,11 +957,6 @@ function sendTrailer (payload, res, reply) {
|
|
|
947
957
|
if (skipped) res.end(null, null, null) // avoid ArgumentsAdaptorTrampoline from V8
|
|
948
958
|
}
|
|
949
959
|
|
|
950
|
-
function sendStreamTrailer (payload, res, reply) {
|
|
951
|
-
if (reply[kReplyTrailers] === null) return
|
|
952
|
-
payload.on('end', () => sendTrailer(null, res, reply))
|
|
953
|
-
}
|
|
954
|
-
|
|
955
960
|
function onErrorHook (reply, error, cb) {
|
|
956
961
|
if (reply[kRouteContext].onError !== null && !reply[kReplyNextErrorHandler]) {
|
|
957
962
|
reply[kReplyIsRunningOnErrorHook] = true
|
|
@@ -1079,7 +1084,7 @@ function notFound (reply) {
|
|
|
1079
1084
|
* @returns {string} the serialized payload
|
|
1080
1085
|
*/
|
|
1081
1086
|
function serialize (context, data, statusCode, contentType) {
|
|
1082
|
-
const fnSerialize = getSchemaSerializer(context, statusCode, contentType)
|
|
1087
|
+
const fnSerialize = getSchemaSerializer(context, statusCode, contentType, { failClosed: true })
|
|
1083
1088
|
if (fnSerialize) {
|
|
1084
1089
|
return fnSerialize(data)
|
|
1085
1090
|
}
|
package/lib/request.js
CHANGED
|
@@ -20,6 +20,8 @@ const { FST_ERR_REQ_INVALID_VALIDATION_INVOCATION, FST_ERR_DEC_UNDECLARED } = re
|
|
|
20
20
|
const decorators = require('./decorate')
|
|
21
21
|
const ContentType = require('./content-type')
|
|
22
22
|
|
|
23
|
+
const PORT_REGEX = /(?<port>:\d+)$/
|
|
24
|
+
|
|
23
25
|
const HTTP_PART_SYMBOL_MAP = {
|
|
24
26
|
body: kSchemaBody,
|
|
25
27
|
headers: kSchemaHeaders,
|
|
@@ -122,8 +124,10 @@ function buildRequestWithTrustProxy (R, trustProxy) {
|
|
|
122
124
|
host: {
|
|
123
125
|
get () {
|
|
124
126
|
const socket = this.raw.socket
|
|
125
|
-
|
|
126
|
-
|
|
127
|
+
// The headers getter may allocate a new object, so read it once.
|
|
128
|
+
const headers = this.headers
|
|
129
|
+
if (headers['x-forwarded-host'] && socket != null && proxyFn(socket.remoteAddress, 0)) {
|
|
130
|
+
return getLastEntryInMultiHeaderValue(headers['x-forwarded-host'])
|
|
127
131
|
}
|
|
128
132
|
/**
|
|
129
133
|
* The last fallback supports the following cases:
|
|
@@ -131,14 +135,16 @@ function buildRequestWithTrustProxy (R, trustProxy) {
|
|
|
131
135
|
* 2. HTTP/1.0 without a Host Header
|
|
132
136
|
* 3. Headers schema that may remove the Host Header
|
|
133
137
|
*/
|
|
134
|
-
return
|
|
138
|
+
return headers.host ?? headers[':authority'] ?? ''
|
|
135
139
|
}
|
|
136
140
|
},
|
|
137
141
|
protocol: {
|
|
138
142
|
get () {
|
|
139
143
|
const socket = this.raw.socket
|
|
140
|
-
|
|
141
|
-
|
|
144
|
+
// The headers getter may allocate a new object, so read it once.
|
|
145
|
+
const headers = this.headers
|
|
146
|
+
if (headers['x-forwarded-proto'] && socket != null && proxyFn(socket.remoteAddress, 0)) {
|
|
147
|
+
return getLastEntryInMultiHeaderValue(headers['x-forwarded-proto'])
|
|
142
148
|
}
|
|
143
149
|
if (this.socket) {
|
|
144
150
|
return this.socket.encrypted ? 'https' : 'http'
|
|
@@ -259,8 +265,7 @@ Object.defineProperties(Request.prototype, {
|
|
|
259
265
|
},
|
|
260
266
|
port: {
|
|
261
267
|
get () {
|
|
262
|
-
const
|
|
263
|
-
const matches = portReg.exec(this.host)
|
|
268
|
+
const matches = PORT_REGEX.exec(this.host)
|
|
264
269
|
if (matches === null || matches[1] === undefined) {
|
|
265
270
|
return null
|
|
266
271
|
}
|
package/lib/route.js
CHANGED
|
@@ -110,7 +110,10 @@ function buildRouting (options) {
|
|
|
110
110
|
return503OnClosing = Object.hasOwn(options, 'return503OnClosing') ? options.return503OnClosing : true
|
|
111
111
|
keepAliveConnections = fastifyArgs.keepAliveConnections
|
|
112
112
|
},
|
|
113
|
-
|
|
113
|
+
// router func to find the right handler to call, not bound because
|
|
114
|
+
// router.compile() replaces router.lookup
|
|
115
|
+
routing: (req, res, done) => router.lookup(req, res, done),
|
|
116
|
+
compileRoutes: () => router.compile(),
|
|
114
117
|
route, // configure a route in the fastify instance
|
|
115
118
|
hasRoute,
|
|
116
119
|
prepareRoute,
|
|
@@ -466,8 +469,12 @@ function buildRouting (options) {
|
|
|
466
469
|
// the child logger creation altogether
|
|
467
470
|
childLogger = logger
|
|
468
471
|
} else {
|
|
469
|
-
const loggerOpts = {
|
|
470
|
-
|
|
472
|
+
const loggerOpts = {}
|
|
473
|
+
|
|
474
|
+
// An empty level means "inherit". Passing it would make pino treat it
|
|
475
|
+
// as a level change and rebuild every log method of each request child.
|
|
476
|
+
if (context.logLevel) {
|
|
477
|
+
loggerOpts.level = context.logLevel
|
|
471
478
|
}
|
|
472
479
|
|
|
473
480
|
if (context.logSerializers) {
|
|
@@ -531,12 +538,35 @@ function buildRouting (options) {
|
|
|
531
538
|
request[kRequestSignal] = ac
|
|
532
539
|
|
|
533
540
|
request[kTimeoutTimer] = setTimeout(() => {
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
541
|
+
// If the reply has already ended (or was hijacked) the response
|
|
542
|
+
// lifecycle is managed elsewhere and there is nothing left to do.
|
|
543
|
+
if (reply.sent === true) {
|
|
544
|
+
return
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
const err = new FST_ERR_HANDLER_TIMEOUT(handlerTimeout, context.config?.url)
|
|
548
|
+
ac.abort(err)
|
|
549
|
+
|
|
550
|
+
// Aborting runs any request.signal listener synchronously; if a
|
|
551
|
+
// listener sent or hijacked the response, leave it intact rather than
|
|
552
|
+
// destroying it, which would reset the connection.
|
|
553
|
+
if (reply.sent === true) {
|
|
554
|
+
return
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
if (reply.raw.headersSent === true) {
|
|
558
|
+
// The response has already started transmitting, e.g. an active
|
|
559
|
+
// stream that has written its headers/body before the timeout.
|
|
560
|
+
// Sending an error response now would raise an uncaught
|
|
561
|
+
// ERR_HTTP_HEADERS_SENT and terminate the process, so abort the
|
|
562
|
+
// in-flight response instead.
|
|
537
563
|
reply[kReplyIsError] = true
|
|
538
|
-
reply.
|
|
564
|
+
reply.raw.destroy(err)
|
|
565
|
+
return
|
|
539
566
|
}
|
|
567
|
+
|
|
568
|
+
reply[kReplyIsError] = true
|
|
569
|
+
reply.send(err)
|
|
540
570
|
}, handlerTimeout)
|
|
541
571
|
|
|
542
572
|
const onAbort = () => {
|
|
@@ -674,6 +704,9 @@ function buildRouterOptions (options, defaultOptions) {
|
|
|
674
704
|
}
|
|
675
705
|
}
|
|
676
706
|
|
|
707
|
+
// compile is only accepted in routerOptions, not as a top-level option
|
|
708
|
+
routerOptions.compile ??= defaultInitOptions.routerOptions.compile
|
|
709
|
+
|
|
677
710
|
return routerOptions
|
|
678
711
|
}
|
|
679
712
|
|
package/lib/schemas.js
CHANGED
|
@@ -8,7 +8,8 @@ const {
|
|
|
8
8
|
FST_ERR_SCH_MISSING_ID,
|
|
9
9
|
FST_ERR_SCH_ALREADY_PRESENT,
|
|
10
10
|
FST_ERR_SCH_DUPLICATE,
|
|
11
|
-
FST_ERR_SCH_CONTENT_MISSING_SCHEMA
|
|
11
|
+
FST_ERR_SCH_CONTENT_MISSING_SCHEMA,
|
|
12
|
+
FST_ERR_MISSING_CONTENTTYPE_SERIALIZATION_FN
|
|
12
13
|
} = require('./errors')
|
|
13
14
|
const ContentType = require('./content-type')
|
|
14
15
|
|
|
@@ -134,6 +135,40 @@ function generateFluentSchema (schema) {
|
|
|
134
135
|
}
|
|
135
136
|
}
|
|
136
137
|
|
|
138
|
+
/**
|
|
139
|
+
* Resolve the right JSON Schema serialization function for a per-media-type
|
|
140
|
+
* (`content`) response schema map, given the response media type.
|
|
141
|
+
*
|
|
142
|
+
* @param {object} contentSchema the compiled per-content-type schema map
|
|
143
|
+
* @param {string} mediaType the response media type (lowercased)
|
|
144
|
+
* @param {string|number} statusCode the resolved http status key
|
|
145
|
+
* @param {boolean} failClosed whether to throw when no schema matches (used
|
|
146
|
+
* for route response serialization) or return false (used by the error
|
|
147
|
+
* handler, which falls back to the default error serializer)
|
|
148
|
+
* @returns {function|false} the serializer function, or false when no schema
|
|
149
|
+
* applies and failClosed is false
|
|
150
|
+
*/
|
|
151
|
+
function getContentSerializer (contentSchema, mediaType, statusCode, failClosed) {
|
|
152
|
+
if (contentSchema[mediaType]) {
|
|
153
|
+
return contentSchema[mediaType]
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// fallback to match all media-types
|
|
157
|
+
if (contentSchema['*/*']) {
|
|
158
|
+
return contentSchema['*/*']
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// The response content-type is not declared in the route's content map. When
|
|
162
|
+
// serializing a route response, do not fall back to a raw JSON dump (which
|
|
163
|
+
// would bypass the response schema and expose every field); surface the
|
|
164
|
+
// misconfiguration instead.
|
|
165
|
+
if (failClosed) {
|
|
166
|
+
throw new FST_ERR_MISSING_CONTENTTYPE_SERIALIZATION_FN(statusCode, mediaType)
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return false
|
|
170
|
+
}
|
|
171
|
+
|
|
137
172
|
/**
|
|
138
173
|
* Search for the right JSON schema compiled function in the request context
|
|
139
174
|
* setup by the route configuration `schema.response`.
|
|
@@ -142,11 +177,15 @@ function generateFluentSchema (schema) {
|
|
|
142
177
|
* @param {object} context the request context
|
|
143
178
|
* @param {number} statusCode the http status code
|
|
144
179
|
* @param {string} [contentType] the reply content type
|
|
180
|
+
* @param {object} [opts] serialization options
|
|
181
|
+
* @param {boolean} [opts.failClosed] when true, throw when the response
|
|
182
|
+
* content-type is not declared in the content map; otherwise return false
|
|
145
183
|
* @returns {function|false} the right JSON Schema function to serialize
|
|
146
184
|
* the reply or false if it is not set
|
|
147
185
|
*/
|
|
148
|
-
function getSchemaSerializer (context, statusCode, contentType) {
|
|
186
|
+
function getSchemaSerializer (context, statusCode, contentType, opts = {}) {
|
|
149
187
|
const responseSchemaDef = context[kSchemaResponse]
|
|
188
|
+
const failClosed = opts.failClosed === true
|
|
150
189
|
if (!responseSchemaDef) {
|
|
151
190
|
return false
|
|
152
191
|
}
|
|
@@ -154,16 +193,7 @@ function getSchemaSerializer (context, statusCode, contentType) {
|
|
|
154
193
|
if (responseSchemaDef[statusCode].constructor === Object) {
|
|
155
194
|
const ct = ContentType.from(contentType)
|
|
156
195
|
if (ct.isValid) {
|
|
157
|
-
|
|
158
|
-
return responseSchemaDef[statusCode][ct.mediaType]
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
// fallback to match all media-type
|
|
162
|
-
if (responseSchemaDef[statusCode]['*/*']) {
|
|
163
|
-
return responseSchemaDef[statusCode]['*/*']
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
return false
|
|
196
|
+
return getContentSerializer(responseSchemaDef[statusCode], ct.mediaType, statusCode, failClosed)
|
|
167
197
|
}
|
|
168
198
|
}
|
|
169
199
|
return responseSchemaDef[statusCode]
|
|
@@ -173,16 +203,7 @@ function getSchemaSerializer (context, statusCode, contentType) {
|
|
|
173
203
|
if (responseSchemaDef[fallbackStatusCode].constructor === Object) {
|
|
174
204
|
const ct = ContentType.from(contentType)
|
|
175
205
|
if (ct.isValid) {
|
|
176
|
-
|
|
177
|
-
return responseSchemaDef[fallbackStatusCode][ct.mediaType]
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
// fallback to match all media-type
|
|
181
|
-
if (responseSchemaDef[fallbackStatusCode]['*/*']) {
|
|
182
|
-
return responseSchemaDef[fallbackStatusCode]['*/*']
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
return false
|
|
206
|
+
return getContentSerializer(responseSchemaDef[fallbackStatusCode], ct.mediaType, fallbackStatusCode, failClosed)
|
|
186
207
|
}
|
|
187
208
|
}
|
|
188
209
|
|
|
@@ -192,16 +213,7 @@ function getSchemaSerializer (context, statusCode, contentType) {
|
|
|
192
213
|
if (responseSchemaDef.default.constructor === Object) {
|
|
193
214
|
const ct = ContentType.from(contentType)
|
|
194
215
|
if (ct.isValid) {
|
|
195
|
-
|
|
196
|
-
return responseSchemaDef.default[ct.mediaType]
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
// fallback to match all media-type
|
|
200
|
-
if (responseSchemaDef.default['*/*']) {
|
|
201
|
-
return responseSchemaDef.default['*/*']
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
return false
|
|
216
|
+
return getContentSerializer(responseSchemaDef.default, ct.mediaType, 'default', failClosed)
|
|
205
217
|
}
|
|
206
218
|
}
|
|
207
219
|
|
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
|
*
|
|
11
12
|
* Deprecation Codes FSTDEP001 - FSTDEP021 were used by v4 and MUST NOT be reused.
|
|
12
13
|
* - FSTDEP022 - FSTDEP025 are used by v5 and MUST NOT be reused.
|
|
@@ -41,9 +42,17 @@ const FSTSEC002 = createWarning({
|
|
|
41
42
|
unlimited: true
|
|
42
43
|
})
|
|
43
44
|
|
|
45
|
+
const FSTSEC003 = createWarning({
|
|
46
|
+
name: 'FastifySecurity',
|
|
47
|
+
code: 'FSTSEC003',
|
|
48
|
+
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.',
|
|
49
|
+
unlimited: true
|
|
50
|
+
})
|
|
51
|
+
|
|
44
52
|
module.exports = {
|
|
45
53
|
FSTWRN001,
|
|
46
54
|
FSTWRN003,
|
|
47
55
|
FSTSEC001,
|
|
48
|
-
FSTSEC002
|
|
56
|
+
FSTSEC002,
|
|
57
|
+
FSTSEC003
|
|
49
58
|
}
|