diff --git a/index.d.ts b/index.d.ts index d67de97241d..ba0f0909d72 100644 --- a/index.d.ts +++ b/index.d.ts @@ -21,6 +21,7 @@ export * from './types/fetch' export * from './types/file' export * from './types/filereader' export * from './types/formdata' +export * from './types/formdataparser' export * from './types/diagnostics-channel' export * from './types/websocket' export * from './types/content-type' diff --git a/index.js b/index.js index 02ac246fa45..713fa4aa394 100644 --- a/index.js +++ b/index.js @@ -141,6 +141,10 @@ if (util.nodeMajor >= 18 && hasCrypto) { const { WebSocket } = require('./lib/websocket/websocket') module.exports.WebSocket = WebSocket + + const { FormDataParser } = require('./lib/formdata/parser') + + module.exports.FormDataParser = FormDataParser } module.exports.request = makeDispatcher(api.request) diff --git a/lib/fetch/body.js b/lib/fetch/body.js index c291afa9368..6ebf9b8723e 100644 --- a/lib/fetch/body.js +++ b/lib/fetch/body.js @@ -1,6 +1,5 @@ 'use strict' -const Busboy = require('busboy') const util = require('../core/util') const { ReadableStreamFrom, @@ -21,6 +20,7 @@ const { isErrored } = require('../core/util') const { isUint8Array, isArrayBuffer } = require('util/types') const { File: UndiciFile } = require('./file') const { parseMIMEType, serializeAMimeType } = require('./dataURL') +const { FormDataParser } = require('../formdata/parser') let ReadableStream = globalThis.ReadableStream @@ -374,10 +374,10 @@ function bodyMixinMethods (instance) { const responseFormData = new FormData() - let busboy + let parser try { - busboy = Busboy({ + parser = new FormDataParser({ headers, defParamCharset: 'utf8' }) @@ -385,10 +385,10 @@ function bodyMixinMethods (instance) { throw new DOMException(`${err}`, 'AbortError') } - busboy.on('field', (name, value) => { + parser.on('field', (name, value) => { responseFormData.append(name, value) }) - busboy.on('file', (name, value, info) => { + parser.on('file', (name, value, info) => { const { filename, encoding, mimeType } = info const chunks = [] @@ -417,14 +417,14 @@ function bodyMixinMethods (instance) { } }) - const busboyResolve = new Promise((resolve, reject) => { - busboy.on('finish', resolve) - busboy.on('error', (err) => reject(new TypeError(err))) + const promise = new Promise((resolve, reject) => { + parser.on('close', () => resolve()) + parser.on('error', (err) => reject(new TypeError(err))) }) - if (this.body !== null) for await (const chunk of consumeBody(this[kState].body)) busboy.write(chunk) - busboy.end() - await busboyResolve + if (this.body !== null) for await (const chunk of consumeBody(this[kState].body)) parser.write(chunk) + parser.end() + await promise return responseFormData } else if (/application\/x-www-form-urlencoded/.test(contentType)) { diff --git a/lib/fetch/dataURL.js b/lib/fetch/dataURL.js index beefad15482..f4e98592700 100644 --- a/lib/fetch/dataURL.js +++ b/lib/fetch/dataURL.js @@ -132,11 +132,16 @@ function URLSerializer (url, excludeFragment = false) { // https://infra.spec.whatwg.org/#collect-a-sequence-of-code-points /** - * @param {(char: string) => boolean} condition - * @param {string} input + * @template {string|Buffer} T + * @param {(char: T[number]) => boolean} condition + * @param {T} input * @param {{ position: number }} position + * @returns {T} */ function collectASequenceOfCodePoints (condition, input, position) { + const inputIsString = typeof input === 'string' + const start = position.position + // 1. Let result be the empty string. let result = '' @@ -144,21 +149,30 @@ function collectASequenceOfCodePoints (condition, input, position) { // code point at position within input meets the condition condition: while (position.position < input.length && condition(input[position.position])) { // 1. Append that code point to the end of result. - result += input[position.position] + if (inputIsString) { + result += input[position.position] + } // 2. Advance position by 1. position.position++ } // 3. Return result. + + if (!inputIsString) { + return input.subarray(start, position.position) + } + return result } /** * A faster collectASequenceOfCodePoints that only works when comparing a single character. + * @template {string|Buffer} T * @param {string} char - * @param {string} input + * @param {T} input * @param {{ position: number }} position + * @returns {T} */ function collectASequenceOfCodePointsFast (char, input, position) { const idx = input.indexOf(char, position.position) diff --git a/lib/formdata/constants.js b/lib/formdata/constants.js new file mode 100644 index 00000000000..12ffbbc7942 --- /dev/null +++ b/lib/formdata/constants.js @@ -0,0 +1,41 @@ +'use strict' + +const states = { + INITIAL: 0, + BOUNDARY: 1, + READ_HEADERS: 2, + READ_BODY: 3 +} + +const headerStates = { + DEFAULT: -1, // no match + FIRST: 0, + SECOND: 1, + THIRD: 2 +} + +const chars = { + '-': 0x2D, + cr: 0x0D, + lf: 0x0A, + ':': 0x3A, + ' ': 0x20, + ';': 0x3B, + '=': 0x3D, + '"': 0x22 +} + +const emptyBuffer = Buffer.alloc(0) + +const crlfBuffer = Buffer.from([0x0D, 0x0A]) // \r\n + +const maxHeaderLength = 16 * 1024 + +module.exports = { + states, + chars, + headerStates, + emptyBuffer, + maxHeaderLength, + crlfBuffer +} diff --git a/lib/formdata/filestream.js b/lib/formdata/filestream.js new file mode 100644 index 00000000000..a6eb360c69a --- /dev/null +++ b/lib/formdata/filestream.js @@ -0,0 +1,11 @@ +'use strict' + +const { Readable } = require('stream') + +class FileStream extends Readable { + _read () {} +} + +module.exports = { + FileStream +} diff --git a/lib/formdata/parser.js b/lib/formdata/parser.js new file mode 100644 index 00000000000..19203f3a0ef --- /dev/null +++ b/lib/formdata/parser.js @@ -0,0 +1,673 @@ +'use strict' + +const { Writable } = require('stream') +const { win32: { basename } } = require('path') +const { states, chars, headerStates, emptyBuffer, maxHeaderLength, crlfBuffer } = require('./constants') +const { FileStream } = require('./filestream') +const { + collectHTTPQuotedStringLenient, + findCharacterType, + hasHeaderValue, + isWhitespace +} = require('./util') +const { TextSearch } = require('./textsearch') +const { + parseMIMEType, + collectASequenceOfCodePoints, + collectASequenceOfCodePointsFast, + stringPercentDecode +} = require('../fetch/dataURL') + +class FormDataParser extends Writable { + /** @type {Buffer[]} */ + #buffers = [] + #byteOffset = 0 + + #state = states.INITIAL + + /** @type {Buffer} */ + #boundary + + #info = { + headerState: headerStates.DEFAULT, + /** @type {FileStream|null} */ + stream: null, + body: { + chunks: [], + length: 0 + }, + complete: false, + limits: { + fieldNameSize: 0, + fieldSize: 0, + fields: 0, + fileSize: 0, + files: 0, + parts: 0, + headerPairs: 0 + }, + headers: { + attributes: {} + }, + headerEndSearch: new TextSearch(Buffer.from('\r\n\r\n')), + headerBuffersChecked: 0 + } + + #opts = {} + + constructor (opts) { + super({ + highWaterMark: opts?.highWaterMark, + autoDestroy: true, + emitClose: true + }) + + const contentType = opts.headers.get?.('content-type') ?? opts.headers['content-type'] + const mimeType = contentType ? parseMIMEType(contentType) : null + + if (mimeType === null || mimeType.essence !== 'multipart/form-data') { + this.destroy(new Error('Invalid mimetype essence.')) + return + } else if (!mimeType.parameters.has('boundary')) { + this.destroy(new Error('No boundary parameter.')) + } + + this.#boundary = Buffer.from(mimeType.parameters.get('boundary')) + + this.#opts.defCharset = opts?.defCharset ?? 'utf8' + this.#opts.defParamCharset = opts?.defParamCharset ?? 'utf8' + this.#opts.fileHwm = opts?.fileHwm + this.#opts.preservePath = !!opts?.preservePath + this.#opts.limits = { + fieldNameSize: opts?.limits?.fieldNameSize ?? 100, + fieldSize: opts?.limits?.fieldSize ?? 1000 * 1000, + fields: opts?.limits?.fields ?? Infinity, + fileSize: opts?.limits?.fileSize ?? Infinity, + files: opts?.limits?.files ?? Infinity, + parts: opts?.limits?.parts ?? Infinity, + headerPairs: opts?.limits?.headerPairs ?? 2000 + } + } + + /** + * @param {Buffer} chunk + * @param {() => void} callback + */ + _write (chunk, _, callback) { + this.#buffers.push(chunk) + this.#byteOffset += chunk.length + + this.run(callback) + } + + /** + * @param {((error?: Error | null) => void)} cb + */ + _final (cb) { + if (!this.#info.complete) { + return cb(new Error('Unexpected end of form')) + } + } + + /** + * @param {Error|null} err + * @param {((error?: Error|null) => void)} cb + */ + _destroy (err, cb) { + if (this.#info.stream) { + // Push any leftover buffers to the filestream + for (const leftover of this.#buffers) { + this.#info.stream.push(leftover) + } + + // Emit an error event on the file stream if there is one + this.#info.stream.destroy(err) + this.#info.stream = null + } + + if (this.#info.headerEndSearch.lookedAt) { + this.emit('error', new Error('Malformed part header')) + } + + cb(err) + } + + /** + * @param {() => void} callback + */ + run (callback) { + while (true) { + if (this.#state === states.INITIAL) { + if (this.#byteOffset < 2) { + return callback() + } + + const bytes = this.consume(2) + + if ( + (bytes[0] !== chars['-'] || bytes[1] !== chars['-']) && + (bytes[0] !== chars.cr || bytes[1] !== chars.lf) + ) { + this.destroy(new Error('FormData should start with -- or CRLF')) + return + } + + if (bytes[0] === chars['-']) { + this.#state = states.BOUNDARY + } + } else if (this.#state === states.BOUNDARY) { + if (this.#byteOffset < this.#boundary.length + 2) { + return callback() + } + + this.#info.skipPart = '' + + const bytes = this.consume(this.#boundary.length) + const nextBytes = this.consume(2) // \r\n OR -- + + if (!this.#boundary.equals(bytes)) { + this.destroy(new Error('Received invalid boundary')) + return + } else if (nextBytes[0] !== chars.cr || nextBytes[1] !== chars.lf) { + if (nextBytes[0] === chars['-'] && nextBytes[1] === chars['-']) { + // Done parsing form-data + + this.#info.complete = true + this.destroy() + return + } + + this.destroy(new Error('Boundary did not end with CRLF')) + return + } else if (this.#info.limits.parts >= this.#opts.limits.parts) { + this.emit('partsLimit') + this.#info.skipPart = 'partsLimit' + this.#state = states.READ_BODY + continue + } + + this.#info.limits.parts++ + this.#state = states.READ_HEADERS + } else if (this.#state === states.READ_HEADERS) { + // There can be an arbitrary amount of headers and each one has an + // arbitrary length. Therefore it's easier to read the headers, and + // then parse once we receive 2 CRLFs which marks the body's start. + + if (this.#byteOffset < crlfBuffer.length * 2) { + return callback() + } + + let i = this.#info.headerBuffersChecked + + while (!this.#info.headerEndSearch.finished && i < this.#buffers.length) { + this.#info.headerEndSearch.write(this.#buffers[i++]) + this.#info.headerBuffersChecked++ + + if (this.#info.headerEndSearch.lookedAt > maxHeaderLength) { + this.#info.skipPart = 'Malformed part header' + break + } + } + + if (!this.#info.headerEndSearch.finished) { + return callback() + } + + const consumed = this.consume(this.#info.headerEndSearch.lookedAt) + + this.#info.headerBuffersChecked = 0 + this.#info.headerEndSearch.reset() + + let index = consumed.indexOf(crlfBuffer) + let start = 0 + + while (index !== -1 && start < index) { + const buffer = consumed.subarray(start, index) + const valueIndex = buffer.indexOf(':') + + if (valueIndex === -1) { + index = consumed.indexOf(crlfBuffer, index + buffer.length) + continue + } else if (!hasHeaderValue(buffer, valueIndex + 1)) { + // HTTP/1.1 header field values can be folded onto multiple lines if the + // continuation line begins with a space or horizontal tab. + index = consumed.indexOf(crlfBuffer, index + buffer.length) + continue + } + + this.#parseRawHeaders(buffer) + + start = index + 2 + index = consumed.indexOf(crlfBuffer, start) + + if (this.#info.skipPart) { + break + } + } + + this.#state = states.READ_BODY + + const { attributes, ...headers } = this.#info.headers + + if ( + !this.#info.skipPart && + ( + headers['content-disposition'] !== 'form-data' || + !attributes.name + ) + ) { + this.#info.headers = { attributes: {} } + // https://www.rfc-editor.org/rfc/rfc7578#section-4.2 + this.#info.skipPart = 'invalid content-disposition' + } else if ( + attributes.filename !== undefined || + attributes['filename*'] !== undefined || + headers['content-type'] === 'application/octet-stream' + ) { + if (this.#info.limits.files >= this.#opts.limits.files) { + this.#info.skipPart = 'file' + this.#info.headers = { attributes: {} } + continue + } + + // End the active stream if there is one + this.#info.stream?.push(null) + this.#info.stream?.destroy() + + this.#info.stream = new FileStream({ + highWaterMark: this.#opts.fileHwm, + autoDestroy: true, + emitClose: true + }) + this.#info.stream.truncated = false + + this.emit( + 'file', + attributes.name, // name is a required attribute + this.#info.stream, + { + filename: attributes.filename ?? attributes['filename*'] ?? '', + encoding: headers['content-transfer-encoding'] ?? '7bit', + mimeType: headers['content-type'] || 'application/octet-stream' + } + ) + this.#info.headers = { attributes: {} } + } + } else if (this.#state === states.READ_BODY) { + // A part's body can contain CRLFs so they cannot be used to + // determine when the body ends. We need to check if a chunk + // (or chunks) contains the boundary to stop parsing the body. + + if (this.#byteOffset < this.#boundary.length) { + return callback() + } + + /** @type {Buffer} */ + let buffer = this.consume(this.#byteOffset) + let bufferLength = buffer.length + + while (!buffer.includes(this.#boundary)) { + // No more buffers to check + if (this.#byteOffset === 0) { + this.#buffers.unshift(buffer) + this.#byteOffset += buffer.length + buffer = undefined + return callback() + } + + const doubleDashIdx = buffer.length ? buffer.indexOf('--') : null + + if (doubleDashIdx === -1) { + // Chunk is completely useless, emit it + if (this.#info.stream !== null) { + this.#info.stream.push(buffer) + } else { + this.#info.body.chunks.push(buffer) + this.#info.body.length += buffer.length + } + + buffer = emptyBuffer + bufferLength = 0 + } else if (doubleDashIdx + this.#boundary.length < buffer.length) { + // If the double dash index is not part of the boundary and + // not a chunked piece of the boundary + if (this.#info.stream !== null) { + this.#info.stream.push(buffer) + } else { + this.#info.body.chunks.push(buffer) + this.#info.body.length += buffer.length + } + + buffer = emptyBuffer + bufferLength = 0 + } + + const next = this.#buffers.shift() + this.#byteOffset -= next.length + bufferLength += next.length + buffer = Buffer.concat([buffer, next], bufferLength) + } + + if (bufferLength === this.#boundary.length) { + this.#buffers.unshift(buffer) + this.#byteOffset += buffer.length + + this.#state = states.BOUNDARY + } else { + const idx = buffer.indexOf(this.#boundary) + const rest = buffer.subarray(idx) + const chunk = buffer.subarray(0, idx - 4) // remove \r\n-- + + // If the current part is a file + if ( + this.#info.stream !== null && + this.#info.limits.fileSize < this.#opts.limits.fileSize && + !this.#info.skipPart + ) { + const limit = this.#opts.limits.fileSize + const current = this.#info.limits.fileSize + const length = chunk.length + + let limitedChunk + + if (current + length >= limit) { + // If the limit is reached + limitedChunk = chunk.subarray(0, limit - (current + length)) + + this.#info.stream.emit('limit') // TODO: arguments? + this.#info.stream.truncated = true + } else { + limitedChunk = chunk + } + + this.#info.limits.files += 1 + this.#info.limits.fileSize += length + + this.#info.stream.push(limitedChunk) + this.#info.stream.push(null) + this.#info.stream.read() // TODO: why is this needed!?!?! + this.#info.stream.destroy() + } else if ( + this.#info.limits.fieldSize < this.#opts.limits.fieldSize && + !this.#info.skipPart && + this.listenerCount('field') > 0 + ) { + if (this.#info.limits.fields >= this.#opts.limits.fields) { + this.#info.skipPart = 'fieldsLimit' + this.emit('fieldsLimit') + + this.#state = states.BOUNDARY + + // Remove all body chunks for the current field + this.#info.body.chunks.length = 0 + this.#info.body.length = 0 + + // Add the rest of the bytes back to the buffer to parse + this.#buffers.unshift(rest) + this.#byteOffset += rest.length + + continue + } + + const { headers: { attributes, ...headers }, body } = this.#info + + body.chunks.push(chunk) + body.length += chunk.length + + let fullBody = Buffer.concat(body.chunks, body.length) + + const limit = this.#opts.limits.fieldSize + const current = this.#info.limits.fieldSize + + let valueTruncated = false + + if (current + body.length >= limit) { + // If the limit is reached + fullBody = fullBody.subarray(0, limit - (current + body.length)) + + valueTruncated = true + } + + this.#info.limits.fieldSize += body.length + + this.emit( + 'field', + attributes.name, + new TextDecoder('utf-8', { fatal: true }).decode(fullBody), + { + nameTruncated: false, + valueTruncated, + encoding: headers['content-transfer-encoding'] ?? '7bit', + mimeType: headers['content-type'] || 'text/plain' + } + ) + + this.#info.headers = { attributes: {} } + this.#info.limits.fields++ + body.chunks.length = 0 + body.length = 0 + } else if (this.#info.skipPart === 'file') { + this.emit('filesLimit') + } + + this.#buffers.unshift(rest) + this.#byteOffset += rest.length + + this.#state = states.BOUNDARY + } + } + } + } + + /** + * Take n bytes from the buffered Buffers + * @param {number} n + * @returns {Buffer|null} + */ + consume (n) { + if (n > this.#byteOffset) { + return null + } else if (n === 0) { + return emptyBuffer + } + + if (this.#buffers[0].length === n) { + this.#byteOffset -= this.#buffers[0].length + return this.#buffers.shift() + } + + const buffer = Buffer.allocUnsafe(n) + let offset = 0 + + while (offset !== n) { + const next = this.#buffers[0] + const { length } = next + + if (length + offset === n) { + buffer.set(this.#buffers.shift(), offset) + break + } else if (length + offset > n) { + buffer.set(next.subarray(0, n - offset), offset) + this.#buffers[0] = next.subarray(n - offset) + break + } else { + buffer.set(this.#buffers.shift(), offset) + offset += next.length + } + } + + this.#byteOffset -= n + + return buffer + } + + /** + * @param {Buffer} buffer + */ + #parseRawHeaders (buffer) { + // https://www.rfc-editor.org/rfc/rfc7578#section-4.8 + + const headers = this.#info.headers + const attributes = this.#info.headers.attributes + + if (buffer.length > maxHeaderLength) { + this.#info.skipPart = 'malformed part header' + this.emit('error', new Error('Malformed part header')) + + return { headers, attributes } + } + + const position = { position: 0 } + + while (position.position < buffer.length) { + const nameBuffer = collectASequenceOfCodePointsFast( + chars[':'], + buffer, + position + ) + + if (position.position >= buffer.length) { + // Invalid header; doesn't contain a delimiter + break + } else if (nameBuffer.length === 0) { + // https://www.rfc-editor.org/rfc/rfc9110.html#section-5.1-2 + // field-name = token + // tchar = "!" / "#" / "$" / "%" / "&" / "'" / "*" / "+" / "-" / "." / + // "^" / "_" / "`" / "|" / "~" / DIGIT / ALPHA + // token = 1*tchar + + // The field name MUST be at least 1 character long. + this.#info.skipPart = 'malformed part header' + this.emit('error', new Error('Malformed part header')) + break + } + + // MIME header fields in multipart bodies are required to + // consist only of 7-bit data in the US-ASCII character set + const name = nameBuffer.toString(this.#opts.defCharset).toLowerCase() + + if ( + name !== 'content-type' && + name !== 'content-disposition' && + name !== 'content-transfer-encoding' + ) { + // The multipart/form-data media type does not support any MIME header + // fields in parts other than Content-Type, Content-Disposition, and (in + // limited circumstances) Content-Transfer-Encoding. Other header + // fields MUST NOT be included and MUST be ignored. + + // Go to the next header if one is available. Headers are split by \r\n, + // so we skip all characters until the sequence is found. + collectASequenceOfCodePointsFast( + '\r\n', + buffer, + position + ) + + continue + } + + position.position++ // skip semicolon + + // Header values can contain leading whitespace, make sure + // we remove it all. + collectASequenceOfCodePoints( + (char) => isWhitespace(char), + buffer, + position + ) + + const value = collectASequenceOfCodePoints( + (char) => { + return ( + char !== chars[';'] && + (char !== chars.cr && buffer[position.position + 1] !== chars.lf) + ) + }, + buffer, + position + ) + + headers[name] = value.toString('ascii') + + // No attributes + if (position.position >= buffer.length) { + continue + } + + if (name !== 'content-disposition') { + collectASequenceOfCodePointsFast( + '\r\n', + buffer, + position + ) + } else { + // A content-disposition header can contain multiple attributes, + // separated with a semicolon in the form of name=value. + + while (position.position < buffer.length) { + position.position++ // skip ";" + + const collected = collectASequenceOfCodePoints( + (char) => { + if (char === chars.cr) { + return buffer[position.position + 1] !== chars.lf + } + + return char !== chars[';'] + }, + buffer, + position + ) + + const attribute = collected.toString(this.#opts.defParamCharset) + const equalIdx = attribute.indexOf('=') + const name = attribute.slice(0, equalIdx).trim().toLowerCase() + const value = collectHTTPQuotedStringLenient(attribute.slice(equalIdx + 1)) + + if (name === 'filename*') { + // https://www.rfc-editor.org/rfc/rfc6266#section-4.3 + + // This attribute is split into 3 parts: + // 1. The character type (ie. utf-8) + // 2. The optional language tag (ie. en or ''). This value is in single quotes. + // 3. The percent encoded name. + + const quote1 = value.indexOf('\'') + const quote2 = value.indexOf('\'', quote1 + 1) + + const characterType = findCharacterType(value.slice(0, quote1)) + const percentEncoded = value.slice(quote2 + 1) + + attributes['filename*'] = new TextDecoder(characterType).decode( + stringPercentDecode(percentEncoded) + ) + + attributes.filename ??= undefined + } else if (name === 'filename') { + // Therefore, when both + // "filename" and "filename*" are present in a single header field + // value, recipients SHOULD pick "filename*" and ignore "filename". + if (!attributes['filename*']) { + attributes[name] = this.#opts.preservePath ? value : basename(value) + } + } else { + attributes[name] = value + } + + if (buffer[position.position] === chars.cr) { + break + } + } + } + + position.position += 2 // skip \r\n + } + + return { headers, attributes } + } +} + +module.exports = { + FormDataParser +} diff --git a/lib/formdata/textsearch.js b/lib/formdata/textsearch.js new file mode 100644 index 00000000000..bccc758ef44 --- /dev/null +++ b/lib/formdata/textsearch.js @@ -0,0 +1,47 @@ +'use strict' + +class TextSearch { + /** @param {Buffer} pattern */ + constructor (pattern) { + this.pattern = pattern + + this.back = 0 + this.lookedAt = 0 + } + + /** + * @param {Buffer} chunk + */ + write (chunk) { + if (this.finished) { + return true + } + + for (const byte of chunk) { + this.lookedAt++ + + if (byte !== this.pattern[this.back]) { + this.back = 0 + } else { + if (++this.back === this.pattern.length) { + return true + } + } + } + + return this.back === this.pattern.length + } + + reset () { + this.back = 0 + this.lookedAt = 0 + } + + get finished () { + return this.back === this.pattern.length + } +} + +module.exports = { + TextSearch +} diff --git a/lib/formdata/util.js b/lib/formdata/util.js new file mode 100644 index 00000000000..e14d0ecae00 --- /dev/null +++ b/lib/formdata/util.js @@ -0,0 +1,55 @@ +'use strict' + +const { collectASequenceOfCodePointsFast } = require('../fetch/dataURL') + +/** + * Collect a string inside of quotes. Unlike + * `collectAnHTTPQuotedString` from dataURL utilities, + * this one does not remove backslashes. + * @param {string} str + */ +function collectHTTPQuotedStringLenient (str) { + if (!str.startsWith('"')) { + return str + } + + return collectASequenceOfCodePointsFast( + '"', + str, + { position: 1 } + ) +} + +function findCharacterType (type) { + switch (type) { + case 'utf8': + case 'utf-8': + return 'utf-8' + case 'latin1': + case 'ascii': + return 'latin1' + default: + return type.toLowerCase() + } +} + +function isWhitespace (char) { + return char === 0x09 || char === 0x20 || char === 0x0D || char === 0x0A +} + +function hasHeaderValue (chunk, start) { + for (let i = start; i < chunk.length; i++) { + if (!isWhitespace(chunk[i])) { + return true + } + } + + return false +} + +module.exports = { + collectHTTPQuotedStringLenient, + findCharacterType, + isWhitespace, + hasHeaderValue +} diff --git a/package.json b/package.json index 37baa25665b..4d667890de3 100644 --- a/package.json +++ b/package.json @@ -46,7 +46,8 @@ "build:wasm": "node build/wasm.js --docker", "lint": "standard | snazzy", "lint:fix": "standard --fix | snazzy", - "test": "npm run test:tap && npm run test:node-fetch && npm run test:fetch && npm run test:cookies && npm run test:wpt && npm run test:websocket && npm run test:jest && tsd", + "test": "npm run test:tap && npm run test:node-fetch && npm run test:fetch && npm run test:cookies && npm run test:busboy && npm run test:wpt && npm run test:websocket && npm run test:jest && tsd", + "test:busboy": "node scripts/verifyVersion 18 || node test/busboy/test.js", "test:cookies": "node scripts/verifyVersion 16 || tap test/cookie/*.js", "test:node-fetch": "node scripts/verifyVersion.js 16 || mocha test/node-fetch", "test:fetch": "node scripts/verifyVersion.js 16 || (npm run build:node && tap test/fetch/*.js && tap test/webidl/*.js)", @@ -71,6 +72,7 @@ "@types/node": "^18.0.3", "abort-controller": "^3.0.0", "atomic-sleep": "^1.0.0", + "busboy": "^1.6.0", "chai": "^4.3.4", "chai-as-promised": "^7.1.1", "chai-iterator": "^3.0.2", @@ -129,8 +131,5 @@ "testMatch": [ "/test/jest/**" ] - }, - "dependencies": { - "busboy": "^1.6.0" } } diff --git a/test/busboy/LICENSE b/test/busboy/LICENSE new file mode 100644 index 00000000000..290762e94f4 --- /dev/null +++ b/test/busboy/LICENSE @@ -0,0 +1,19 @@ +Copyright Brian White. All rights reserved. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to +deal in the Software without restriction, including without limitation the +rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +sell copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +IN THE SOFTWARE. \ No newline at end of file diff --git a/test/busboy/common.js b/test/busboy/common.js new file mode 100644 index 00000000000..c2ed9c47c03 --- /dev/null +++ b/test/busboy/common.js @@ -0,0 +1,105 @@ +'use strict' + +const assert = require('assert') +const { inspect } = require('util') + +const mustCallChecks = [] + +function noop () {} + +function runCallChecks (exitCode) { + if (exitCode !== 0) return + + const failed = mustCallChecks.filter((context) => { + if ('minimum' in context) { + context.messageSegment = `at least ${context.minimum}` + return context.actual < context.minimum + } + context.messageSegment = `exactly ${context.exact}` + return context.actual !== context.exact + }) + + failed.forEach((context) => { + console.error('Mismatched %s function calls. Expected %s, actual %d.', + context.name, + context.messageSegment, + context.actual) + console.error(context.stack.split('\n').slice(2).join('\n')) + }) + + if (failed.length) { process.exit(1) } +} + +function mustCall (fn, exact) { + return _mustCallInner(fn, exact, 'exact') +} + +function mustCallAtLeast (fn, minimum) { + return _mustCallInner(fn, minimum, 'minimum') +} + +function _mustCallInner (fn, criteria = 1, field) { + if (process._exiting) { throw new Error('Cannot use common.mustCall*() in process exit handler') } + + if (typeof fn === 'number') { + criteria = fn + fn = noop + } else if (fn === undefined) { + fn = noop + } + + if (typeof criteria !== 'number') { throw new TypeError(`Invalid ${field} value: ${criteria}`) } + + const context = { + [field]: criteria, + actual: 0, + stack: inspect(new Error()), + name: fn.name || '' + } + + // Add the exit listener only once to avoid listener leak warnings + if (mustCallChecks.length === 0) { process.on('exit', runCallChecks) } + + mustCallChecks.push(context) + + function wrapped (...args) { + ++context.actual + return fn.call(this, ...args) + } + // TODO: remove origFn? + wrapped.origFn = fn + + return wrapped +} + +function getCallSite (top) { + const originalStackFormatter = Error.prepareStackTrace + Error.prepareStackTrace = (_err, stack) => + `${stack[0].getFileName()}:${stack[0].getLineNumber()}` + const err = new Error() + Error.captureStackTrace(err, top) + // With the V8 Error API, the stack is not formatted until it is accessed + // eslint-disable-next-line no-unused-expressions + err.stack + Error.prepareStackTrace = originalStackFormatter + return err.stack +} + +function mustNotCall (msg) { + const callSite = getCallSite(mustNotCall) + return function mustNotCall (...args) { + args = args.map(inspect).join(', ') + const argsInfo = (args.length > 0 + ? `\ncalled with arguments: ${args}` + : '') + assert.fail( + `${msg || 'function should not have been called'} at ${callSite}` + + argsInfo) + } +} + +module.exports = { + mustCall, + mustCallAtLeast, + mustNotCall +} diff --git a/test/busboy/test-types-multipart-charsets.js b/test/busboy/test-types-multipart-charsets.js new file mode 100644 index 00000000000..d143a7654ac --- /dev/null +++ b/test/busboy/test-types-multipart-charsets.js @@ -0,0 +1,97 @@ +'use strict' + +const assert = require('assert') +const { inspect } = require('util') +const { join } = require('path') + +const { mustCall } = require(join(__dirname, 'common.js')) + +const { FormDataParser } = require('../..') +const busboy = (opts) => new FormDataParser(opts) + +const input = Buffer.from([ + '-----------------------------paZqsnEHRufoShdX6fh0lUhXBP4k', + 'Content-Disposition: form-data; ' + + 'name="upload_file_0"; filename="テスト.dat"', + 'Content-Type: application/octet-stream', + '', + 'A'.repeat(1023), + '-----------------------------paZqsnEHRufoShdX6fh0lUhXBP4k--' +].join('\r\n')) +const boundary = '---------------------------paZqsnEHRufoShdX6fh0lUhXBP4k' +const expected = [ + { + type: 'file', + name: 'upload_file_0', + data: Buffer.from('A'.repeat(1023)), + info: { + filename: 'テスト.dat', + encoding: '7bit', + mimeType: 'application/octet-stream' + }, + limited: false + } +] +const bb = busboy({ + defParamCharset: 'utf8', + headers: { + 'content-type': `multipart/form-data; boundary=${boundary}` + } +}) +const results = [] + +bb.on('field', (name, val, info) => { + results.push({ type: 'field', name, val, info }) +}) + +bb.on('file', (name, stream, info) => { + const data = [] + let nb = 0 + const file = { + type: 'file', + name, + data: null, + info, + limited: false + } + results.push(file) + stream.on('data', (d) => { + data.push(d) + nb += d.length + }).on('limit', () => { + file.limited = true + }).on('close', () => { + file.data = Buffer.concat(data, nb) + assert.strictEqual(stream.truncated, file.limited) + }).once('error', (err) => { + file.err = err.message + }) +}) + +bb.on('error', (err) => { + results.push({ error: err.message }) +}) + +bb.on('partsLimit', () => { + results.push('partsLimit') +}) + +bb.on('filesLimit', () => { + results.push('filesLimit') +}) + +bb.on('fieldsLimit', () => { + results.push('fieldsLimit') +}) + +bb.on('close', mustCall(() => { + assert.deepStrictEqual( + results, + expected, + 'Results mismatch.\n' + + `Parsed: ${inspect(results)}\n` + + `Expected: ${inspect(expected)}` + ) +})) + +bb.end(input) diff --git a/test/busboy/test-types-multipart.js b/test/busboy/test-types-multipart.js new file mode 100644 index 00000000000..0e142963fb1 --- /dev/null +++ b/test/busboy/test-types-multipart.js @@ -0,0 +1,1113 @@ +'use strict' + +const assert = require('assert') +const { inspect } = require('util') + +const { FormDataParser } = require('../..') +const busboy = (opts) => new FormDataParser(opts) + +const active = new Map() + +const tests = [ + { + source: [ + ['-----------------------------paZqsnEHRufoShdX6fh0lUhXBP4k', + 'Content-Disposition: form-data; name="file_name_0"', + '', + 'super alpha file', + '-----------------------------paZqsnEHRufoShdX6fh0lUhXBP4k', + 'Content-Disposition: form-data; name="file_name_1"', + '', + 'super beta file', + '-----------------------------paZqsnEHRufoShdX6fh0lUhXBP4k', + 'Content-Disposition: form-data; ' + + 'name="upload_file_0"; filename="1k_a.dat"', + 'Content-Type: application/octet-stream', + '', + 'A'.repeat(1023), + '-----------------------------paZqsnEHRufoShdX6fh0lUhXBP4k', + 'Content-Disposition: form-data; ' + + 'name="upload_file_1"; filename="1k_b.dat"', + 'Content-Type: application/octet-stream', + '', + 'B'.repeat(1023), + '-----------------------------paZqsnEHRufoShdX6fh0lUhXBP4k--' + ].join('\r\n') + ], + boundary: '---------------------------paZqsnEHRufoShdX6fh0lUhXBP4k', + expected: [ + { + type: 'field', + name: 'file_name_0', + val: 'super alpha file', + info: { + nameTruncated: false, + valueTruncated: false, + encoding: '7bit', + mimeType: 'text/plain' + } + }, + { + type: 'field', + name: 'file_name_1', + val: 'super beta file', + info: { + nameTruncated: false, + valueTruncated: false, + encoding: '7bit', + mimeType: 'text/plain' + } + }, + { + type: 'file', + name: 'upload_file_0', + data: Buffer.from('A'.repeat(1023)), + info: { + filename: '1k_a.dat', + encoding: '7bit', + mimeType: 'application/octet-stream' + }, + limited: false + }, + { + type: 'file', + name: 'upload_file_1', + data: Buffer.from('B'.repeat(1023)), + info: { + filename: '1k_b.dat', + encoding: '7bit', + mimeType: 'application/octet-stream' + }, + limited: false + } + ], + what: 'Fields and files' + }, + { + source: [ + ['------WebKitFormBoundaryTB2MiQ36fnSJlrhY', + 'Content-Disposition: form-data; name="cont"', + '', + 'some random content', + '------WebKitFormBoundaryTB2MiQ36fnSJlrhY', + 'Content-Disposition: form-data; name="pass"', + '', + 'some random pass', + '------WebKitFormBoundaryTB2MiQ36fnSJlrhY', + 'Content-Disposition: form-data; name=bit', + '', + '2', + '------WebKitFormBoundaryTB2MiQ36fnSJlrhY--' + ].join('\r\n') + ], + boundary: '----WebKitFormBoundaryTB2MiQ36fnSJlrhY', + expected: [ + { + type: 'field', + name: 'cont', + val: 'some random content', + info: { + nameTruncated: false, + valueTruncated: false, + encoding: '7bit', + mimeType: 'text/plain' + } + }, + { + type: 'field', + name: 'pass', + val: 'some random pass', + info: { + nameTruncated: false, + valueTruncated: false, + encoding: '7bit', + mimeType: 'text/plain' + } + }, + { + type: 'field', + name: 'bit', + val: '2', + info: { + nameTruncated: false, + valueTruncated: false, + encoding: '7bit', + mimeType: 'text/plain' + } + } + ], + what: 'Fields only' + }, + { + source: [ + '' + ], + boundary: '----WebKitFormBoundaryTB2MiQ36fnSJlrhY', + expected: [ + { error: 'Unexpected end of form' } + ], + what: 'No fields and no files' + }, + { + source: [ + ['-----------------------------paZqsnEHRufoShdX6fh0lUhXBP4k', + 'Content-Disposition: form-data; name="file_name_0"', + '', + 'super alpha file', + '-----------------------------paZqsnEHRufoShdX6fh0lUhXBP4k', + 'Content-Disposition: form-data; ' + + 'name="upload_file_0"; filename="1k_a.dat"', + 'Content-Type: application/octet-stream', + '', + 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', + '-----------------------------paZqsnEHRufoShdX6fh0lUhXBP4k--' + ].join('\r\n') + ], + boundary: '---------------------------paZqsnEHRufoShdX6fh0lUhXBP4k', + limits: { + fileSize: 13, + fieldSize: 5 + }, + expected: [ + { + type: 'field', + name: 'file_name_0', + val: 'super', + info: { + nameTruncated: false, + valueTruncated: true, + encoding: '7bit', + mimeType: 'text/plain' + } + }, + { + type: 'file', + name: 'upload_file_0', + data: Buffer.from('ABCDEFGHIJKLM'), + info: { + filename: '1k_a.dat', + encoding: '7bit', + mimeType: 'application/octet-stream' + }, + limited: true + } + ], + what: 'Fields and files (limits)' + }, + { + source: [ + ['-----------------------------paZqsnEHRufoShdX6fh0lUhXBP4k', + 'Content-Disposition: form-data; name="file_name_0"', + '', + 'super alpha file', + '-----------------------------paZqsnEHRufoShdX6fh0lUhXBP4k', + 'Content-Disposition: form-data; ' + + 'name="upload_file_0"; filename="1k_a.dat"', + 'Content-Type: application/octet-stream', + '', + 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', + '-----------------------------paZqsnEHRufoShdX6fh0lUhXBP4k--' + ].join('\r\n') + ], + boundary: '---------------------------paZqsnEHRufoShdX6fh0lUhXBP4k', + limits: { + files: 0 + }, + expected: [ + { + type: 'field', + name: 'file_name_0', + val: 'super alpha file', + info: { + nameTruncated: false, + valueTruncated: false, + encoding: '7bit', + mimeType: 'text/plain' + } + }, + 'filesLimit' + ], + what: 'Fields and files (limits: 0 files)' + }, + { + source: [ + ['-----------------------------paZqsnEHRufoShdX6fh0lUhXBP4k', + 'Content-Disposition: form-data; name="file_name_0"', + '', + 'super alpha file', + '-----------------------------paZqsnEHRufoShdX6fh0lUhXBP4k', + 'Content-Disposition: form-data; name="file_name_1"', + '', + 'super beta file', + '-----------------------------paZqsnEHRufoShdX6fh0lUhXBP4k', + 'Content-Disposition: form-data; ' + + 'name="upload_file_0"; filename="1k_a.dat"', + 'Content-Type: application/octet-stream', + '', + 'A'.repeat(1023), + '-----------------------------paZqsnEHRufoShdX6fh0lUhXBP4k', + 'Content-Disposition: form-data; ' + + 'name="upload_file_1"; filename="1k_b.dat"', + 'Content-Type: application/octet-stream', + '', + 'B'.repeat(1023), + '-----------------------------paZqsnEHRufoShdX6fh0lUhXBP4k--' + ].join('\r\n') + ], + boundary: '---------------------------paZqsnEHRufoShdX6fh0lUhXBP4k', + expected: [ + { + type: 'field', + name: 'file_name_0', + val: 'super alpha file', + info: { + nameTruncated: false, + valueTruncated: false, + encoding: '7bit', + mimeType: 'text/plain' + } + }, + { + type: 'field', + name: 'file_name_1', + val: 'super beta file', + info: { + nameTruncated: false, + valueTruncated: false, + encoding: '7bit', + mimeType: 'text/plain' + } + } + ], + events: ['field'], + what: 'Fields and (ignored) files' + }, + { + source: [ + ['-----------------------------paZqsnEHRufoShdX6fh0lUhXBP4k', + 'Content-Disposition: form-data; ' + + 'name="upload_file_0"; filename="/tmp/1k_a.dat"', + 'Content-Type: application/octet-stream', + '', + 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', + '-----------------------------paZqsnEHRufoShdX6fh0lUhXBP4k', + 'Content-Disposition: form-data; ' + + 'name="upload_file_1"; filename="C:\\files\\1k_b.dat"', + 'Content-Type: application/octet-stream', + '', + 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', + '-----------------------------paZqsnEHRufoShdX6fh0lUhXBP4k', + 'Content-Disposition: form-data; ' + + 'name="upload_file_2"; filename="relative/1k_c.dat"', + 'Content-Type: application/octet-stream', + '', + 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', + '-----------------------------paZqsnEHRufoShdX6fh0lUhXBP4k--' + ].join('\r\n') + ], + boundary: '---------------------------paZqsnEHRufoShdX6fh0lUhXBP4k', + expected: [ + { + type: 'file', + name: 'upload_file_0', + data: Buffer.from('ABCDEFGHIJKLMNOPQRSTUVWXYZ'), + info: { + filename: '1k_a.dat', + encoding: '7bit', + mimeType: 'application/octet-stream' + }, + limited: false + }, + { + type: 'file', + name: 'upload_file_1', + data: Buffer.from('ABCDEFGHIJKLMNOPQRSTUVWXYZ'), + info: { + filename: '1k_b.dat', + encoding: '7bit', + mimeType: 'application/octet-stream' + }, + limited: false + }, + { + type: 'file', + name: 'upload_file_2', + data: Buffer.from('ABCDEFGHIJKLMNOPQRSTUVWXYZ'), + info: { + filename: '1k_c.dat', + encoding: '7bit', + mimeType: 'application/octet-stream' + }, + limited: false + } + ], + what: 'Files with filenames containing paths' + }, + { + source: [ + ['-----------------------------paZqsnEHRufoShdX6fh0lUhXBP4k', + 'Content-Disposition: form-data; ' + + 'name="upload_file_0"; filename="/absolute/1k_a.dat"', + 'Content-Type: application/octet-stream', + '', + 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', + '-----------------------------paZqsnEHRufoShdX6fh0lUhXBP4k', + 'Content-Disposition: form-data; ' + + 'name="upload_file_1"; filename="C:\\absolute\\1k_b.dat"', + 'Content-Type: application/octet-stream', + '', + 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', + '-----------------------------paZqsnEHRufoShdX6fh0lUhXBP4k', + 'Content-Disposition: form-data; ' + + 'name="upload_file_2"; filename="relative/1k_c.dat"', + 'Content-Type: application/octet-stream', + '', + 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', + '-----------------------------paZqsnEHRufoShdX6fh0lUhXBP4k--' + ].join('\r\n') + ], + boundary: '---------------------------paZqsnEHRufoShdX6fh0lUhXBP4k', + preservePath: true, + expected: [ + { + type: 'file', + name: 'upload_file_0', + data: Buffer.from('ABCDEFGHIJKLMNOPQRSTUVWXYZ'), + info: { + filename: '/absolute/1k_a.dat', + encoding: '7bit', + mimeType: 'application/octet-stream' + }, + limited: false + }, + { + type: 'file', + name: 'upload_file_1', + data: Buffer.from('ABCDEFGHIJKLMNOPQRSTUVWXYZ'), + info: { + filename: 'C:\\absolute\\1k_b.dat', + encoding: '7bit', + mimeType: 'application/octet-stream' + }, + limited: false + }, + { + type: 'file', + name: 'upload_file_2', + data: Buffer.from('ABCDEFGHIJKLMNOPQRSTUVWXYZ'), + info: { + filename: 'relative/1k_c.dat', + encoding: '7bit', + mimeType: 'application/octet-stream' + }, + limited: false + } + ], + what: 'Paths to be preserved through the preservePath option' + }, + { + source: [ + ['------WebKitFormBoundaryTB2MiQ36fnSJlrhY', + 'Content-Disposition: form-data; name="cont"', + 'Content-Type: ', + '', + 'some random content', + '------WebKitFormBoundaryTB2MiQ36fnSJlrhY', + 'Content-Disposition: ', + '', + 'some random pass', + '------WebKitFormBoundaryTB2MiQ36fnSJlrhY--' + ].join('\r\n') + ], + boundary: '----WebKitFormBoundaryTB2MiQ36fnSJlrhY', + expected: [ + { + type: 'field', + name: 'cont', + val: 'some random content', + info: { + nameTruncated: false, + valueTruncated: false, + encoding: '7bit', + mimeType: 'text/plain' + } + } + ], + what: 'Empty content-type and empty content-disposition' + }, + { + source: [ + ['-----------------------------paZqsnEHRufoShdX6fh0lUhXBP4k', + 'Content-Disposition: form-data; ' + + 'name="file"; filename*=utf-8\'\'n%C3%A4me.txt', + 'Content-Type: application/octet-stream', + '', + 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', + '-----------------------------paZqsnEHRufoShdX6fh0lUhXBP4k--' + ].join('\r\n') + ], + boundary: '---------------------------paZqsnEHRufoShdX6fh0lUhXBP4k', + expected: [ + { + type: 'file', + name: 'file', + data: Buffer.from('ABCDEFGHIJKLMNOPQRSTUVWXYZ'), + info: { + filename: 'näme.txt', + encoding: '7bit', + mimeType: 'application/octet-stream' + }, + limited: false + } + ], + what: 'Unicode filenames' + }, + { + source: [ + ['--asdasdasdasd\r\n', + 'Content-Type: text/plain\r\n', + 'Content-Disposition: form-data; name="foo"\r\n', + '\r\n', + 'asd\r\n', + '--asdasdasdasd--' + ].join(':)') + ], + boundary: 'asdasdasdasd', + expected: [ + { error: 'Malformed part header' }, + { error: 'Unexpected end of form' } + ], + what: 'Stopped mid-header' + }, + { + source: [ + ['------WebKitFormBoundaryTB2MiQ36fnSJlrhY', + 'Content-Disposition: form-data; name="cont"', + 'Content-Type: application/json', + '', + '{}', + '------WebKitFormBoundaryTB2MiQ36fnSJlrhY--' + ].join('\r\n') + ], + boundary: '----WebKitFormBoundaryTB2MiQ36fnSJlrhY', + expected: [ + { + type: 'field', + name: 'cont', + val: '{}', + info: { + nameTruncated: false, + valueTruncated: false, + encoding: '7bit', + mimeType: 'application/json' + } + } + ], + what: 'content-type for fields' + }, + { + source: [ + '------WebKitFormBoundaryTB2MiQ36fnSJlrhY--' + ], + boundary: '----WebKitFormBoundaryTB2MiQ36fnSJlrhY', + expected: [], + what: 'empty form' + }, + { + source: [ + ['-----------------------------paZqsnEHRufoShdX6fh0lUhXBP4k', + 'Content-Disposition: form-data; ' + + 'name=upload_file_0; filename="1k_a.dat"', + 'Content-Type: application/octet-stream', + 'Content-Transfer-Encoding: binary', + '', + '' + ].join('\r\n') + ], + boundary: '---------------------------paZqsnEHRufoShdX6fh0lUhXBP4k', + expected: [ + { + type: 'file', + name: 'upload_file_0', + data: Buffer.alloc(0), + info: { + filename: '1k_a.dat', + encoding: 'binary', + mimeType: 'application/octet-stream' + }, + limited: false, + err: 'Unexpected end of form' + }, + { error: 'Unexpected end of form' } + ], + what: 'Stopped mid-file #1' + }, + { + source: [ + ['-----------------------------paZqsnEHRufoShdX6fh0lUhXBP4k', + 'Content-Disposition: form-data; ' + + 'name=upload_file_0; filename="1k_a.dat"', + 'Content-Type: application/octet-stream', + '', + 'a' + ].join('\r\n') + ], + boundary: '---------------------------paZqsnEHRufoShdX6fh0lUhXBP4k', + expected: [ + { + type: 'file', + name: 'upload_file_0', + data: Buffer.from('a'), + info: { + filename: '1k_a.dat', + encoding: '7bit', + mimeType: 'application/octet-stream' + }, + limited: false, + err: 'Unexpected end of form' + }, + { error: 'Unexpected end of form' } + ], + what: 'Stopped mid-file #2' + }, + { + source: [ + ['-----------------------------paZqsnEHRufoShdX6fh0lUhXBP4k', + 'Content-Disposition: form-data; ' + + 'name="upload_file_0"; filename="notes.txt"', + 'Content-Type: text/plain; charset=utf8', + '', + 'a', + '-----------------------------paZqsnEHRufoShdX6fh0lUhXBP4k--' + ].join('\r\n') + ], + boundary: '---------------------------paZqsnEHRufoShdX6fh0lUhXBP4k', + expected: [ + { + type: 'file', + name: 'upload_file_0', + data: Buffer.from('a'), + info: { + filename: 'notes.txt', + encoding: '7bit', + mimeType: 'text/plain' + }, + limited: false + } + ], + what: 'Text file with charset' + }, + { + source: [ + ['-----------------------------paZqsnEHRufoShdX6fh0lUhXBP4k', + 'Content-Disposition: form-data; ' + + 'name="upload_file_0"; filename="notes.txt"', + 'Content-Type: ', + ' text/plain; charset=utf8', + '', + 'a', + '-----------------------------paZqsnEHRufoShdX6fh0lUhXBP4k--' + ].join('\r\n') + ], + boundary: '---------------------------paZqsnEHRufoShdX6fh0lUhXBP4k', + expected: [ + { + type: 'file', + name: 'upload_file_0', + data: Buffer.from('a'), + info: { + filename: 'notes.txt', + encoding: '7bit', + mimeType: 'text/plain' + }, + limited: false + } + ], + what: 'Folded header value' + }, + { + source: [ + ['-----------------------------paZqsnEHRufoShdX6fh0lUhXBP4k', + 'Content-Type: text/plain; charset=utf8', + '', + 'a', + '-----------------------------paZqsnEHRufoShdX6fh0lUhXBP4k--' + ].join('\r\n') + ], + boundary: '---------------------------paZqsnEHRufoShdX6fh0lUhXBP4k', + expected: [], + what: 'No Content-Disposition' + }, + { + source: [ + ['-----------------------------paZqsnEHRufoShdX6fh0lUhXBP4k', + 'Content-Disposition: form-data; name="file_name_0"', + '', + 'a'.repeat(64 * 1024), + '-----------------------------paZqsnEHRufoShdX6fh0lUhXBP4k', + 'Content-Disposition: form-data; ' + + 'name="upload_file_0"; filename="notes.txt"', + 'Content-Type: ', + ' text/plain; charset=utf8', + '', + 'bc', + '-----------------------------paZqsnEHRufoShdX6fh0lUhXBP4k--' + ].join('\r\n') + ], + boundary: '---------------------------paZqsnEHRufoShdX6fh0lUhXBP4k', + limits: { + fieldSize: Infinity + }, + expected: [ + { + type: 'file', + name: 'upload_file_0', + data: Buffer.from('bc'), + info: { + filename: 'notes.txt', + encoding: '7bit', + mimeType: 'text/plain' + }, + limited: false + } + ], + events: ['file'], + what: 'Skip field parts if no listener' + }, + { + source: [ + ['-----------------------------paZqsnEHRufoShdX6fh0lUhXBP4k', + 'Content-Disposition: form-data; name="file_name_0"', + '', + 'a', + '-----------------------------paZqsnEHRufoShdX6fh0lUhXBP4k', + 'Content-Disposition: form-data; ' + + 'name="upload_file_0"; filename="notes.txt"', + 'Content-Type: ', + ' text/plain; charset=utf8', + '', + 'bc', + '-----------------------------paZqsnEHRufoShdX6fh0lUhXBP4k--' + ].join('\r\n') + ], + boundary: '---------------------------paZqsnEHRufoShdX6fh0lUhXBP4k', + limits: { + parts: 1 + }, + expected: [ + { + type: 'field', + name: 'file_name_0', + val: 'a', + info: { + nameTruncated: false, + valueTruncated: false, + encoding: '7bit', + mimeType: 'text/plain' + } + }, + 'partsLimit' + ], + what: 'Parts limit' + }, + { + source: [ + ['-----------------------------paZqsnEHRufoShdX6fh0lUhXBP4k', + 'Content-Disposition: form-data; name="file_name_0"', + '', + 'a', + '-----------------------------paZqsnEHRufoShdX6fh0lUhXBP4k', + 'Content-Disposition: form-data; name="file_name_1"', + '', + 'b', + '-----------------------------paZqsnEHRufoShdX6fh0lUhXBP4k--' + ].join('\r\n') + ], + boundary: '---------------------------paZqsnEHRufoShdX6fh0lUhXBP4k', + limits: { + fields: 1 + }, + expected: [ + { + type: 'field', + name: 'file_name_0', + val: 'a', + info: { + nameTruncated: false, + valueTruncated: false, + encoding: '7bit', + mimeType: 'text/plain' + } + }, + 'fieldsLimit' + ], + what: 'Fields limit' + }, + { + source: [ + ['-----------------------------paZqsnEHRufoShdX6fh0lUhXBP4k', + 'Content-Disposition: form-data; ' + + 'name="upload_file_0"; filename="notes.txt"', + 'Content-Type: text/plain; charset=utf8', + '', + 'ab', + '-----------------------------paZqsnEHRufoShdX6fh0lUhXBP4k', + 'Content-Disposition: form-data; ' + + 'name="upload_file_1"; filename="notes2.txt"', + 'Content-Type: text/plain; charset=utf8', + '', + 'cd', + '-----------------------------paZqsnEHRufoShdX6fh0lUhXBP4k--' + ].join('\r\n') + ], + boundary: '---------------------------paZqsnEHRufoShdX6fh0lUhXBP4k', + limits: { + files: 1 + }, + expected: [ + { + type: 'file', + name: 'upload_file_0', + data: Buffer.from('ab'), + info: { + filename: 'notes.txt', + encoding: '7bit', + mimeType: 'text/plain' + }, + limited: false + }, + 'filesLimit' + ], + what: 'Files limit' + }, + { + source: [ + ['-----------------------------paZqsnEHRufoShdX6fh0lUhXBP4k', + 'Content-Disposition: form-data; ' + + `name="upload_file_0"; filename="${'a'.repeat(64 * 1024)}.txt"`, + 'Content-Type: text/plain; charset=utf8', + '', + 'ab', + '-----------------------------paZqsnEHRufoShdX6fh0lUhXBP4k', + 'Content-Disposition: form-data; ' + + 'name="upload_file_1"; filename="notes2.txt"', + 'Content-Type: text/plain; charset=utf8', + '', + 'cd', + '-----------------------------paZqsnEHRufoShdX6fh0lUhXBP4k--' + ].join('\r\n') + ], + boundary: '---------------------------paZqsnEHRufoShdX6fh0lUhXBP4k', + expected: [ + { error: 'Malformed part header' }, + { + type: 'file', + name: 'upload_file_1', + data: Buffer.from('cd'), + info: { + filename: 'notes2.txt', + encoding: '7bit', + mimeType: 'text/plain' + }, + limited: false + } + ], + what: 'Oversized part header' + }, + { + source: [ + ['-----------------------------paZqsnEHRufoShdX6fh0lUhXBP4k', + 'Content-Disposition: form-data; ' + + 'name="upload_file_0"; filename="notes.txt"', + 'Content-Type: text/plain; charset=utf8', + '', + 'a'.repeat(31) + '\r' + ].join('\r\n'), + 'b'.repeat(40), + '\r\n-----------------------------paZqsnEHRufoShdX6fh0lUhXBP4k--' + ], + boundary: '---------------------------paZqsnEHRufoShdX6fh0lUhXBP4k', + fileHwm: 32, + expected: [ + { + type: 'file', + name: 'upload_file_0', + data: Buffer.from('a'.repeat(31) + '\r' + 'b'.repeat(40)), + info: { + filename: 'notes.txt', + encoding: '7bit', + mimeType: 'text/plain' + }, + limited: false + } + ], + what: 'Lookbehind data should not stall file streams' + }, + { + source: [ + ['-----------------------------paZqsnEHRufoShdX6fh0lUhXBP4k', + 'Content-Disposition: form-data; ' + + `name="upload_file_0"; filename="${'a'.repeat(8 * 1024)}.txt"`, + 'Content-Type: text/plain; charset=utf8', + '', + 'ab', + '-----------------------------paZqsnEHRufoShdX6fh0lUhXBP4k', + 'Content-Disposition: form-data; ' + + `name="upload_file_1"; filename="${'b'.repeat(8 * 1024)}.txt"`, + 'Content-Type: text/plain; charset=utf8', + '', + 'cd', + '-----------------------------paZqsnEHRufoShdX6fh0lUhXBP4k', + 'Content-Disposition: form-data; ' + + `name="upload_file_2"; filename="${'c'.repeat(8 * 1024)}.txt"`, + 'Content-Type: text/plain; charset=utf8', + '', + 'ef', + '-----------------------------paZqsnEHRufoShdX6fh0lUhXBP4k--' + ].join('\r\n') + ], + boundary: '---------------------------paZqsnEHRufoShdX6fh0lUhXBP4k', + expected: [ + { + type: 'file', + name: 'upload_file_0', + data: Buffer.from('ab'), + info: { + filename: `${'a'.repeat(8 * 1024)}.txt`, + encoding: '7bit', + mimeType: 'text/plain' + }, + limited: false + }, + { + type: 'file', + name: 'upload_file_1', + data: Buffer.from('cd'), + info: { + filename: `${'b'.repeat(8 * 1024)}.txt`, + encoding: '7bit', + mimeType: 'text/plain' + }, + limited: false + }, + { + type: 'file', + name: 'upload_file_2', + data: Buffer.from('ef'), + info: { + filename: `${'c'.repeat(8 * 1024)}.txt`, + encoding: '7bit', + mimeType: 'text/plain' + }, + limited: false + } + ], + what: 'Header size limit should be per part' + }, + { + source: [ + '\r\n--d1bf46b3-aa33-4061-b28d-6c5ced8b08ee\r\n', + 'Content-Type: application/gzip\r\n' + + 'Content-Encoding: gzip\r\n' + + 'Content-Disposition: form-data; name=batch-1; filename=batch-1' + + '\r\n\r\n', + '\r\n--d1bf46b3-aa33-4061-b28d-6c5ced8b08ee--' + ], + boundary: 'd1bf46b3-aa33-4061-b28d-6c5ced8b08ee', + expected: [ + { + type: 'file', + name: 'batch-1', + data: Buffer.alloc(0), + info: { + filename: 'batch-1', + encoding: '7bit', + mimeType: 'application/gzip' + }, + limited: false + } + ], + what: 'Empty part' + } +] + +for (const test of tests) { + active.set(test, 1) + + const { what, boundary, events, limits, preservePath, fileHwm } = test + const bb = busboy({ + fileHwm, + limits, + preservePath, + headers: { + 'content-type': `multipart/form-data; boundary=${boundary}` + } + }) + const results = [] + + if (events === undefined || events.includes('field')) { + bb.on('field', (name, val, info) => { + results.push({ type: 'field', name, val, info }) + }) + } + + if (events === undefined || events.includes('file')) { + bb.on('file', (name, stream, info) => { + const data = [] + let nb = 0 + const file = { + type: 'file', + name, + data: null, + info, + limited: false + } + results.push(file) + stream.on('data', (d) => { + data.push(d) + nb += d.length + }).on('limit', () => { + file.limited = true + }).on('close', () => { + file.data = Buffer.concat(data, nb) + assert.strictEqual(stream.truncated, file.limited) + }).once('error', (err) => { + file.err = err.message + }) + }) + } + + bb.on('error', (err) => { + results.push({ error: err.message }) + }) + + bb.on('partsLimit', () => { + results.push('partsLimit') + }) + + bb.on('filesLimit', () => { + results.push('filesLimit') + }) + + bb.on('fieldsLimit', () => { + results.push('fieldsLimit') + }) + + bb.on('close', () => { + active.delete(test) + + assert.deepStrictEqual( + results, + test.expected, + `[${what}] Results mismatch.\n` + + `Parsed: ${inspect(results)}\n` + + `Expected: ${inspect(test.expected)}` + ) + }) + + for (const src of test.source) { + const buf = (typeof src === 'string' ? Buffer.from(src, 'utf8') : src) + bb.write(buf) + } + bb.end() +} + +// Byte-by-byte versions +for (let test of tests) { + test = { ...test } + test.what += ' (byte-by-byte)' + active.set(test, 1) + + const { what, boundary, events, limits, preservePath, fileHwm } = test + const bb = busboy({ + fileHwm, + limits, + preservePath, + headers: { + 'content-type': `multipart/form-data; boundary=${boundary}` + } + }) + const results = [] + + if (events === undefined || events.includes('field')) { + bb.on('field', (name, val, info) => { + results.push({ type: 'field', name, val, info }) + }) + } + + if (events === undefined || events.includes('file')) { + bb.on('file', (name, stream, info) => { + const data = [] + let nb = 0 + const file = { + type: 'file', + name, + data: null, + info, + limited: false + } + results.push(file) + stream.on('data', (d) => { + data.push(d) + nb += d.length + }).on('limit', () => { + file.limited = true + }).on('close', () => { + file.data = Buffer.concat(data, nb) + assert.strictEqual(stream.truncated, file.limited) + }).once('error', (err) => { + file.err = err.message + }) + }) + } + + bb.on('error', (err) => { + results.push({ error: err.message }) + }) + + bb.on('partsLimit', () => { + results.push('partsLimit') + }) + + bb.on('filesLimit', () => { + results.push('filesLimit') + }) + + bb.on('fieldsLimit', () => { + results.push('fieldsLimit') + }) + + bb.on('close', () => { + active.delete(test) + + assert.deepStrictEqual( + results, + test.expected, + `[${what}] Results mismatch.\n` + + `Parsed: ${inspect(results)}\n` + + `Expected: ${inspect(test.expected)}` + ) + }) + + for (const src of test.source) { + const buf = (typeof src === 'string' ? Buffer.from(src, 'utf8') : src) + for (let i = 0; i < buf.length; ++i) { bb.write(buf.slice(i, i + 1)) } + } + bb.end() +} + +{ + let exception = false + process.once('uncaughtException', (ex) => { + exception = true + throw ex + }) + process.on('exit', () => { + if (exception || active.size === 0) { return } + process.exitCode = 1 + console.error('==========================') + console.error(`${active.size} test(s) did not finish:`) + console.error('==========================') + console.error(Array.from(active.keys()).map((v) => v.what).join('\n')) + }) +} diff --git a/test/busboy/test.js b/test/busboy/test.js new file mode 100644 index 00000000000..22c42d3fd6b --- /dev/null +++ b/test/busboy/test.js @@ -0,0 +1,20 @@ +'use strict' + +const { spawnSync } = require('child_process') +const { readdirSync } = require('fs') +const { join } = require('path') + +const files = readdirSync(__dirname).sort() +for (const filename of files) { + if (filename.startsWith('test-')) { + const path = join(__dirname, filename) + console.log(`> Running ${filename} ...`) + // Note: nyc changes process.argv0, process.execPath, etc. + const result = spawnSync(`node ${path}`, { + shell: true, + stdio: 'inherit', + windowsHide: true + }) + if (result.status !== 0) { process.exitCode = 1 } + } +} diff --git a/test/client-request.js b/test/client-request.js index ff564604d42..fbb1265ca4e 100644 --- a/test/client-request.js +++ b/test/client-request.js @@ -658,7 +658,7 @@ test('request text2', (t) => { }) }) -test('request with FormData body', { skip: nodeMajor < 16 }, (t) => { +test('request with FormData body', { skip: nodeMajor < 18 }, (t) => { const { FormData } = require('../') const { Blob } = require('buffer') diff --git a/test/fetch/client-fetch.js b/test/fetch/client-fetch.js index 7a0cb550f54..fea06a2b392 100644 --- a/test/fetch/client-fetch.js +++ b/test/fetch/client-fetch.js @@ -247,7 +247,7 @@ test('multipart fromdata non-ascii filed names', async (t) => { t.equal(form.get('fiŝo'), 'value1') }) -test('busboy emit error', async (t) => { +test('BodyMixin.formData rejects with mismatched boundary', async (t) => { t.plan(1) const formData = new FormData() formData.append('field1', 'value1') diff --git a/test/utils/formdata.js b/test/utils/formdata.js index 19dac3e84e5..24aee0a5c04 100644 --- a/test/utils/formdata.js +++ b/test/utils/formdata.js @@ -1,4 +1,4 @@ -const busboy = require('busboy') +const { FormDataParser } = require('../..') function parseFormDataString ( body, @@ -9,7 +9,7 @@ function parseFormDataString ( fields: [] } - const bb = busboy({ + const bb = new FormDataParser({ headers: { 'content-type': contentType } diff --git a/types/formdataparser.d.ts b/types/formdataparser.d.ts new file mode 100644 index 00000000000..ac2e8149c9f --- /dev/null +++ b/types/formdataparser.d.ts @@ -0,0 +1,111 @@ +// @ts-check + +/// + +import { Writable, Readable } from 'stream' +import { IncomingHttpHeaders } from 'http' +import { Headers } from './fetch' + +export interface FormDataParserConfig { + headers: IncomingHttpHeaders | Headers + highWaterMark?: number + fileHwm?: number + defCharset?: string + defParamCharset?: string + preservePath?: boolean + limits?: { + fieldNameSize?: number + fieldSize?: number + fields?: number + fileSize?: number + files?: number + parts?: number + headerPairs?: number + } +} + +interface FormDataParserErrors { + file: ( + name: string, + stream: Readable, + info: { + fileName: string, + stream: FileStream, + info: { + filename: string, + encoding: string, + mimeType: string + } + } + ) => void + + field: ( + name: string, + value: string, + info: { + name: string, + data: string, + info: { + nameTruncated: boolean, + valueTruncated: boolean, + encoding: string, + mimeType: string + } + } + ) => void + + partsLimit: () => void + + filesLimit: () => void + + fieldsLimit: () => void + + limit: () => void + + error: (error: Error) => void + + close: () => void +} + +export declare class FileStream extends Readable {} + +export declare class FormDataParser extends Writable { + constructor (opts: FormDataParserConfig) + + private run (cb: (err?: Error) => void): void + + addListener< + T extends keyof FormDataParserErrors + >(event: T, listener: FormDataParserErrors[T]): this + addListener(event: string | symbol, listener: (...args: any[]) => void): this + + on< + T extends keyof FormDataParserErrors + >(event: T, listener: FormDataParserErrors[T]): this + on(event: string | symbol, listener: (...args: any[]) => void): this + + once< + T extends keyof FormDataParserErrors + >(event: T, listener: FormDataParserErrors[T]): this + once(event: string | symbol, listener: (...args: any[]) => void): this + + removeListener< + T extends keyof FormDataParserErrors + >(event: T, listener: FormDataParserErrors[T]): this + removeListener(event: string | symbol, listener: (...args: any[]) => void): this + + off< + T extends keyof FormDataParserErrors + >(event: T, listener: FormDataParserErrors[T]): this + off(event: string | symbol, listener: (...args: any[]) => void): this + + prependListener< + T extends keyof FormDataParserErrors + >(event: T, listener: FormDataParserErrors[T]): this + prependListener(event: string | symbol, listener: (...args: any[]) => void): this + + prependOnceListener< + T extends keyof FormDataParserErrors + >(event: T, listener: FormDataParserErrors[T]): this + prependOnceListener(event: string | symbol, listener: (...args: any[]) => void): this +}