UNPKG

13.5 kBJavaScriptView Raw
1'use strict'
2
3const { AsyncResource } = require('node:async_hooks')
4const { FifoMap: Fifo } = require('toad-cache')
5const { safeParse: safeParseContentType, defaultContentType } = require('fast-content-type-parse')
6const secureJson = require('secure-json-parse')
7const {
8 kDefaultJsonParse,
9 kContentTypeParser,
10 kBodyLimit,
11 kRequestPayloadStream,
12 kState,
13 kTestInternals,
14 kReplyIsError,
15 kRouteContext
16} = require('./symbols')
17
18const {
19 FST_ERR_CTP_INVALID_TYPE,
20 FST_ERR_CTP_EMPTY_TYPE,
21 FST_ERR_CTP_ALREADY_PRESENT,
22 FST_ERR_CTP_INVALID_HANDLER,
23 FST_ERR_CTP_INVALID_PARSE_TYPE,
24 FST_ERR_CTP_BODY_TOO_LARGE,
25 FST_ERR_CTP_INVALID_MEDIA_TYPE,
26 FST_ERR_CTP_INVALID_CONTENT_LENGTH,
27 FST_ERR_CTP_EMPTY_JSON_BODY,
28 FST_ERR_CTP_INSTANCE_ALREADY_STARTED
29} = require('./errors')
30
31function ContentTypeParser (bodyLimit, onProtoPoisoning, onConstructorPoisoning) {
32 this[kDefaultJsonParse] = getDefaultJsonParser(onProtoPoisoning, onConstructorPoisoning)
33 // using a map instead of a plain object to avoid prototype hijack attacks
34 this.customParsers = new Map()
35 this.customParsers.set('application/json', new Parser(true, false, bodyLimit, this[kDefaultJsonParse]))
36 this.customParsers.set('text/plain', new Parser(true, false, bodyLimit, defaultPlainTextParser))
37 this.parserList = [new ParserListItem('application/json'), new ParserListItem('text/plain')]
38 this.parserRegExpList = []
39 this.cache = new Fifo(100)
40}
41
42ContentTypeParser.prototype.add = function (contentType, opts, parserFn) {
43 const contentTypeIsString = typeof contentType === 'string'
44
45 if (!contentTypeIsString && !(contentType instanceof RegExp)) throw new FST_ERR_CTP_INVALID_TYPE()
46 if (contentTypeIsString && contentType.length === 0) throw new FST_ERR_CTP_EMPTY_TYPE()
47 if (typeof parserFn !== 'function') throw new FST_ERR_CTP_INVALID_HANDLER()
48
49 if (this.existingParser(contentType)) {
50 throw new FST_ERR_CTP_ALREADY_PRESENT(contentType)
51 }
52
53 if (opts.parseAs !== undefined) {
54 if (opts.parseAs !== 'string' && opts.parseAs !== 'buffer') {
55 throw new FST_ERR_CTP_INVALID_PARSE_TYPE(opts.parseAs)
56 }
57 }
58
59 const parser = new Parser(
60 opts.parseAs === 'string',
61 opts.parseAs === 'buffer',
62 opts.bodyLimit,
63 parserFn
64 )
65
66 if (contentTypeIsString && contentType === '*') {
67 this.customParsers.set('', parser)
68 } else {
69 if (contentTypeIsString) {
70 this.parserList.unshift(new ParserListItem(contentType))
71 } else {
72 contentType.isEssence = contentType.source.indexOf(';') === -1
73 this.parserRegExpList.unshift(contentType)
74 }
75 this.customParsers.set(contentType.toString(), parser)
76 }
77}
78
79ContentTypeParser.prototype.hasParser = function (contentType) {
80 return this.customParsers.has(typeof contentType === 'string' ? contentType : contentType.toString())
81}
82
83ContentTypeParser.prototype.existingParser = function (contentType) {
84 if (contentType === 'application/json' && this.customParsers.has(contentType)) {
85 return this.customParsers.get(contentType).fn !== this[kDefaultJsonParse]
86 }
87 if (contentType === 'text/plain' && this.customParsers.has(contentType)) {
88 return this.customParsers.get(contentType).fn !== defaultPlainTextParser
89 }
90
91 return this.hasParser(contentType)
92}
93
94ContentTypeParser.prototype.getParser = function (contentType) {
95 if (this.hasParser(contentType)) {
96 return this.customParsers.get(contentType)
97 }
98
99 const parser = this.cache.get(contentType)
100 if (parser !== undefined) return parser
101
102 const parsed = safeParseContentType(contentType)
103
104 // dummyContentType always the same object
105 // we can use === for the comparison and return early
106 if (parsed === defaultContentType) {
107 return this.customParsers.get('')
108 }
109
110 // eslint-disable-next-line no-var
111 for (var i = 0; i !== this.parserList.length; ++i) {
112 const parserListItem = this.parserList[i]
113 if (compareContentType(parsed, parserListItem)) {
114 const parser = this.customParsers.get(parserListItem.name)
115 // we set request content-type in cache to reduce parsing of MIME type
116 this.cache.set(contentType, parser)
117 return parser
118 }
119 }
120
121 // eslint-disable-next-line no-var
122 for (var j = 0; j !== this.parserRegExpList.length; ++j) {
123 const parserRegExp = this.parserRegExpList[j]
124 if (compareRegExpContentType(contentType, parsed.type, parserRegExp)) {
125 const parser = this.customParsers.get(parserRegExp.toString())
126 // we set request content-type in cache to reduce parsing of MIME type
127 this.cache.set(contentType, parser)
128 return parser
129 }
130 }
131
132 return this.customParsers.get('')
133}
134
135ContentTypeParser.prototype.removeAll = function () {
136 this.customParsers = new Map()
137 this.parserRegExpList = []
138 this.parserList = []
139 this.cache = new Fifo(100)
140}
141
142ContentTypeParser.prototype.remove = function (contentType) {
143 if (!(typeof contentType === 'string' || contentType instanceof RegExp)) throw new FST_ERR_CTP_INVALID_TYPE()
144
145 const removed = this.customParsers.delete(contentType.toString())
146
147 const parsers = typeof contentType === 'string' ? this.parserList : this.parserRegExpList
148
149 const idx = parsers.findIndex(ct => ct.toString() === contentType.toString())
150
151 if (idx > -1) {
152 parsers.splice(idx, 1)
153 }
154
155 return removed || idx > -1
156}
157
158ContentTypeParser.prototype.run = function (contentType, handler, request, reply) {
159 const parser = this.getParser(contentType)
160
161 if (parser === undefined) {
162 if (request.is404) {
163 handler(request, reply)
164 } else {
165 reply.send(new FST_ERR_CTP_INVALID_MEDIA_TYPE(contentType || undefined))
166 }
167
168 // Early return to avoid allocating an AsyncResource if it's not needed
169 return
170 }
171
172 const resource = new AsyncResource('content-type-parser:run', request)
173
174 if (parser.asString === true || parser.asBuffer === true) {
175 rawBody(
176 request,
177 reply,
178 reply[kRouteContext]._parserOptions,
179 parser,
180 done
181 )
182 } else {
183 const result = parser.fn(request, request[kRequestPayloadStream], done)
184
185 if (result && typeof result.then === 'function') {
186 result.then(body => done(null, body), done)
187 }
188 }
189
190 function done (error, body) {
191 // We cannot use resource.bind() because it is broken in node v12 and v14
192 resource.runInAsyncScope(() => {
193 resource.emitDestroy()
194 if (error) {
195 reply[kReplyIsError] = true
196 reply.send(error)
197 } else {
198 request.body = body
199 handler(request, reply)
200 }
201 })
202 }
203}
204
205function rawBody (request, reply, options, parser, done) {
206 const asString = parser.asString
207 const limit = options.limit === null ? parser.bodyLimit : options.limit
208 const contentLength = request.headers['content-length'] === undefined
209 ? NaN
210 : Number(request.headers['content-length'])
211
212 if (contentLength > limit) {
213 // We must close the connection as the client is going
214 // to send this data anyway
215 reply.header('connection', 'close')
216 reply.send(new FST_ERR_CTP_BODY_TOO_LARGE())
217 return
218 }
219
220 let receivedLength = 0
221 let body = asString === true ? '' : []
222
223 const payload = request[kRequestPayloadStream] || request.raw
224
225 if (asString === true) {
226 payload.setEncoding('utf8')
227 }
228
229 payload.on('data', onData)
230 payload.on('end', onEnd)
231 payload.on('error', onEnd)
232 payload.resume()
233
234 function onData (chunk) {
235 receivedLength += chunk.length
236 const { receivedEncodedLength = 0 } = payload
237 // The resulting body length must not exceed bodyLimit (see "zip bomb").
238 // The case when encoded length is larger than received length is rather theoretical,
239 // unless the stream returned by preParsing hook is broken and reports wrong value.
240 if (receivedLength > limit || receivedEncodedLength > limit) {
241 payload.removeListener('data', onData)
242 payload.removeListener('end', onEnd)
243 payload.removeListener('error', onEnd)
244 reply.send(new FST_ERR_CTP_BODY_TOO_LARGE())
245 return
246 }
247
248 if (asString === true) {
249 body += chunk
250 } else {
251 body.push(chunk)
252 }
253 }
254
255 function onEnd (err) {
256 payload.removeListener('data', onData)
257 payload.removeListener('end', onEnd)
258 payload.removeListener('error', onEnd)
259
260 if (err !== undefined) {
261 if (!(typeof err.statusCode === 'number' && err.statusCode >= 400)) {
262 err.statusCode = 400
263 }
264 reply[kReplyIsError] = true
265 reply.code(err.statusCode).send(err)
266 return
267 }
268
269 if (asString === true) {
270 receivedLength = Buffer.byteLength(body)
271 }
272
273 if (!Number.isNaN(contentLength) && (payload.receivedEncodedLength || receivedLength) !== contentLength) {
274 reply.header('connection', 'close')
275 reply.send(new FST_ERR_CTP_INVALID_CONTENT_LENGTH())
276 return
277 }
278
279 if (asString === false) {
280 body = Buffer.concat(body)
281 }
282
283 const result = parser.fn(request, body, done)
284 if (result && typeof result.then === 'function') {
285 result.then(body => done(null, body), done)
286 }
287 }
288}
289
290function getDefaultJsonParser (onProtoPoisoning, onConstructorPoisoning) {
291 return defaultJsonParser
292
293 function defaultJsonParser (req, body, done) {
294 if (body === '' || body == null || (Buffer.isBuffer(body) && body.length === 0)) {
295 return done(new FST_ERR_CTP_EMPTY_JSON_BODY(), undefined)
296 }
297 let json
298 try {
299 json = secureJson.parse(body, { protoAction: onProtoPoisoning, constructorAction: onConstructorPoisoning })
300 } catch (err) {
301 err.statusCode = 400
302 return done(err, undefined)
303 }
304 done(null, json)
305 }
306}
307
308function defaultPlainTextParser (req, body, done) {
309 done(null, body)
310}
311
312function Parser (asString, asBuffer, bodyLimit, fn) {
313 this.asString = asString
314 this.asBuffer = asBuffer
315 this.bodyLimit = bodyLimit
316 this.fn = fn
317}
318
319function buildContentTypeParser (c) {
320 const contentTypeParser = new ContentTypeParser()
321 contentTypeParser[kDefaultJsonParse] = c[kDefaultJsonParse]
322 contentTypeParser.customParsers = new Map(c.customParsers.entries())
323 contentTypeParser.parserList = c.parserList.slice()
324 contentTypeParser.parserRegExpList = c.parserRegExpList.slice()
325 return contentTypeParser
326}
327
328function addContentTypeParser (contentType, opts, parser) {
329 if (this[kState].started) {
330 throw new FST_ERR_CTP_INSTANCE_ALREADY_STARTED('addContentTypeParser')
331 }
332
333 if (typeof opts === 'function') {
334 parser = opts
335 opts = {}
336 }
337
338 if (!opts) opts = {}
339 if (!opts.bodyLimit) opts.bodyLimit = this[kBodyLimit]
340
341 if (Array.isArray(contentType)) {
342 contentType.forEach((type) => this[kContentTypeParser].add(type, opts, parser))
343 } else {
344 this[kContentTypeParser].add(contentType, opts, parser)
345 }
346
347 return this
348}
349
350function hasContentTypeParser (contentType) {
351 return this[kContentTypeParser].hasParser(contentType)
352}
353
354function removeContentTypeParser (contentType) {
355 if (this[kState].started) {
356 throw new FST_ERR_CTP_INSTANCE_ALREADY_STARTED('removeContentTypeParser')
357 }
358
359 if (Array.isArray(contentType)) {
360 for (const type of contentType) {
361 this[kContentTypeParser].remove(type)
362 }
363 } else {
364 this[kContentTypeParser].remove(contentType)
365 }
366}
367
368function removeAllContentTypeParsers () {
369 if (this[kState].started) {
370 throw new FST_ERR_CTP_INSTANCE_ALREADY_STARTED('removeAllContentTypeParsers')
371 }
372
373 this[kContentTypeParser].removeAll()
374}
375
376function compareContentType (contentType, parserListItem) {
377 if (parserListItem.isEssence) {
378 // we do essence check
379 return contentType.type.indexOf(parserListItem) !== -1
380 } else {
381 // when the content-type includes parameters
382 // we do a full-text search
383 // reject essence content-type before checking parameters
384 if (contentType.type.indexOf(parserListItem.type) === -1) return false
385 for (const key of parserListItem.parameterKeys) {
386 // reject when missing parameters
387 if (!(key in contentType.parameters)) return false
388 // reject when parameters do not match
389 if (contentType.parameters[key] !== parserListItem.parameters[key]) return false
390 }
391 return true
392 }
393}
394
395function compareRegExpContentType (contentType, essenceMIMEType, regexp) {
396 if (regexp.isEssence) {
397 // we do essence check
398 return regexp.test(essenceMIMEType)
399 } else {
400 // when the content-type includes parameters
401 // we do a full-text match
402 return regexp.test(contentType)
403 }
404}
405
406function ParserListItem (contentType) {
407 this.name = contentType
408 // we pre-calculate all the needed information
409 // before content-type comparison
410 const parsed = safeParseContentType(contentType)
411 this.isEssence = contentType.indexOf(';') === -1
412 // we should not allow empty string for parser list item
413 // because it would become a match-all handler
414 if (this.isEssence === false && parsed.type === '') {
415 // handle semicolon or empty string
416 const tmp = contentType.split(';', 1)[0]
417 this.type = tmp === '' ? contentType : tmp
418 } else {
419 this.type = parsed.type
420 }
421 this.parameters = parsed.parameters
422 this.parameterKeys = Object.keys(parsed.parameters)
423}
424
425// used in ContentTypeParser.remove
426ParserListItem.prototype.toString = function () {
427 return this.name
428}
429
430module.exports = ContentTypeParser
431module.exports.helpers = {
432 buildContentTypeParser,
433 addContentTypeParser,
434 hasContentTypeParser,
435 removeContentTypeParser,
436 removeAllContentTypeParsers
437}
438module.exports.defaultParsers = {
439 getDefaultJsonParser,
440 defaultTextParser: defaultPlainTextParser
441}
442module.exports[kTestInternals] = { rawBody }