UNPKG

fastify

Version:

Fast and low overhead web framework, for Node.js

443 lines (375 loc) 13.5 kB
'use strict' const { AsyncResource } = require('node:async_hooks') const { FifoMap: Fifo } = require('toad-cache') const { safeParse: safeParseContentType, defaultContentType } = require('fast-content-type-parse') const secureJson = require('secure-json-parse') const { kDefaultJsonParse, kContentTypeParser, kBodyLimit, kRequestPayloadStream, kState, kTestInternals, kReplyIsError, kRouteContext } = require('./symbols') const { FST_ERR_CTP_INVALID_TYPE, FST_ERR_CTP_EMPTY_TYPE, FST_ERR_CTP_ALREADY_PRESENT, FST_ERR_CTP_INVALID_HANDLER, FST_ERR_CTP_INVALID_PARSE_TYPE, FST_ERR_CTP_BODY_TOO_LARGE, FST_ERR_CTP_INVALID_MEDIA_TYPE, FST_ERR_CTP_INVALID_CONTENT_LENGTH, FST_ERR_CTP_EMPTY_JSON_BODY, FST_ERR_CTP_INSTANCE_ALREADY_STARTED } = require('./errors') function ContentTypeParser (bodyLimit, onProtoPoisoning, onConstructorPoisoning) { this[kDefaultJsonParse] = getDefaultJsonParser(onProtoPoisoning, onConstructorPoisoning) // using a map instead of a plain object to avoid prototype hijack attacks this.customParsers = new Map() this.customParsers.set('application/json', new Parser(true, false, bodyLimit, this[kDefaultJsonParse])) this.customParsers.set('text/plain', new Parser(true, false, bodyLimit, defaultPlainTextParser)) this.parserList = [new ParserListItem('application/json'), new ParserListItem('text/plain')] this.parserRegExpList = [] this.cache = new Fifo(100) } ContentTypeParser.prototype.add = function (contentType, opts, parserFn) { const contentTypeIsString = typeof contentType === 'string' if (!contentTypeIsString && !(contentType instanceof RegExp)) throw new FST_ERR_CTP_INVALID_TYPE() if (contentTypeIsString && contentType.length === 0) throw new FST_ERR_CTP_EMPTY_TYPE() if (typeof parserFn !== 'function') throw new FST_ERR_CTP_INVALID_HANDLER() if (this.existingParser(contentType)) { throw new FST_ERR_CTP_ALREADY_PRESENT(contentType) } if (opts.parseAs !== undefined) { if (opts.parseAs !== 'string' && opts.parseAs !== 'buffer') { throw new FST_ERR_CTP_INVALID_PARSE_TYPE(opts.parseAs) } } const parser = new Parser( opts.parseAs === 'string', opts.parseAs === 'buffer', opts.bodyLimit, parserFn ) if (contentTypeIsString && contentType === '*') { this.customParsers.set('', parser) } else { if (contentTypeIsString) { this.parserList.unshift(new ParserListItem(contentType)) } else { contentType.isEssence = contentType.source.indexOf(';') === -1 this.parserRegExpList.unshift(contentType) } this.customParsers.set(contentType.toString(), parser) } } ContentTypeParser.prototype.hasParser = function (contentType) { return this.customParsers.has(typeof contentType === 'string' ? contentType : contentType.toString()) } ContentTypeParser.prototype.existingParser = function (contentType) { if (contentType === 'application/json' && this.customParsers.has(contentType)) { return this.customParsers.get(contentType).fn !== this[kDefaultJsonParse] } if (contentType === 'text/plain' && this.customParsers.has(contentType)) { return this.customParsers.get(contentType).fn !== defaultPlainTextParser } return this.hasParser(contentType) } ContentTypeParser.prototype.getParser = function (contentType) { if (this.hasParser(contentType)) { return this.customParsers.get(contentType) } const parser = this.cache.get(contentType) if (parser !== undefined) return parser const parsed = safeParseContentType(contentType) // dummyContentType always the same object // we can use === for the comparison and return early if (parsed === defaultContentType) { return this.customParsers.get('') } // eslint-disable-next-line no-var for (var i = 0; i !== this.parserList.length; ++i) { const parserListItem = this.parserList[i] if (compareContentType(parsed, parserListItem)) { const parser = this.customParsers.get(parserListItem.name) // we set request content-type in cache to reduce parsing of MIME type this.cache.set(contentType, parser) return parser } } // eslint-disable-next-line no-var for (var j = 0; j !== this.parserRegExpList.length; ++j) { const parserRegExp = this.parserRegExpList[j] if (compareRegExpContentType(contentType, parsed.type, parserRegExp)) { const parser = this.customParsers.get(parserRegExp.toString()) // we set request content-type in cache to reduce parsing of MIME type this.cache.set(contentType, parser) return parser } } return this.customParsers.get('') } ContentTypeParser.prototype.removeAll = function () { this.customParsers = new Map() this.parserRegExpList = [] this.parserList = [] this.cache = new Fifo(100) } ContentTypeParser.prototype.remove = function (contentType) { if (!(typeof contentType === 'string' || contentType instanceof RegExp)) throw new FST_ERR_CTP_INVALID_TYPE() const removed = this.customParsers.delete(contentType.toString()) const parsers = typeof contentType === 'string' ? this.parserList : this.parserRegExpList const idx = parsers.findIndex(ct => ct.toString() === contentType.toString()) if (idx > -1) { parsers.splice(idx, 1) } return removed || idx > -1 } ContentTypeParser.prototype.run = function (contentType, handler, request, reply) { const parser = this.getParser(contentType) if (parser === undefined) { if (request.is404) { handler(request, reply) } else { reply.send(new FST_ERR_CTP_INVALID_MEDIA_TYPE(contentType || undefined)) } // Early return to avoid allocating an AsyncResource if it's not needed return } const resource = new AsyncResource('content-type-parser:run', request) if (parser.asString === true || parser.asBuffer === true) { rawBody( request, reply, reply[kRouteContext]._parserOptions, parser, done ) } else { const result = parser.fn(request, request[kRequestPayloadStream], done) if (result && typeof result.then === 'function') { result.then(body => done(null, body), done) } } function done (error, body) { // We cannot use resource.bind() because it is broken in node v12 and v14 resource.runInAsyncScope(() => { resource.emitDestroy() if (error) { reply[kReplyIsError] = true reply.send(error) } else { request.body = body handler(request, reply) } }) } } function rawBody (request, reply, options, parser, done) { const asString = parser.asString const limit = options.limit === null ? parser.bodyLimit : options.limit const contentLength = request.headers['content-length'] === undefined ? NaN : Number(request.headers['content-length']) if (contentLength > limit) { // We must close the connection as the client is going // to send this data anyway reply.header('connection', 'close') reply.send(new FST_ERR_CTP_BODY_TOO_LARGE()) return } let receivedLength = 0 let body = asString === true ? '' : [] const payload = request[kRequestPayloadStream] || request.raw if (asString === true) { payload.setEncoding('utf8') } payload.on('data', onData) payload.on('end', onEnd) payload.on('error', onEnd) payload.resume() function onData (chunk) { receivedLength += chunk.length const { receivedEncodedLength = 0 } = payload // The resulting body length must not exceed bodyLimit (see "zip bomb"). // The case when encoded length is larger than received length is rather theoretical, // unless the stream returned by preParsing hook is broken and reports wrong value. if (receivedLength > limit || receivedEncodedLength > limit) { payload.removeListener('data', onData) payload.removeListener('end', onEnd) payload.removeListener('error', onEnd) reply.send(new FST_ERR_CTP_BODY_TOO_LARGE()) return } if (asString === true) { body += chunk } else { body.push(chunk) } } function onEnd (err) { payload.removeListener('data', onData) payload.removeListener('end', onEnd) payload.removeListener('error', onEnd) if (err !== undefined) { if (!(typeof err.statusCode === 'number' && err.statusCode >= 400)) { err.statusCode = 400 } reply[kReplyIsError] = true reply.code(err.statusCode).send(err) return } if (asString === true) { receivedLength = Buffer.byteLength(body) } if (!Number.isNaN(contentLength) && (payload.receivedEncodedLength || receivedLength) !== contentLength) { reply.header('connection', 'close') reply.send(new FST_ERR_CTP_INVALID_CONTENT_LENGTH()) return } if (asString === false) { body = Buffer.concat(body) } const result = parser.fn(request, body, done) if (result && typeof result.then === 'function') { result.then(body => done(null, body), done) } } } function getDefaultJsonParser (onProtoPoisoning, onConstructorPoisoning) { return defaultJsonParser function defaultJsonParser (req, body, done) { if (body === '' || body == null || (Buffer.isBuffer(body) && body.length === 0)) { return done(new FST_ERR_CTP_EMPTY_JSON_BODY(), undefined) } let json try { json = secureJson.parse(body, { protoAction: onProtoPoisoning, constructorAction: onConstructorPoisoning }) } catch (err) { err.statusCode = 400 return done(err, undefined) } done(null, json) } } function defaultPlainTextParser (req, body, done) { done(null, body) } function Parser (asString, asBuffer, bodyLimit, fn) { this.asString = asString this.asBuffer = asBuffer this.bodyLimit = bodyLimit this.fn = fn } function buildContentTypeParser (c) { const contentTypeParser = new ContentTypeParser() contentTypeParser[kDefaultJsonParse] = c[kDefaultJsonParse] contentTypeParser.customParsers = new Map(c.customParsers.entries()) contentTypeParser.parserList = c.parserList.slice() contentTypeParser.parserRegExpList = c.parserRegExpList.slice() return contentTypeParser } function addContentTypeParser (contentType, opts, parser) { if (this[kState].started) { throw new FST_ERR_CTP_INSTANCE_ALREADY_STARTED('addContentTypeParser') } if (typeof opts === 'function') { parser = opts opts = {} } if (!opts) opts = {} if (!opts.bodyLimit) opts.bodyLimit = this[kBodyLimit] if (Array.isArray(contentType)) { contentType.forEach((type) => this[kContentTypeParser].add(type, opts, parser)) } else { this[kContentTypeParser].add(contentType, opts, parser) } return this } function hasContentTypeParser (contentType) { return this[kContentTypeParser].hasParser(contentType) } function removeContentTypeParser (contentType) { if (this[kState].started) { throw new FST_ERR_CTP_INSTANCE_ALREADY_STARTED('removeContentTypeParser') } if (Array.isArray(contentType)) { for (const type of contentType) { this[kContentTypeParser].remove(type) } } else { this[kContentTypeParser].remove(contentType) } } function removeAllContentTypeParsers () { if (this[kState].started) { throw new FST_ERR_CTP_INSTANCE_ALREADY_STARTED('removeAllContentTypeParsers') } this[kContentTypeParser].removeAll() } function compareContentType (contentType, parserListItem) { if (parserListItem.isEssence) { // we do essence check return contentType.type.indexOf(parserListItem) !== -1 } else { // when the content-type includes parameters // we do a full-text search // reject essence content-type before checking parameters if (contentType.type.indexOf(parserListItem.type) === -1) return false for (const key of parserListItem.parameterKeys) { // reject when missing parameters if (!(key in contentType.parameters)) return false // reject when parameters do not match if (contentType.parameters[key] !== parserListItem.parameters[key]) return false } return true } } function compareRegExpContentType (contentType, essenceMIMEType, regexp) { if (regexp.isEssence) { // we do essence check return regexp.test(essenceMIMEType) } else { // when the content-type includes parameters // we do a full-text match return regexp.test(contentType) } } function ParserListItem (contentType) { this.name = contentType // we pre-calculate all the needed information // before content-type comparison const parsed = safeParseContentType(contentType) this.isEssence = contentType.indexOf(';') === -1 // we should not allow empty string for parser list item // because it would become a match-all handler if (this.isEssence === false && parsed.type === '') { // handle semicolon or empty string const tmp = contentType.split(';', 1)[0] this.type = tmp === '' ? contentType : tmp } else { this.type = parsed.type } this.parameters = parsed.parameters this.parameterKeys = Object.keys(parsed.parameters) } // used in ContentTypeParser.remove ParserListItem.prototype.toString = function () { return this.name } module.exports = ContentTypeParser module.exports.helpers = { buildContentTypeParser, addContentTypeParser, hasContentTypeParser, removeContentTypeParser, removeAllContentTypeParsers } module.exports.defaultParsers = { getDefaultJsonParser, defaultTextParser: defaultPlainTextParser } module.exports[kTestInternals] = { rawBody }