strong-tie/inbound-calls
0
1'use strict'2 3const { AsyncResource } = require('node:async_hooks')4const { FifoMap: Fifo } = require('toad-cache')5const secureJson = require('secure-json-parse')6const {7 kDefaultJsonParse,8 kContentTypeParser,9 kBodyLimit,10 kRequestPayloadStream,11 kState,12 kTestInternals,13 kReplyIsError,14 kRouteContext15} = require('./symbols')16 17const {18 FST_ERR_CTP_INVALID_TYPE,19 FST_ERR_CTP_EMPTY_TYPE,20 FST_ERR_CTP_ALREADY_PRESENT,21 FST_ERR_CTP_INVALID_HANDLER,22 FST_ERR_CTP_INVALID_PARSE_TYPE,23 FST_ERR_CTP_BODY_TOO_LARGE,24 FST_ERR_CTP_INVALID_MEDIA_TYPE,25 FST_ERR_CTP_INVALID_CONTENT_LENGTH,26 FST_ERR_CTP_EMPTY_JSON_BODY,27 FST_ERR_CTP_INSTANCE_ALREADY_STARTED28} = require('./errors')29const { FSTSEC001 } = require('./warnings')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 attacks34 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 = ['application/json', '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) {46 contentType = contentType.trim().toLowerCase()47 if (contentType.length === 0) throw new FST_ERR_CTP_EMPTY_TYPE()48 } else if (!(contentType instanceof RegExp)) {49 throw new FST_ERR_CTP_INVALID_TYPE()50 }51 52 if (typeof parserFn !== 'function') {53 throw new FST_ERR_CTP_INVALID_HANDLER()54 }55 56 if (this.existingParser(contentType)) {57 throw new FST_ERR_CTP_ALREADY_PRESENT(contentType)58 }59 60 if (opts.parseAs !== undefined) {61 if (opts.parseAs !== 'string' && opts.parseAs !== 'buffer') {62 throw new FST_ERR_CTP_INVALID_PARSE_TYPE(opts.parseAs)63 }64 }65 66 const parser = new Parser(67 opts.parseAs === 'string',68 opts.parseAs === 'buffer',69 opts.bodyLimit,70 parserFn71 )72 73 if (contentType === '*') {74 this.customParsers.set('', parser)75 } else {76 if (contentTypeIsString) {77 this.parserList.unshift(contentType)78 this.customParsers.set(contentType, parser)79 } else {80 validateRegExp(contentType)81 this.parserRegExpList.unshift(contentType)82 this.customParsers.set(contentType.toString(), parser)83 }84 }85}86 87ContentTypeParser.prototype.hasParser = function (contentType) {88 if (typeof contentType === 'string') {89 contentType = contentType.trim().toLowerCase()90 } else {91 if (!(contentType instanceof RegExp)) throw new FST_ERR_CTP_INVALID_TYPE()92 contentType = contentType.toString()93 }94 95 return this.customParsers.has(contentType)96}97 98ContentTypeParser.prototype.existingParser = function (contentType) {99 if (contentType === 'application/json' && this.customParsers.has(contentType)) {100 return this.customParsers.get(contentType).fn !== this[kDefaultJsonParse]101 }102 if (contentType === 'text/plain' && this.customParsers.has(contentType)) {103 return this.customParsers.get(contentType).fn !== defaultPlainTextParser104 }105 106 return this.hasParser(contentType)107}108 109ContentTypeParser.prototype.getParser = function (contentType) {110 let parser = this.customParsers.get(contentType)111 if (parser !== undefined) return parser112 parser = this.cache.get(contentType)113 if (parser !== undefined) return parser114 115 const caseInsensitiveContentType = contentType.toLowerCase()116 for (let i = 0; i !== this.parserList.length; ++i) {117 const parserListItem = this.parserList[i]118 if (119 caseInsensitiveContentType.slice(0, parserListItem.length) === parserListItem &&120 (121 caseInsensitiveContentType.length === parserListItem.length ||122 caseInsensitiveContentType.charCodeAt(parserListItem.length) === 59 /* `;` */ ||123 caseInsensitiveContentType.charCodeAt(parserListItem.length) === 32 /* ` ` */124 )125 ) {126 parser = this.customParsers.get(parserListItem)127 this.cache.set(contentType, parser)128 return parser129 }130 }131 132 for (let j = 0; j !== this.parserRegExpList.length; ++j) {133 const parserRegExp = this.parserRegExpList[j]134 if (parserRegExp.test(contentType)) {135 parser = this.customParsers.get(parserRegExp.toString())136 this.cache.set(contentType, parser)137 return parser138 }139 }140 141 return this.customParsers.get('')142}143 144ContentTypeParser.prototype.removeAll = function () {145 this.customParsers = new Map()146 this.parserRegExpList = []147 this.parserList = []148 this.cache = new Fifo(100)149}150 151ContentTypeParser.prototype.remove = function (contentType) {152 let parsers153 154 if (typeof contentType === 'string') {155 contentType = contentType.trim().toLowerCase()156 parsers = this.parserList157 } else {158 if (!(contentType instanceof RegExp)) throw new FST_ERR_CTP_INVALID_TYPE()159 contentType = contentType.toString()160 parsers = this.parserRegExpList161 }162 163 const removed = this.customParsers.delete(contentType)164 const idx = parsers.findIndex(ct => ct.toString() === contentType)165 166 if (idx > -1) {167 parsers.splice(idx, 1)168 }169 170 return removed || idx > -1171}172 173ContentTypeParser.prototype.run = function (contentType, handler, request, reply) {174 const parser = this.getParser(contentType)175 176 if (parser === undefined) {177 if (request.is404) {178 handler(request, reply)179 } else {180 reply.send(new FST_ERR_CTP_INVALID_MEDIA_TYPE(contentType || undefined))181 }182 183 // Early return to avoid allocating an AsyncResource if it's not needed184 return185 }186 187 const resource = new AsyncResource('content-type-parser:run', request)188 189 if (parser.asString === true || parser.asBuffer === true) {190 rawBody(191 request,192 reply,193 reply[kRouteContext]._parserOptions,194 parser,195 done196 )197 } else {198 const result = parser.fn(request, request[kRequestPayloadStream], done)199 200 if (typeof result?.then === 'function') {201 result.then(body => done(null, body), done)202 }203 }204 205 function done (error, body) {206 // We cannot use resource.bind() because it is broken in node v12 and v14207 resource.runInAsyncScope(() => {208 resource.emitDestroy()209 if (error) {210 reply[kReplyIsError] = true211 reply.send(error)212 } else {213 request.body = body214 handler(request, reply)215 }216 })217 }218}219 220function rawBody (request, reply, options, parser, done) {221 const asString = parser.asString222 const limit = options.limit === null ? parser.bodyLimit : options.limit223 const contentLength = Number(request.headers['content-length'])224 225 if (contentLength > limit) {226 // We must close the connection as the client is going227 // to send this data anyway228 reply.header('connection', 'close')229 reply.send(new FST_ERR_CTP_BODY_TOO_LARGE())230 return231 }232 233 let receivedLength = 0234 let body = asString === true ? '' : []235 236 const payload = request[kRequestPayloadStream] || request.raw237 238 if (asString === true) {239 payload.setEncoding('utf8')240 }241 242 payload.on('data', onData)243 payload.on('end', onEnd)244 payload.on('error', onEnd)245 payload.resume()246 247 function onData (chunk) {248 receivedLength += chunk.length249 const { receivedEncodedLength = 0 } = payload250 // The resulting body length must not exceed bodyLimit (see "zip bomb").251 // The case when encoded length is larger than received length is rather theoretical,252 // unless the stream returned by preParsing hook is broken and reports wrong value.253 if (receivedLength > limit || receivedEncodedLength > limit) {254 payload.removeListener('data', onData)255 payload.removeListener('end', onEnd)256 payload.removeListener('error', onEnd)257 reply.send(new FST_ERR_CTP_BODY_TOO_LARGE())258 return259 }260 261 if (asString === true) {262 body += chunk263 } else {264 body.push(chunk)265 }266 }267 268 function onEnd (err) {269 payload.removeListener('data', onData)270 payload.removeListener('end', onEnd)271 payload.removeListener('error', onEnd)272 273 if (err !== undefined) {274 if (!(typeof err.statusCode === 'number' && err.statusCode >= 400)) {275 err.statusCode = 400276 }277 reply[kReplyIsError] = true278 reply.code(err.statusCode).send(err)279 return280 }281 282 if (asString === true) {283 receivedLength = Buffer.byteLength(body)284 }285 286 if (!Number.isNaN(contentLength) && (payload.receivedEncodedLength || receivedLength) !== contentLength) {287 reply.header('connection', 'close')288 reply.send(new FST_ERR_CTP_INVALID_CONTENT_LENGTH())289 return290 }291 292 if (asString === false) {293 body = Buffer.concat(body)294 }295 296 const result = parser.fn(request, body, done)297 if (result && typeof result.then === 'function') {298 result.then(body => done(null, body), done)299 }300 }301}302 303function getDefaultJsonParser (onProtoPoisoning, onConstructorPoisoning) {304 return defaultJsonParser305 306 function defaultJsonParser (req, body, done) {307 if (body === '' || body == null || (Buffer.isBuffer(body) && body.length === 0)) {308 return done(new FST_ERR_CTP_EMPTY_JSON_BODY(), undefined)309 }310 let json311 try {312 json = secureJson.parse(body, { protoAction: onProtoPoisoning, constructorAction: onConstructorPoisoning })313 } catch (err) {314 err.statusCode = 400315 return done(err, undefined)316 }317 done(null, json)318 }319}320 321function defaultPlainTextParser (req, body, done) {322 done(null, body)323}324 325function Parser (asString, asBuffer, bodyLimit, fn) {326 this.asString = asString327 this.asBuffer = asBuffer328 this.bodyLimit = bodyLimit329 this.fn = fn330}331 332function buildContentTypeParser (c) {333 const contentTypeParser = new ContentTypeParser()334 contentTypeParser[kDefaultJsonParse] = c[kDefaultJsonParse]335 contentTypeParser.customParsers = new Map(c.customParsers.entries())336 contentTypeParser.parserList = c.parserList.slice()337 contentTypeParser.parserRegExpList = c.parserRegExpList.slice()338 return contentTypeParser339}340 341function addContentTypeParser (contentType, opts, parser) {342 if (this[kState].started) {343 throw new FST_ERR_CTP_INSTANCE_ALREADY_STARTED('addContentTypeParser')344 }345 346 if (typeof opts === 'function') {347 parser = opts348 opts = {}349 }350 351 if (!opts) opts = {}352 if (!opts.bodyLimit) opts.bodyLimit = this[kBodyLimit]353 354 if (Array.isArray(contentType)) {355 contentType.forEach((type) => this[kContentTypeParser].add(type, opts, parser))356 } else {357 this[kContentTypeParser].add(contentType, opts, parser)358 }359 360 return this361}362 363function hasContentTypeParser (contentType) {364 return this[kContentTypeParser].hasParser(contentType)365}366 367function removeContentTypeParser (contentType) {368 if (this[kState].started) {369 throw new FST_ERR_CTP_INSTANCE_ALREADY_STARTED('removeContentTypeParser')370 }371 372 if (Array.isArray(contentType)) {373 for (const type of contentType) {374 this[kContentTypeParser].remove(type)375 }376 } else {377 this[kContentTypeParser].remove(contentType)378 }379}380 381function removeAllContentTypeParsers () {382 if (this[kState].started) {383 throw new FST_ERR_CTP_INSTANCE_ALREADY_STARTED('removeAllContentTypeParsers')384 }385 386 this[kContentTypeParser].removeAll()387}388 389function validateRegExp (regexp) {390 // RegExp should either start with ^ or include ;?391 // It can ensure the user is properly detect the essence392 // MIME types.393 if (regexp.source[0] !== '^' && regexp.source.includes(';?') === false) {394 FSTSEC001(regexp.source)395 }396}397 398module.exports = ContentTypeParser399module.exports.helpers = {400 buildContentTypeParser,401 addContentTypeParser,402 hasContentTypeParser,403 removeContentTypeParser,404 removeAllContentTypeParsers405}406module.exports.defaultParsers = {407 getDefaultJsonParser,408 defaultTextParser: defaultPlainTextParser409}410module.exports[kTestInternals] = { rawBody }411 