UNPKG

fastify

Version:

Fast and low overhead web framework, for Node.js

410 lines (347 loc) 11.9 kB
'use strict' const { AsyncResource } = require('node:async_hooks') const { FifoMap: Fifo } = require('toad-cache') const { parse: secureJsonParse } = 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') const { FSTSEC001 } = require('./warnings') 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 = ['application/json', 'text/plain'] this.parserRegExpList = [] this.cache = new Fifo(100) } ContentTypeParser.prototype.add = function (contentType, opts, parserFn) { const contentTypeIsString = typeof contentType === 'string' if (contentTypeIsString) { contentType = contentType.trim().toLowerCase() if (contentType.length === 0) throw new FST_ERR_CTP_EMPTY_TYPE() } else if (!(contentType instanceof RegExp)) { throw new FST_ERR_CTP_INVALID_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 (contentType === '*') { this.customParsers.set('', parser) } else { if (contentTypeIsString) { this.parserList.unshift(contentType) this.customParsers.set(contentType, parser) } else { validateRegExp(contentType) this.parserRegExpList.unshift(contentType) this.customParsers.set(contentType.toString(), parser) } } } ContentTypeParser.prototype.hasParser = function (contentType) { if (typeof contentType === 'string') { contentType = contentType.trim().toLowerCase() } else { if (!(contentType instanceof RegExp)) throw new FST_ERR_CTP_INVALID_TYPE() contentType = contentType.toString() } return this.customParsers.has(contentType) } 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) { let parser = this.customParsers.get(contentType) if (parser !== undefined) return parser parser = this.cache.get(contentType) if (parser !== undefined) return parser const caseInsensitiveContentType = contentType.toLowerCase() for (let i = 0; i !== this.parserList.length; ++i) { const parserListItem = this.parserList[i] if ( caseInsensitiveContentType.slice(0, parserListItem.length) === parserListItem && ( caseInsensitiveContentType.length === parserListItem.length || caseInsensitiveContentType.charCodeAt(parserListItem.length) === 59 /* `;` */ || caseInsensitiveContentType.charCodeAt(parserListItem.length) === 32 /* ` ` */ ) ) { parser = this.customParsers.get(parserListItem) this.cache.set(contentType, parser) return parser } } for (let j = 0; j !== this.parserRegExpList.length; ++j) { const parserRegExp = this.parserRegExpList[j] if (parserRegExp.test(contentType)) { parser = this.customParsers.get(parserRegExp.toString()) 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) { let parsers if (typeof contentType === 'string') { contentType = contentType.trim().toLowerCase() parsers = this.parserList } else { if (!(contentType instanceof RegExp)) throw new FST_ERR_CTP_INVALID_TYPE() contentType = contentType.toString() parsers = this.parserRegExpList } const removed = this.customParsers.delete(contentType) const idx = parsers.findIndex(ct => ct.toString() === contentType) 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 = 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.length === 0) { done(new FST_ERR_CTP_EMPTY_JSON_BODY(), undefined) return } try { done(null, secureJsonParse(body, { protoAction: onProtoPoisoning, constructorAction: onConstructorPoisoning })) } catch (err) { err.statusCode = 400 done(err, undefined) } } } 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 validateRegExp (regexp) { // RegExp should either start with ^ or include ;? // It can ensure the user is properly detect the essence // MIME types. if (regexp.source[0] !== '^' && regexp.source.includes(';?') === false) { FSTSEC001(regexp.source) } } module.exports = ContentTypeParser module.exports.helpers = { buildContentTypeParser, addContentTypeParser, hasContentTypeParser, removeContentTypeParser, removeAllContentTypeParsers } module.exports.defaultParsers = { getDefaultJsonParser, defaultTextParser: defaultPlainTextParser } module.exports[kTestInternals] = { rawBody }