CoolFace
Apppublic

strong-tie/inbound-calls

sourceHugging Faceupdated 2y agoView on Hugging Face
0likes
contentTypeParser.js411 linesDownload Raw Back to lib
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