From ff769c79050c697ede87e1a1ec761fbc12370f9d Mon Sep 17 00:00:00 2001 From: qmuntal Date: Mon, 9 Nov 2026 12:42:05 +0100 Subject: [PATCH] revert: remove unrelated dependency audit updates This reverts commit aa2cac01fdcdf9a44b189033e31031805e8b6798. The split-cache implementation and sequential-restore simplifications are retained. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .licenses/npm/brace-expansion.dep.yml | 2 +- .licenses/npm/undici.dep.yml | 2 +- dist/cache-save/index.js | 876 +++++++------------------- dist/setup/index.js | 876 +++++++------------------- package-lock.json | 32 +- 5 files changed, 480 insertions(+), 1308 deletions(-) diff --git a/.licenses/npm/brace-expansion.dep.yml b/.licenses/npm/brace-expansion.dep.yml index 7d836c2..fd7e18f 100644 --- a/.licenses/npm/brace-expansion.dep.yml +++ b/.licenses/npm/brace-expansion.dep.yml @@ -1,6 +1,6 @@ --- name: brace-expansion -version: 5.0.12 +version: 5.0.9 type: npm summary: Brace expansion as known from sh/bash homepage: diff --git a/.licenses/npm/undici.dep.yml b/.licenses/npm/undici.dep.yml index 459f1a9..b339a44 100644 --- a/.licenses/npm/undici.dep.yml +++ b/.licenses/npm/undici.dep.yml @@ -1,6 +1,6 @@ --- name: undici -version: 6.29.1 +version: 6.28.0 type: npm summary: An HTTP/1.1 client, written from scratch for Node.js homepage: https://undici.nodejs.org diff --git a/dist/cache-save/index.js b/dist/cache-save/index.js index dbee425..3eb736f 100644 --- a/dist/cache-save/index.js +++ b/dist/cache-save/index.js @@ -12800,77 +12800,11 @@ class Request { } } - /** - * @param {number|null} statusCode - * @param {Buffer[]|null} headers - * @param {import('node:stream').Duplex} socket - * @param {string} [statusText] - */ - onUpgrade (statusCode, headers, socket, statusText = '') { - this.onFinally() - + onUpgrade (statusCode, headers, socket) { assert(!this.aborted) assert(!this.completed) - if (statusCode !== null) { - this.#publishUpgradeHeaders(statusCode, headers, statusText) - } - - const result = this[kHandler].onUpgrade(statusCode, headers, socket) - - if (!this.aborted) { - this.completed = true - if (statusCode !== null) { - this.#publishUpgradeTrailers() - } - } - - return result - } - - /** - * @param {number} statusCode - * @param {import('node:http2').IncomingHttpHeaders} headers - * @param {(headers: import('node:http2').IncomingHttpHeaders) => Buffer[]} parseHeaders - * @param {string} [statusText] - */ - onUpgradeResponse (statusCode, headers, parseHeaders, statusText = '') { - assert(!this.aborted) - assert(this.completed) - - if (channels.headers.hasSubscribers) { - this.#publishUpgradeHeaders(statusCode, parseHeaders(headers), statusText) - } - this.#publishUpgradeTrailers() - } - - /** - * @param {Error} error - */ - onUpgradeError (error) { - assert(!this.aborted) - assert(this.completed) - - if (channels.error.hasSubscribers) { - channels.error.publish({ request: this, error }) - } - } - - /** - * @param {number} statusCode - * @param {Buffer[]} headers - * @param {string} statusText - */ - #publishUpgradeHeaders (statusCode, headers, statusText) { - if (channels.headers.hasSubscribers) { - channels.headers.publish({ request: this, response: { statusCode, headers, statusText } }) - } - } - - #publishUpgradeTrailers () { - if (channels.trailers.hasSubscribers) { - channels.trailers.publish({ request: this, trailers: [] }) - } + return this[kHandler].onUpgrade(statusCode, headers, socket) } onComplete (trailers) { @@ -14008,7 +13942,7 @@ function defaultFactory (origin, opts) { } class Agent extends DispatcherBase { - constructor ({ factory = defaultFactory, maxRedirections = 0, connect, tls, ...options } = {}) { + constructor ({ factory = defaultFactory, maxRedirections = 0, connect, ...options } = {}) { if (typeof factory !== 'function') { throw new InvalidArgumentError('factory must be a function.') } @@ -14027,15 +13961,11 @@ class Agent extends DispatcherBase { connect = { ...connect } } - if (tls && typeof tls !== 'function') { - tls = { ...tls } - } - this[kInterceptors] = options.interceptors?.Agent && Array.isArray(options.interceptors.Agent) ? options.interceptors.Agent : [createRedirectInterceptor({ maxRedirections })] - this[kOptions] = { ...util.deepClone(options), connect, tls } + this[kOptions] = { ...util.deepClone(options), connect } this[kOptions].interceptors = options.interceptors ? { ...options.interceptors } : undefined @@ -14773,7 +14703,7 @@ class Parser { } onUpgrade (head) { - const { upgrade, client, socket, headers, statusCode, statusText } = this + const { upgrade, client, socket, headers, statusCode } = this assert(upgrade) assert(client[kSocket] === socket) @@ -14808,10 +14738,9 @@ class Parser { client.emit('disconnect', client[kUrl], [client], new InformationalError('upgrade')) try { - request.onUpgrade(statusCode, headers, socket, statusText) - } catch (error) { - util.errorRequest(client, request, error) - util.destroy(socket, error) + request.onUpgrade(statusCode, headers, socket) + } catch (err) { + util.destroy(socket, err) } client[kResume]() @@ -15068,6 +14997,8 @@ async function connectH1 (client, socket) { socket[kParser] = new Parser(client, socket, llhttpInstance) addListener(socket, 'error', function (err) { + assert(err.code !== 'ERR_TLS_CERT_ALTNAME_INVALID') + const parser = this[kParser] // On Mac OS, we get an ECONNRESET even if there is a full body to be forwarded @@ -15216,7 +15147,7 @@ async function connectH1 (client, socket) { function clearIdleSocketValidation (socket) { if (socket[kIdleSocketValidationTimeout]) { - clearImmediate(socket[kIdleSocketValidationTimeout]) + clearTimeout(socket[kIdleSocketValidationTimeout]) socket[kIdleSocketValidationTimeout] = null } @@ -15225,23 +15156,15 @@ function clearIdleSocketValidation (socket) { function scheduleIdleSocketValidation (client, socket) { socket[kIdleSocketValidation] = 1 - // Yield to the check phase (after poll) so unsolicited bytes / FIN / RST - // already pending on this idle keep-alive socket are processed before the - // next request is written (GHSA-35p6-xmwp-9g52). - // - // setTimeout(0) pays Node's ~1ms timer floor on every sequential reuse - // (#5493). setImmediate avoids that, but an *unref'd* Immediate lets poll - // block for ~500ms when the event loop is otherwise idle (#5600 / #5606). - // A ref'd Immediate both keeps the pending request alive and makes poll - // return immediately — the hybrid those issues asked for. - socket[kIdleSocketValidationTimeout] = setImmediate(() => { + socket[kIdleSocketValidationTimeout] = setTimeout(() => { socket[kIdleSocketValidationTimeout] = null socket[kIdleSocketValidation] = 2 if (client[kSocket] === socket && !socket.destroyed) { client[kResume]() } - }) + }, 0) + socket[kIdleSocketValidationTimeout].unref?.() } /** @@ -15390,22 +15313,12 @@ function writeH1 (client, request) { const socket = client[kSocket] clearIdleSocketValidation(socket) - /** - * @param {Error} [error] - */ - const abort = (error) => { - if (request.aborted) { + const abort = (err) => { + if (request.aborted || request.completed) { return } - if (request.completed) { - if (request.upgrade || request.method === 'CONNECT') { - util.destroy(socket, new InformationalError('aborted')) - } - return - } - - util.errorRequest(client, request, error || new RequestAbortedError()) + util.errorRequest(client, request, err || new RequestAbortedError()) util.destroy(body) util.destroy(socket, new InformationalError('aborted')) @@ -15862,7 +15775,6 @@ module.exports = connectH1 const assert = __nccwpck_require__(4589) -const { errorMonitor } = __nccwpck_require__(8474) const { pipeline } = __nccwpck_require__(7075) const util = __nccwpck_require__(3440) const { @@ -15939,15 +15851,6 @@ function parseH2Headers (headers) { return result } -/** - * @param {import('node:http2').IncomingHttpHeaders} headers - * @returns {Buffer[]} - */ -function parseH2ResponseHeaders (headers) { - const { [HTTP2_HEADER_STATUS]: _statusCode, ...realHeaders } = headers - return parseH2Headers(realHeaders) -} - async function connectH2 (client, socket) { client[kSocket] = socket @@ -15997,6 +15900,8 @@ async function connectH2 (client, socket) { socket[kHTTP2Session] = session util.addListener(socket, 'error', function (err) { + assert(err.code !== 'ERR_TLS_CERT_ALTNAME_INVALID') + this[kError] = err this[kClient][kOnError](err) @@ -16070,6 +15975,8 @@ function resumeH2 (client) { } function onHttp2SessionError (err) { + assert(err.code !== 'ERR_TLS_CERT_ALTNAME_INVALID') + this[kSocket][kError] = err this[kClient][kOnError](err) } @@ -16164,32 +16071,22 @@ function writeH2 (client, request) { headers[HTTP2_HEADER_AUTHORITY] = host || `${hostname}${port ? `:${port}` : ''}` headers[HTTP2_HEADER_METHOD] = method - /** - * @param {Error} [error] - */ - const abort = (error) => { - if (request.aborted) { + const abort = (err) => { + if (request.aborted || request.completed) { return } - if (request.completed) { - if (method === 'CONNECT' && stream != null) { - util.destroy(stream, error || new RequestAbortedError()) - } - return - } + err = err || new RequestAbortedError() - error = error || new RequestAbortedError() - - util.errorRequest(client, request, error) + util.errorRequest(client, request, err) if (stream != null) { - util.destroy(stream, error) + util.destroy(stream, err) } // We do not destroy the socket as we can continue using the session // the stream get's destroyed and the session remains to create new streams - util.destroy(body, error) + util.destroy(body, err) client[kQueue][client[kRunningIdx]++] = null client[kResume]() } @@ -16208,57 +16105,25 @@ function writeH2 (client, request) { if (method === 'CONNECT') { session.ref() + // We are already connected, streams are pending, first request + // will create a new stream. We trigger a request to create the stream and wait until + // `ready` event is triggered // We disabled endStream to allow the user to write to the stream stream = session.request(headers, { endStream: false, signal }) - let upgradeResponseFinished = false - /** - * @param {import('node:http2').IncomingHttpHeaders} headers - */ - const onResponse = (headers) => { - upgradeResponseFinished = true - stream.off(errorMonitor, onUpgradeError) - request.onUpgradeResponse(Number(headers[HTTP2_HEADER_STATUS]), headers, parseH2ResponseHeaders) - } - - /** - * @param {Error} error - */ - const onUpgradeError = (error) => { - upgradeResponseFinished = true - stream.off('response', onResponse) - request.onUpgradeError(error) - } - - const onReady = () => { - try { - request.onUpgrade(null, null, stream) - } catch (error) { - stream.off('response', onResponse) - abort(error) - return - } - - if (request.aborted) { - return - } - - stream.off('error', abort) - stream.once(errorMonitor, onUpgradeError) + if (stream.id && !stream.pending) { + request.onUpgrade(null, null, stream) + ++session[kOpenStreams] client[kQueue][client[kRunningIdx]++] = null + } else { + stream.once('ready', () => { + request.onUpgrade(null, null, stream) + ++session[kOpenStreams] + client[kQueue][client[kRunningIdx]++] = null + }) } - stream.once('response', onResponse) - stream.once('error', abort) - ++session[kOpenStreams] - onReady() - stream.once('close', () => { - if (!upgradeResponseFinished && request.completed) { - stream.off('response', onResponse) - stream.off(errorMonitor, onUpgradeError) - request.onUpgradeError(new InformationalError(`HTTP/2: "stream error" received - code ${stream.rstCode}`)) - } session[kOpenStreams] -= 1 if (session[kOpenStreams] === 0) session.unref() }) @@ -16767,10 +16632,9 @@ class Client extends DispatcherBase { // h2 maxConcurrentStreams, allowH2, - webSocket, - eventSource + webSocket } = {}) { - super({ webSocket, eventSource }) + super({ webSocket }) if (keepAlive !== undefined) { throw new InvalidArgumentError('unsupported keepAlive, use pipelining=0 instead') @@ -17291,7 +17155,6 @@ module.exports = Client -const buffer = __nccwpck_require__(4573) const Dispatcher = __nccwpck_require__(883) const { ClientDestroyedError, @@ -17304,7 +17167,6 @@ const kOnDestroyed = Symbol('onDestroyed') const kOnClosed = Symbol('onClosed') const kInterceptedDispatch = Symbol('Intercepted Dispatch') const kWebSocketOptions = Symbol('webSocketOptions') -const kEventSourceOptions = Symbol('eventSourceOptions') class DispatcherBase extends Dispatcher { constructor (opts) { @@ -17315,7 +17177,6 @@ class DispatcherBase extends Dispatcher { this[kClosed] = false this[kOnClosed] = [] this[kWebSocketOptions] = opts?.webSocket ?? {} - this[kEventSourceOptions] = opts?.eventSource ?? {} } get webSocketOptions () { @@ -17325,12 +17186,6 @@ class DispatcherBase extends Dispatcher { } } - get eventSourceOptions () { - return { - maxEventSize: this[kEventSourceOptions].maxEventSize ?? buffer.kStringMaxLength - } - } - get destroyed () { return this[kDestroyed] } @@ -18967,7 +18822,6 @@ class RetryHandler { this.end = null this.etag = null this.resume = null - this.headersSent = false // Handle possible onConnect duplication this.handler.onConnect(reason => { @@ -18980,20 +18834,6 @@ class RetryHandler { }) } - checkpointResponseEnd (headers, resume) { - if (this.end == null && this.opts.method !== 'HEAD') { - const contentLength = headers['content-length'] - this.end = contentLength != null ? Number(contentLength) - 1 : null - - assert( - this.end == null || Number.isFinite(this.end), - 'invalid content-length' - ) - } - - this.resume = this.end != null ? resume : null - } - onRequestSent () { if (this.handler.onRequestSent) { this.handler.onRequestSent() @@ -19082,12 +18922,7 @@ class RetryHandler { this.retryCount += 1 if (statusCode >= 300) { - // Only expose a response if no earlier attempt has reached the caller. - // Otherwise abort this attempt so the error settles the existing body - // instead of replacing it with a new response. - if (!this.headersSent && this.retryOpts.statusCodes.includes(statusCode) === false) { - this.headersSent = true - this.checkpointResponseEnd(headers, resume) + if (this.retryOpts.statusCodes.includes(statusCode) === false) { return this.handler.onHeaders( statusCode, rawHeaders, @@ -19156,15 +18991,8 @@ class RetryHandler { const { start, size, end = size - 1 } = contentRange - if (this.start !== start || (this.end != null && this.end !== end)) { - this.abort( - new RequestRetryError('Content-Range mismatch', statusCode, { - headers, - data: { count: this.retryCount } - }) - ) - return false - } + assert(this.start === start, 'content-range mismatch') + assert(this.end == null || this.end === end, 'content-range mismatch') this.resume = resume return true @@ -19176,7 +19004,6 @@ class RetryHandler { const range = parseRangeHeader(headers['content-range']) if (range == null) { - this.headersSent = true return this.handler.onHeaders( statusCode, rawHeaders, @@ -19215,7 +19042,6 @@ class RetryHandler { ) this.resume = resume - this.headersSent = true this.etag = headers.etag != null ? headers.etag : null // Weak etags are not useful for comparison nor cache @@ -19255,7 +19081,7 @@ class RetryHandler { } onError (err) { - if (this.aborted || isDisturbed(this.opts.body) || (this.headersSent && this.resume == null)) { + if (this.aborted || isDisturbed(this.opts.body)) { return this.handler.onError(err) } @@ -23689,7 +23515,6 @@ module.exports = { /***/ ((module, __unused_webpack_exports, __nccwpck_require__) => { -const buffer = __nccwpck_require__(4573) const { Transform } = __nccwpck_require__(7075) const { isASCIINumber, isValidLastEventId } = __nccwpck_require__(4811) @@ -23714,63 +23539,6 @@ const COLON = 0x3A */ const SPACE = 0x20 -const defaultMaxEventSize = buffer.kStringMaxLength - -const DATA = Buffer.from('data') -const EVENT = Buffer.from('event') -const ID = Buffer.from('id') -const RETRY = Buffer.from('retry') - -// Each data field creates a string node, and each unconsumed input chunk keeps -// a Buffer wrapper. The absolute ceiling protects the large default byte limit. -const minParserItems = 1024 -const maxParserItems = 131072 -const bytesPerParserItem = 128 - -function isASCIINumberBytes (buffer, start) { - if (start >= buffer.length) { - return false - } - - for (let i = start; i < buffer.length; i++) { - if (buffer[i] < 0x30 || buffer[i] > 0x39) { - return false - } - } - - return true -} - -function isValidLastEventIdBytes (buffer, start) { - for (let i = start; i < buffer.length; i++) { - if (buffer[i] === 0x00) { - return false - } - } - - return true -} - -function isFieldName (line, length, field) { - if (length !== field.length) { - return false - } - - for (let i = 0; i < length; i++) { - if (line[i] !== field[i]) { - return false - } - } - - return true -} - -function createMaxEventSizeExceededError () { - const error = new Error('EventSource message size exceeded') - error.aborted = false - return error -} - /** * @typedef {object} EventSourceStreamEvent * @type {object} @@ -23811,21 +23579,11 @@ class EventSourceStream extends Transform { eventEndCheck = false /** - * @type {Buffer[]} + * @type {Buffer} */ - chunks = [] + buffer = null - chunkIndex = 0 pos = 0 - lineChunkIndex = 0 - linePos = 0 - lineLength = 0 - eventSize = 0 - eventFieldSizes = { data: 0, event: 0, id: 0, retry: 0 } - dataLineCount = 0 - maxEventSize - maxDataLines - maxBufferedChunks event = { data: undefined, @@ -23836,7 +23594,6 @@ class EventSourceStream extends Transform { /** * @param {object} options - * @param {number} [options.maxEventSize] * @param {eventSourceSettings} options.eventSourceSettings * @param {Function} [options.push] */ @@ -23848,12 +23605,6 @@ class EventSourceStream extends Transform { super(options) this.state = options.eventSourceSettings || {} - this.maxEventSize = options.maxEventSize ?? defaultMaxEventSize - const parserItemLimit = this.maxEventSize > 0 - ? Math.min(maxParserItems, Math.max(minParserItems, Math.floor(this.maxEventSize / bytesPerParserItem))) - : 0 - this.maxDataLines = parserItemLimit - this.maxBufferedChunks = parserItemLimit if (options.push) { this.push = options.push } @@ -23871,28 +23622,92 @@ class EventSourceStream extends Transform { return } - // A leading line ending can immediately release the currently buffered - // line, even when it uses the final allowed chunk slot. - if (this.maxEventSize > 0 && - this.chunks.length >= this.maxBufferedChunks && - chunk[0] !== LF && chunk[0] !== CR) { - callback(createMaxEventSizeExceededError()) - return + // Cache the chunk in the buffer, as the data might not be complete while + // processing it + // TODO: Investigate if there is a more performant way to handle + // incoming chunks + // see: https://github.com/nodejs/undici/issues/2630 + if (this.buffer) { + this.buffer = Buffer.concat([this.buffer, chunk]) + } else { + this.buffer = chunk } - this.chunks.push(chunk) // Strip leading byte-order-mark if we opened the stream and started // the processing of the incoming data if (this.checkBOM) { - if (this.handleBOM()) { - callback() - return + switch (this.buffer.length) { + case 1: + // Check if the first byte is the same as the first byte of the BOM + if (this.buffer[0] === BOM[0]) { + // If it is, we need to wait for more data + callback() + return + } + // Set the checkBOM flag to false as we don't need to check for the + // BOM anymore + this.checkBOM = false + + // The buffer only contains one byte so we need to wait for more data + callback() + return + case 2: + // Check if the first two bytes are the same as the first two bytes + // of the BOM + if ( + this.buffer[0] === BOM[0] && + this.buffer[1] === BOM[1] + ) { + // If it is, we need to wait for more data, because the third byte + // is needed to determine if it is the BOM or not + callback() + return + } + + // Set the checkBOM flag to false as we don't need to check for the + // BOM anymore + this.checkBOM = false + break + case 3: + // Check if the first three bytes are the same as the first three + // bytes of the BOM + if ( + this.buffer[0] === BOM[0] && + this.buffer[1] === BOM[1] && + this.buffer[2] === BOM[2] + ) { + // If it is, we can drop the buffered data, as it is only the BOM + this.buffer = Buffer.alloc(0) + // Set the checkBOM flag to false as we don't need to check for the + // BOM anymore + this.checkBOM = false + + // Await more data + callback() + return + } + // If it is not the BOM, we can start processing the data + this.checkBOM = false + break + default: + // The buffer is longer than 3 bytes, so we can drop the BOM if it is + // present + if ( + this.buffer[0] === BOM[0] && + this.buffer[1] === BOM[1] && + this.buffer[2] === BOM[2] + ) { + // Remove the BOM from the buffer + this.buffer = this.buffer.subarray(3) + } + + // Set the checkBOM flag to false as we don't need to check for the + this.checkBOM = false + break } } - while (this.hasCurrentByte()) { - const byte = this.currentByte() - + while (this.pos < this.buffer.length) { // If the previous line ended with an end-of-line, we need to check // if the next character is also an end-of-line. if (this.eventEndCheck) { @@ -23905,9 +23720,10 @@ class EventSourceStream extends Transform { if (this.crlfCheck) { // If the current character is a line feed, we can remove it // from the buffer and reset the crlfCheck flag - if (byte === LF) { + if (this.buffer[this.pos] === LF) { + this.buffer = this.buffer.subarray(this.pos + 1) + this.pos = 0 this.crlfCheck = false - this.consumeCurrentByte() // It is possible that the line feed is not the end of the // event. We need to check if the next character is an @@ -23923,17 +23739,19 @@ class EventSourceStream extends Transform { this.crlfCheck = false } - if (byte === LF || byte === CR) { + if (this.buffer[this.pos] === LF || this.buffer[this.pos] === CR) { // If the current character is a carriage return, we need to // set the crlfCheck flag to true, as we need to check if the // next character is a line feed so we can remove it from the // buffer - if (byte === CR) { + if (this.buffer[this.pos] === CR) { this.crlfCheck = true } - this.consumeCurrentByte() - if (this.hasPendingEvent()) { + this.buffer = this.buffer.subarray(this.pos + 1) + this.pos = 0 + if ( + this.event.data !== undefined || this.event.event || this.event.id || this.event.retry) { this.processEvent(this.event) } this.clearEvent() @@ -23947,23 +23765,22 @@ class EventSourceStream extends Transform { // If the current character is an end-of-line, we can process the // line - if (byte === LF || byte === CR) { + if (this.buffer[this.pos] === LF || this.buffer[this.pos] === CR) { // If the current character is a carriage return, we need to // set the crlfCheck flag to true, as we need to check if the // next character is a line feed - if (byte === CR) { + if (this.buffer[this.pos] === CR) { this.crlfCheck = true } // In any case, we can process the line as we reached an // end-of-line character - try { - this.parseLine(this.readLine(), this.event) - } catch (error) { - callback(error) - return - } - this.consumeCurrentByte() + this.parseLine(this.buffer.subarray(0, this.pos), this.event) + + // Remove the processed line from the buffer + this.buffer = this.buffer.subarray(this.pos + 1) + // Reset the position as we removed the processed line from the buffer + this.pos = 0 // A line was processed and this could be the end of the event. We need // to check if the next line is empty to determine if the event is // finished. @@ -23971,28 +23788,15 @@ class EventSourceStream extends Transform { continue } - // readLine() is only called after CR or LF. Reject an unfinished line - // before it can retain an unbounded number of decoded chunks. The seven - // extra bytes allow the longest field prefix ("event: " or "retry: "). - if (this.maxEventSize > 0 && ++this.lineLength > this.maxEventSize + 7) { - callback(createMaxEventSizeExceededError()) - return - } - this.advanceCursor() + this.pos++ } callback() } - _destroy (error, callback) { - this.chunks.length = 0 - this.clearEvent() - super._destroy(error, callback) - } - /** * @param {Buffer} line - * @param {EventSourceStreamEvent} event + * @param {EventStreamEvent} event */ parseLine (line, event) { // If the line is empty (a blank line) @@ -24009,72 +23813,65 @@ class EventSourceStream extends Transform { return } - let fieldLength = line.length - let valueStart = line.length + let field = '' + let value = '' // If the line contains a U+003A COLON character (:) if (colonPosition !== -1) { - fieldLength = colonPosition + // Collect the characters on the line before the first U+003A COLON + // character (:), and let field be that string. + // TODO: Investigate if there is a more performant way to extract the + // field + // see: https://github.com/nodejs/undici/issues/2630 + field = line.subarray(0, colonPosition).toString('utf8') // Collect the characters on the line after the first U+003A COLON // character (:), and let value be that string. // If value starts with a U+0020 SPACE character, remove it from value. - valueStart = colonPosition + 1 + let valueStart = colonPosition + 1 if (line[valueStart] === SPACE) { ++valueStart } + // TODO: Investigate if there is a more performant way to extract the + // value + // see: https://github.com/nodejs/undici/issues/2630 + value = line.subarray(valueStart).toString('utf8') + + // Otherwise, the string is not empty but does not contain a U+003A COLON + // character (:) + } else { + // Process the field using the steps described below, using the whole + // line as the field name, and the empty string as the field value. + field = line.toString('utf8') + value = '' } - if (isFieldName(line, fieldLength, DATA)) { - if (this.maxEventSize > 0 && this.dataLineCount >= this.maxDataLines) { - throw createMaxEventSizeExceededError() - } - const valueBytes = line.length - valueStart - const dataSize = this.eventFieldSizes.data + (event.data === undefined ? 0 : 1) + valueBytes - this.setEventFieldSize('data', dataSize) - - const value = line.toString('utf8', valueStart) - - if (event.data === undefined) { - event.data = value - } else { - event.data += `\n${value}` - } - this.dataLineCount++ - return + // Modify the event with the field name and value. The value is also + // decoded as UTF-8 + switch (field) { + case 'data': + if (event[field] === undefined) { + event[field] = value + } else { + event[field] += `\n${value}` + } + break + case 'retry': + if (isASCIINumber(value)) { + event[field] = value + } + break + case 'id': + if (isValidLastEventId(value)) { + event[field] = value + } + break + case 'event': + if (value.length > 0) { + event[field] = value + } + break } - - if (isFieldName(line, fieldLength, RETRY)) { - if (isASCIINumberBytes(line, valueStart)) { - this.setEventFieldSize('retry', line.length - valueStart) - event.retry = line.toString('utf8', valueStart) - } - return - } - - if (isFieldName(line, fieldLength, ID)) { - if (isValidLastEventIdBytes(line, valueStart)) { - this.setEventFieldSize('id', line.length - valueStart) - event.id = line.toString('utf8', valueStart) - } - return - } - - if (isFieldName(line, fieldLength, EVENT)) { - if (valueStart < line.length) { - this.setEventFieldSize('event', line.length - valueStart) - event.event = line.toString('utf8', valueStart) - } - } - } - - setEventFieldSize (field, size) { - const eventSize = this.eventSize - this.eventFieldSizes[field] + size - if (this.maxEventSize > 0 && eventSize > this.maxEventSize) { - throw createMaxEventSizeExceededError() - } - this.eventSize = eventSize - this.eventFieldSizes[field] = size } /** @@ -24103,160 +23900,13 @@ class EventSourceStream extends Transform { } clearEvent () { - this.event.data = undefined - this.event.event = undefined - this.event.id = undefined - this.event.retry = undefined - this.eventSize = 0 - this.dataLineCount = 0 - this.eventFieldSizes.data = 0 - this.eventFieldSizes.event = 0 - this.eventFieldSizes.id = 0 - this.eventFieldSizes.retry = 0 - } - - hasPendingEvent () { - return this.event.data !== undefined || - this.event.event !== undefined || - this.event.id !== undefined || - this.event.retry !== undefined - } - - hasCurrentByte () { - return this.chunkIndex < this.chunks.length && - this.pos < this.chunks[this.chunkIndex].length - } - - currentByte () { - return this.chunks[this.chunkIndex][this.pos] - } - - consumeCurrentByte () { - this.advanceCursor() - this.syncLineStartToCursor() - } - - advanceCursor () { - this.pos++ - - while (this.chunkIndex < this.chunks.length && this.pos >= this.chunks[this.chunkIndex].length) { - this.chunkIndex++ - this.pos = 0 + this.event = { + data: undefined, + event: undefined, + id: undefined, + retry: undefined } } - - syncLineStartToCursor () { - this.lineChunkIndex = this.chunkIndex - this.linePos = this.pos - this.lineLength = 0 - this.dropConsumedChunks() - } - - dropConsumedChunks () { - if (this.lineChunkIndex > 0) { - const consumed = this.lineChunkIndex - this.chunks.splice(0, consumed) - this.lineChunkIndex = 0 - this.chunkIndex -= consumed - } - - if (this.chunkIndex === this.chunks.length) { - this.chunks.length = 0 - this.chunkIndex = 0 - this.pos = 0 - this.lineChunkIndex = 0 - this.linePos = 0 - } - } - - readLine () { - if (this.lineChunkIndex === this.chunkIndex) { - return this.chunks[this.chunkIndex].subarray(this.linePos, this.pos) - } - - const chunks = [] - let length = 0 - - for (let i = this.lineChunkIndex; i <= this.chunkIndex; i++) { - const chunk = this.chunks[i] - const start = i === this.lineChunkIndex ? this.linePos : 0 - const end = i === this.chunkIndex ? this.pos : chunk.length - const slice = chunk.subarray(start, end) - length += slice.length - chunks.push(slice) - } - - return Buffer.concat(chunks, length) - } - - peekBufferedByte (offset) { - let chunkIndex = this.lineChunkIndex - let pos = this.linePos - - while (chunkIndex < this.chunks.length) { - const chunk = this.chunks[chunkIndex] - const remaining = chunk.length - pos - - if (offset < remaining) { - return chunk[pos + offset] - } - - offset -= remaining - chunkIndex++ - pos = 0 - } - } - - discardLeadingBytes (count) { - while (count > 0 && this.lineChunkIndex < this.chunks.length) { - const chunk = this.chunks[this.lineChunkIndex] - const remaining = chunk.length - this.linePos - - if (count < remaining) { - this.linePos += count - count = 0 - } else { - count -= remaining - this.lineChunkIndex++ - this.linePos = 0 - } - } - - this.chunkIndex = this.lineChunkIndex - this.pos = this.linePos - this.dropConsumedChunks() - } - - handleBOM () { - const first = this.peekBufferedByte(0) - const second = this.peekBufferedByte(1) - const third = this.peekBufferedByte(2) - - if (second === undefined) { - if (first === BOM[0]) { - return true - } - - this.checkBOM = false - return true - } - - if (third === undefined) { - if (first === BOM[0] && second === BOM[1]) { - return true - } - - this.checkBOM = false - return false - } - - if (first === BOM[0] && second === BOM[1] && third === BOM[2]) { - this.discardLeadingBytes(3) - } - - this.checkBOM = false - return !this.hasCurrentByte() - } } module.exports = { @@ -24282,7 +23932,6 @@ const { isNetworkError } = __nccwpck_require__(9051) const { delay } = __nccwpck_require__(4811) const { kEnumerableProperty } = __nccwpck_require__(3440) const { environmentSettingsObject } = __nccwpck_require__(3168) -const { getGlobalDispatcher } = __nccwpck_require__(2581) let experimentalWarned = false @@ -24564,7 +24213,6 @@ class EventSource extends EventTarget { const eventSourceStream = new EventSourceStream({ eventSourceSettings: this.#state, - maxEventSize: this.#dispatcher.eventSourceOptions?.maxEventSize, push: (event) => { this.dispatchEvent(createFastMessageEvent( event.type, @@ -24743,8 +24391,7 @@ webidl.converters.EventSourceInitDict = webidl.dictionaryConverter([ }, { key: 'dispatcher', // undici only - converter: webidl.converters.any, - defaultValue: () => getGlobalDispatcher() + converter: webidl.converters.any } ]) @@ -29840,10 +29487,8 @@ async function httpNetworkFetch ( // 19. Run these steps in parallel: // 1. Run these steps, but abort when fetchParams is canceled: - if (!fetchParams.controller.resume) { - fetchParams.controller.on('terminated', onAborted) - } - + fetchParams.controller.onAborted = onAborted + fetchParams.controller.on('terminated', onAborted) fetchParams.controller.resume = async () => { // 1. While true while (true) { @@ -30012,6 +29657,8 @@ async function httpNetworkFetch ( } location = headersList.get('location', true) + this.body = new Readable({ read: resume }) + const decoders = [] const willFollow = location && request.redirect === 'follow' && @@ -30029,7 +29676,7 @@ async function httpNetworkFetch ( // CVE fix similar to urllib3 (GHSA-gm62-xv2j-4w53) and curl (CVE-2022-32206). const maxContentEncodings = 5 if (codings.length > maxContentEncodings) { - this.abort(new Error(`too many content-encodings in response: ${codings.length}, maximum allowed is ${maxContentEncodings}`)) + reject(new Error(`too many content-encodings in response: ${codings.length}, maximum allowed is ${maxContentEncodings}`)) return true } @@ -30062,8 +29709,6 @@ async function httpNetworkFetch ( } } - this.body = new Readable({ read: resume }) - const onError = this.onError.bind(this) resolve({ @@ -30110,6 +29755,10 @@ async function httpNetworkFetch ( fetchParams.controller.off('terminated', this.abort) } + if (fetchParams.controller.onAborted) { + fetchParams.controller.off('terminated', fetchParams.controller.onAborted) + } + fetchParams.controller.ended = true this.body.push(null) @@ -35525,7 +35174,7 @@ function establishWebSocketConnection (url, protocols, client, ws, onEstablish, // is specified, the server needs to include the same field and one of // the selected subprotocol values in its response for the connection to // be established. - if (requestProtocols === null || !requestProtocols.includes(secProtocol)) { + if (!requestProtocols.includes(secProtocol)) { failWebsocketConnection(ws, 'Protocol was not set in the opening handshake.') return } @@ -36286,12 +35935,7 @@ class PerMessageDeflate { if (this.#maxPayloadSize > 0 && this.#inflate[kLength] > this.#maxPayloadSize) { callback(new MessageSizeExceededError()) - // The inflater may still hold buffered input that can emit a late - // zlib error. Remove the data listener, then deterministically stop - // the stream so a subsequent 'error' cannot fire without a listener - // (which would terminate the process as an unhandled error event). this.#inflate.removeAllListeners() - this.#inflate.destroy() this.#inflate = null return } @@ -36584,20 +36228,6 @@ class ByteParser extends Writable { if (isControlFrame(this.#info.opcode)) { this.#loop = this.parseControlFrame(body) this.#state = parserStates.INFO - - // If the automatic PONG/CLOSE reply filled the socket's write buffer, - // stop parsing and resume on drain so a PING flood cannot grow the - // outbound queue without bound. - const socket = this.ws[kResponse].socket - if (this.#loop && socket.writableNeedDrain) { - this.#loop = false - socket.once('drain', () => { - if (this.destroyed) return - this.#loop = true - this.run(callback) - }) - return - } } else { if (!this.#info.compressed) { if (!this.writeFragments(body)) { @@ -41827,24 +41457,6 @@ const EXPANSION_MAX = 100_000; // realistic expansion (100k results hitting `EXPANSION_MAX` measure ~1M // characters) so legitimate input is unaffected. const EXPANSION_MAX_LENGTH = 4_000_000; -// `expand_` recurses once per level of brace *nesting* - both when expanding a -// set's comma members and when re-wrapping a set whose body is a single part. -// The CVE-2026-14257 fix made the *tail* iterative (recursion on `m.post`, one -// level per chained group), which left nesting depth unbounded: about 3,100 -// levels of `{{{...a,b...}}}` - only ~6KB of input - exhausted the native stack -// and crashed the process. `EXPANSION_MAX_DEPTH` bounds how deep the parser -// will follow nesting. It sits far above any realistic pattern and well below -// the depth at which the stack runs out. -const EXPANSION_MAX_DEPTH = 1_000; -// Bash keeps a quirk where a brace group followed by a comma set still expands -// (`{a},b}`). The parser implements it by rewriting the string and restarting -// the scan, absorbing one `}` per pass. `n` trailing braces therefore cost `n` -// full passes over a string that itself grows by one `escClose` sentinel each -// time - quadratic in `n`, with a ~26x constant from the sentinel's length. -// 128KB of `'{a}' + '}'.repeat(n) + ',z}'` blocked the event loop for 27 -// seconds to produce two results. `EXPANSION_MAX_REWRITES` bounds how many -// times the scan may restart. Real `{a},b}` input needs a handful. -const EXPANSION_MAX_REWRITES = 1_000; function numeric(str) { return !isNaN(str) ? parseInt(str, 10) : str.charCodeAt(0); } @@ -41864,52 +41476,37 @@ function unescapeBraces(str) { .replace(escCommaPattern, ',') .replace(escPeriodPattern, '.'); } -// Like `target.push(...items)` but doesn't overflow the stack -function pushAll(target, items) { - for (let i = 0; i < items.length; i++) { - target.push(items[i]); - } -} /** * Basically just str.split(","), but handling cases * where we have nested braced sections, which should be * treated as individual members, like {a,{b,c},d} */ function parseCommaParts(str) { - const parts = []; - // Walk the brace groups iteratively. Recursing on `post` once per group let a - // chain of them exhaust the stack - the parsing-side counterpart to - // the `expand_` overflow fixed for CVE-2026-14257, and not something `max` or - // `maxLength` can bound, since it happens before expansion. - // - // The part the next chunk continues - let carry = ''; - for (;;) { - const m = balanced('{', '}', str); - if (!m) { - const tail = str.split(','); - tail[0] = carry + tail[0]; - pushAll(parts, tail); - return parts; - } - const { pre, body, post } = m; - const p = pre.split(','); - p[0] = carry + p[0]; - p[p.length - 1] += '{' + body + '}'; - if (!post.length) { - pushAll(parts, p); - return parts; - } - carry = p.pop(); - pushAll(parts, p); - str = post; + if (!str) { + return ['']; } + const parts = []; + const m = balanced('{', '}', str); + if (!m) { + return str.split(','); + } + const { pre, body, post } = m; + const p = pre.split(','); + p[p.length - 1] += '{' + body + '}'; + const postParts = parseCommaParts(post); + if (post.length) { + ; + p[p.length - 1] += postParts.shift(); + p.push.apply(p, postParts); + } + parts.push.apply(parts, p); + return parts; } function expand(str, options = {}) { if (!str) { return []; } - const { max = EXPANSION_MAX, maxLength = EXPANSION_MAX_LENGTH, maxDepth = EXPANSION_MAX_DEPTH, maxRewrites = EXPANSION_MAX_REWRITES, } = options; + const { max = EXPANSION_MAX, maxLength = EXPANSION_MAX_LENGTH } = options; // I don't know why Bash 4.3 does this, but it does. // Anything starting with {} will have the first two bytes preserved // but *only* at the top level, so {},a}b will not expand to anything, @@ -41919,7 +41516,7 @@ function expand(str, options = {}) { if (str.slice(0, 2) === '{}') { str = '\\{\\}' + str.slice(2); } - return expand_(escapeBraces(str), max, maxLength, maxDepth, 0, maxRewrites, true).map(unescapeBraces); + return expand_(escapeBraces(str), max, maxLength, true).map(unescapeBraces); } function embrace(str) { return '{' + str + '}'; @@ -42014,13 +41611,7 @@ function expandSequence(body, isAlphaSequence, max, maxLength) { } return N; } -function expand_(str, max, maxLength, maxDepth, depth, maxRewrites, isTop) { - // Too deeply nested to keep following: treat the rest as literal, the same - // way a group that cannot expand is already handled. Truncating rather than - // throwing keeps `expand` total, matching `max` and `maxLength`. - if (depth > maxDepth) { - return [str]; - } +function expand_(str, max, maxLength, isTop) { // Consume the string's top-level brace groups left to right, threading a // running set of combined prefixes (`acc`). Expanding the tail iteratively - // rather than recursing on `m.post` once per group - keeps the native stack @@ -42032,9 +41623,6 @@ function expand_(str, max, maxLength, maxDepth, depth, maxRewrites, isTop) { // comma set - a sequence like `{a..\}` may legitimately yield ''. The drop // is on the final strings, so it is applied to whichever `combine` produces // them (the one with no brace set left in the tail). - // How many times the `{a},b}` rewrite below has restarted the scan. Each pass - // re-reads the whole string, so leaving this unbounded is quadratic. - let rewrites = 0; let dropEmpties = false; let firstGroup = true; for (;;) { @@ -42059,8 +41647,7 @@ function expand_(str, max, maxLength, maxDepth, depth, maxRewrites, isTop) { const isOptions = m.body.indexOf(',') >= 0; if (!isSequence && !isOptions) { // {a},b} - if (rewrites < maxRewrites && m.post.match(/,(?!,).*\}/)) { - rewrites++; + if (m.post.match(/,(?!,).*\}/)) { str = m.pre + '{' + m.body + escClose + m.post; isTop = true; continue; @@ -42080,7 +41667,7 @@ function expand_(str, max, maxLength, maxDepth, depth, maxRewrites, isTop) { let n = parseCommaParts(m.body); if (n.length === 1 && n[0] !== undefined) { // x{{a,b}}y ==> x{a}y x{b}y - n = expand_(n[0], max, maxLength, maxDepth, depth + 1, maxRewrites, false).map(embrace); + n = expand_(n[0], max, maxLength, false).map(embrace); //XXX is this necessary? Can't seem to hit it in tests. /* c8 ignore start */ if (n.length === 1) { @@ -42106,13 +41693,12 @@ function expand_(str, max, maxLength, maxDepth, depth, maxRewrites, isTop) { values = []; let valuesLength = 0; outer: for (let j = 0; j < n.length; j++) { - const expanded = expand_(n[j], max, maxLength, maxDepth, depth + 1, maxRewrites, false); + const expanded = expand_(n[j], max, maxLength, false); for (let k = 0; k < expanded.length; k++) { const v = expanded[k]; if (dropsEmpties && !v) continue; - if (values.length >= max || - valuesLength + v.length > maxLength) { + if (values.length >= max || valuesLength + v.length > maxLength) { break outer; } values.push(v); diff --git a/dist/setup/index.js b/dist/setup/index.js index a0cfd4e..9972012 100644 --- a/dist/setup/index.js +++ b/dist/setup/index.js @@ -12800,77 +12800,11 @@ class Request { } } - /** - * @param {number|null} statusCode - * @param {Buffer[]|null} headers - * @param {import('node:stream').Duplex} socket - * @param {string} [statusText] - */ - onUpgrade (statusCode, headers, socket, statusText = '') { - this.onFinally() - + onUpgrade (statusCode, headers, socket) { assert(!this.aborted) assert(!this.completed) - if (statusCode !== null) { - this.#publishUpgradeHeaders(statusCode, headers, statusText) - } - - const result = this[kHandler].onUpgrade(statusCode, headers, socket) - - if (!this.aborted) { - this.completed = true - if (statusCode !== null) { - this.#publishUpgradeTrailers() - } - } - - return result - } - - /** - * @param {number} statusCode - * @param {import('node:http2').IncomingHttpHeaders} headers - * @param {(headers: import('node:http2').IncomingHttpHeaders) => Buffer[]} parseHeaders - * @param {string} [statusText] - */ - onUpgradeResponse (statusCode, headers, parseHeaders, statusText = '') { - assert(!this.aborted) - assert(this.completed) - - if (channels.headers.hasSubscribers) { - this.#publishUpgradeHeaders(statusCode, parseHeaders(headers), statusText) - } - this.#publishUpgradeTrailers() - } - - /** - * @param {Error} error - */ - onUpgradeError (error) { - assert(!this.aborted) - assert(this.completed) - - if (channels.error.hasSubscribers) { - channels.error.publish({ request: this, error }) - } - } - - /** - * @param {number} statusCode - * @param {Buffer[]} headers - * @param {string} statusText - */ - #publishUpgradeHeaders (statusCode, headers, statusText) { - if (channels.headers.hasSubscribers) { - channels.headers.publish({ request: this, response: { statusCode, headers, statusText } }) - } - } - - #publishUpgradeTrailers () { - if (channels.trailers.hasSubscribers) { - channels.trailers.publish({ request: this, trailers: [] }) - } + return this[kHandler].onUpgrade(statusCode, headers, socket) } onComplete (trailers) { @@ -14008,7 +13942,7 @@ function defaultFactory (origin, opts) { } class Agent extends DispatcherBase { - constructor ({ factory = defaultFactory, maxRedirections = 0, connect, tls, ...options } = {}) { + constructor ({ factory = defaultFactory, maxRedirections = 0, connect, ...options } = {}) { if (typeof factory !== 'function') { throw new InvalidArgumentError('factory must be a function.') } @@ -14027,15 +13961,11 @@ class Agent extends DispatcherBase { connect = { ...connect } } - if (tls && typeof tls !== 'function') { - tls = { ...tls } - } - this[kInterceptors] = options.interceptors?.Agent && Array.isArray(options.interceptors.Agent) ? options.interceptors.Agent : [createRedirectInterceptor({ maxRedirections })] - this[kOptions] = { ...util.deepClone(options), connect, tls } + this[kOptions] = { ...util.deepClone(options), connect } this[kOptions].interceptors = options.interceptors ? { ...options.interceptors } : undefined @@ -14773,7 +14703,7 @@ class Parser { } onUpgrade (head) { - const { upgrade, client, socket, headers, statusCode, statusText } = this + const { upgrade, client, socket, headers, statusCode } = this assert(upgrade) assert(client[kSocket] === socket) @@ -14808,10 +14738,9 @@ class Parser { client.emit('disconnect', client[kUrl], [client], new InformationalError('upgrade')) try { - request.onUpgrade(statusCode, headers, socket, statusText) - } catch (error) { - util.errorRequest(client, request, error) - util.destroy(socket, error) + request.onUpgrade(statusCode, headers, socket) + } catch (err) { + util.destroy(socket, err) } client[kResume]() @@ -15068,6 +14997,8 @@ async function connectH1 (client, socket) { socket[kParser] = new Parser(client, socket, llhttpInstance) addListener(socket, 'error', function (err) { + assert(err.code !== 'ERR_TLS_CERT_ALTNAME_INVALID') + const parser = this[kParser] // On Mac OS, we get an ECONNRESET even if there is a full body to be forwarded @@ -15216,7 +15147,7 @@ async function connectH1 (client, socket) { function clearIdleSocketValidation (socket) { if (socket[kIdleSocketValidationTimeout]) { - clearImmediate(socket[kIdleSocketValidationTimeout]) + clearTimeout(socket[kIdleSocketValidationTimeout]) socket[kIdleSocketValidationTimeout] = null } @@ -15225,23 +15156,15 @@ function clearIdleSocketValidation (socket) { function scheduleIdleSocketValidation (client, socket) { socket[kIdleSocketValidation] = 1 - // Yield to the check phase (after poll) so unsolicited bytes / FIN / RST - // already pending on this idle keep-alive socket are processed before the - // next request is written (GHSA-35p6-xmwp-9g52). - // - // setTimeout(0) pays Node's ~1ms timer floor on every sequential reuse - // (#5493). setImmediate avoids that, but an *unref'd* Immediate lets poll - // block for ~500ms when the event loop is otherwise idle (#5600 / #5606). - // A ref'd Immediate both keeps the pending request alive and makes poll - // return immediately — the hybrid those issues asked for. - socket[kIdleSocketValidationTimeout] = setImmediate(() => { + socket[kIdleSocketValidationTimeout] = setTimeout(() => { socket[kIdleSocketValidationTimeout] = null socket[kIdleSocketValidation] = 2 if (client[kSocket] === socket && !socket.destroyed) { client[kResume]() } - }) + }, 0) + socket[kIdleSocketValidationTimeout].unref?.() } /** @@ -15390,22 +15313,12 @@ function writeH1 (client, request) { const socket = client[kSocket] clearIdleSocketValidation(socket) - /** - * @param {Error} [error] - */ - const abort = (error) => { - if (request.aborted) { + const abort = (err) => { + if (request.aborted || request.completed) { return } - if (request.completed) { - if (request.upgrade || request.method === 'CONNECT') { - util.destroy(socket, new InformationalError('aborted')) - } - return - } - - util.errorRequest(client, request, error || new RequestAbortedError()) + util.errorRequest(client, request, err || new RequestAbortedError()) util.destroy(body) util.destroy(socket, new InformationalError('aborted')) @@ -15862,7 +15775,6 @@ module.exports = connectH1 const assert = __nccwpck_require__(4589) -const { errorMonitor } = __nccwpck_require__(8474) const { pipeline } = __nccwpck_require__(7075) const util = __nccwpck_require__(3440) const { @@ -15939,15 +15851,6 @@ function parseH2Headers (headers) { return result } -/** - * @param {import('node:http2').IncomingHttpHeaders} headers - * @returns {Buffer[]} - */ -function parseH2ResponseHeaders (headers) { - const { [HTTP2_HEADER_STATUS]: _statusCode, ...realHeaders } = headers - return parseH2Headers(realHeaders) -} - async function connectH2 (client, socket) { client[kSocket] = socket @@ -15997,6 +15900,8 @@ async function connectH2 (client, socket) { socket[kHTTP2Session] = session util.addListener(socket, 'error', function (err) { + assert(err.code !== 'ERR_TLS_CERT_ALTNAME_INVALID') + this[kError] = err this[kClient][kOnError](err) @@ -16070,6 +15975,8 @@ function resumeH2 (client) { } function onHttp2SessionError (err) { + assert(err.code !== 'ERR_TLS_CERT_ALTNAME_INVALID') + this[kSocket][kError] = err this[kClient][kOnError](err) } @@ -16164,32 +16071,22 @@ function writeH2 (client, request) { headers[HTTP2_HEADER_AUTHORITY] = host || `${hostname}${port ? `:${port}` : ''}` headers[HTTP2_HEADER_METHOD] = method - /** - * @param {Error} [error] - */ - const abort = (error) => { - if (request.aborted) { + const abort = (err) => { + if (request.aborted || request.completed) { return } - if (request.completed) { - if (method === 'CONNECT' && stream != null) { - util.destroy(stream, error || new RequestAbortedError()) - } - return - } + err = err || new RequestAbortedError() - error = error || new RequestAbortedError() - - util.errorRequest(client, request, error) + util.errorRequest(client, request, err) if (stream != null) { - util.destroy(stream, error) + util.destroy(stream, err) } // We do not destroy the socket as we can continue using the session // the stream get's destroyed and the session remains to create new streams - util.destroy(body, error) + util.destroy(body, err) client[kQueue][client[kRunningIdx]++] = null client[kResume]() } @@ -16208,57 +16105,25 @@ function writeH2 (client, request) { if (method === 'CONNECT') { session.ref() + // We are already connected, streams are pending, first request + // will create a new stream. We trigger a request to create the stream and wait until + // `ready` event is triggered // We disabled endStream to allow the user to write to the stream stream = session.request(headers, { endStream: false, signal }) - let upgradeResponseFinished = false - /** - * @param {import('node:http2').IncomingHttpHeaders} headers - */ - const onResponse = (headers) => { - upgradeResponseFinished = true - stream.off(errorMonitor, onUpgradeError) - request.onUpgradeResponse(Number(headers[HTTP2_HEADER_STATUS]), headers, parseH2ResponseHeaders) - } - - /** - * @param {Error} error - */ - const onUpgradeError = (error) => { - upgradeResponseFinished = true - stream.off('response', onResponse) - request.onUpgradeError(error) - } - - const onReady = () => { - try { - request.onUpgrade(null, null, stream) - } catch (error) { - stream.off('response', onResponse) - abort(error) - return - } - - if (request.aborted) { - return - } - - stream.off('error', abort) - stream.once(errorMonitor, onUpgradeError) + if (stream.id && !stream.pending) { + request.onUpgrade(null, null, stream) + ++session[kOpenStreams] client[kQueue][client[kRunningIdx]++] = null + } else { + stream.once('ready', () => { + request.onUpgrade(null, null, stream) + ++session[kOpenStreams] + client[kQueue][client[kRunningIdx]++] = null + }) } - stream.once('response', onResponse) - stream.once('error', abort) - ++session[kOpenStreams] - onReady() - stream.once('close', () => { - if (!upgradeResponseFinished && request.completed) { - stream.off('response', onResponse) - stream.off(errorMonitor, onUpgradeError) - request.onUpgradeError(new InformationalError(`HTTP/2: "stream error" received - code ${stream.rstCode}`)) - } session[kOpenStreams] -= 1 if (session[kOpenStreams] === 0) session.unref() }) @@ -16767,10 +16632,9 @@ class Client extends DispatcherBase { // h2 maxConcurrentStreams, allowH2, - webSocket, - eventSource + webSocket } = {}) { - super({ webSocket, eventSource }) + super({ webSocket }) if (keepAlive !== undefined) { throw new InvalidArgumentError('unsupported keepAlive, use pipelining=0 instead') @@ -17291,7 +17155,6 @@ module.exports = Client -const buffer = __nccwpck_require__(4573) const Dispatcher = __nccwpck_require__(883) const { ClientDestroyedError, @@ -17304,7 +17167,6 @@ const kOnDestroyed = Symbol('onDestroyed') const kOnClosed = Symbol('onClosed') const kInterceptedDispatch = Symbol('Intercepted Dispatch') const kWebSocketOptions = Symbol('webSocketOptions') -const kEventSourceOptions = Symbol('eventSourceOptions') class DispatcherBase extends Dispatcher { constructor (opts) { @@ -17315,7 +17177,6 @@ class DispatcherBase extends Dispatcher { this[kClosed] = false this[kOnClosed] = [] this[kWebSocketOptions] = opts?.webSocket ?? {} - this[kEventSourceOptions] = opts?.eventSource ?? {} } get webSocketOptions () { @@ -17325,12 +17186,6 @@ class DispatcherBase extends Dispatcher { } } - get eventSourceOptions () { - return { - maxEventSize: this[kEventSourceOptions].maxEventSize ?? buffer.kStringMaxLength - } - } - get destroyed () { return this[kDestroyed] } @@ -18967,7 +18822,6 @@ class RetryHandler { this.end = null this.etag = null this.resume = null - this.headersSent = false // Handle possible onConnect duplication this.handler.onConnect(reason => { @@ -18980,20 +18834,6 @@ class RetryHandler { }) } - checkpointResponseEnd (headers, resume) { - if (this.end == null && this.opts.method !== 'HEAD') { - const contentLength = headers['content-length'] - this.end = contentLength != null ? Number(contentLength) - 1 : null - - assert( - this.end == null || Number.isFinite(this.end), - 'invalid content-length' - ) - } - - this.resume = this.end != null ? resume : null - } - onRequestSent () { if (this.handler.onRequestSent) { this.handler.onRequestSent() @@ -19082,12 +18922,7 @@ class RetryHandler { this.retryCount += 1 if (statusCode >= 300) { - // Only expose a response if no earlier attempt has reached the caller. - // Otherwise abort this attempt so the error settles the existing body - // instead of replacing it with a new response. - if (!this.headersSent && this.retryOpts.statusCodes.includes(statusCode) === false) { - this.headersSent = true - this.checkpointResponseEnd(headers, resume) + if (this.retryOpts.statusCodes.includes(statusCode) === false) { return this.handler.onHeaders( statusCode, rawHeaders, @@ -19156,15 +18991,8 @@ class RetryHandler { const { start, size, end = size - 1 } = contentRange - if (this.start !== start || (this.end != null && this.end !== end)) { - this.abort( - new RequestRetryError('Content-Range mismatch', statusCode, { - headers, - data: { count: this.retryCount } - }) - ) - return false - } + assert(this.start === start, 'content-range mismatch') + assert(this.end == null || this.end === end, 'content-range mismatch') this.resume = resume return true @@ -19176,7 +19004,6 @@ class RetryHandler { const range = parseRangeHeader(headers['content-range']) if (range == null) { - this.headersSent = true return this.handler.onHeaders( statusCode, rawHeaders, @@ -19215,7 +19042,6 @@ class RetryHandler { ) this.resume = resume - this.headersSent = true this.etag = headers.etag != null ? headers.etag : null // Weak etags are not useful for comparison nor cache @@ -19255,7 +19081,7 @@ class RetryHandler { } onError (err) { - if (this.aborted || isDisturbed(this.opts.body) || (this.headersSent && this.resume == null)) { + if (this.aborted || isDisturbed(this.opts.body)) { return this.handler.onError(err) } @@ -23689,7 +23515,6 @@ module.exports = { /***/ ((module, __unused_webpack_exports, __nccwpck_require__) => { -const buffer = __nccwpck_require__(4573) const { Transform } = __nccwpck_require__(7075) const { isASCIINumber, isValidLastEventId } = __nccwpck_require__(4811) @@ -23714,63 +23539,6 @@ const COLON = 0x3A */ const SPACE = 0x20 -const defaultMaxEventSize = buffer.kStringMaxLength - -const DATA = Buffer.from('data') -const EVENT = Buffer.from('event') -const ID = Buffer.from('id') -const RETRY = Buffer.from('retry') - -// Each data field creates a string node, and each unconsumed input chunk keeps -// a Buffer wrapper. The absolute ceiling protects the large default byte limit. -const minParserItems = 1024 -const maxParserItems = 131072 -const bytesPerParserItem = 128 - -function isASCIINumberBytes (buffer, start) { - if (start >= buffer.length) { - return false - } - - for (let i = start; i < buffer.length; i++) { - if (buffer[i] < 0x30 || buffer[i] > 0x39) { - return false - } - } - - return true -} - -function isValidLastEventIdBytes (buffer, start) { - for (let i = start; i < buffer.length; i++) { - if (buffer[i] === 0x00) { - return false - } - } - - return true -} - -function isFieldName (line, length, field) { - if (length !== field.length) { - return false - } - - for (let i = 0; i < length; i++) { - if (line[i] !== field[i]) { - return false - } - } - - return true -} - -function createMaxEventSizeExceededError () { - const error = new Error('EventSource message size exceeded') - error.aborted = false - return error -} - /** * @typedef {object} EventSourceStreamEvent * @type {object} @@ -23811,21 +23579,11 @@ class EventSourceStream extends Transform { eventEndCheck = false /** - * @type {Buffer[]} + * @type {Buffer} */ - chunks = [] + buffer = null - chunkIndex = 0 pos = 0 - lineChunkIndex = 0 - linePos = 0 - lineLength = 0 - eventSize = 0 - eventFieldSizes = { data: 0, event: 0, id: 0, retry: 0 } - dataLineCount = 0 - maxEventSize - maxDataLines - maxBufferedChunks event = { data: undefined, @@ -23836,7 +23594,6 @@ class EventSourceStream extends Transform { /** * @param {object} options - * @param {number} [options.maxEventSize] * @param {eventSourceSettings} options.eventSourceSettings * @param {Function} [options.push] */ @@ -23848,12 +23605,6 @@ class EventSourceStream extends Transform { super(options) this.state = options.eventSourceSettings || {} - this.maxEventSize = options.maxEventSize ?? defaultMaxEventSize - const parserItemLimit = this.maxEventSize > 0 - ? Math.min(maxParserItems, Math.max(minParserItems, Math.floor(this.maxEventSize / bytesPerParserItem))) - : 0 - this.maxDataLines = parserItemLimit - this.maxBufferedChunks = parserItemLimit if (options.push) { this.push = options.push } @@ -23871,28 +23622,92 @@ class EventSourceStream extends Transform { return } - // A leading line ending can immediately release the currently buffered - // line, even when it uses the final allowed chunk slot. - if (this.maxEventSize > 0 && - this.chunks.length >= this.maxBufferedChunks && - chunk[0] !== LF && chunk[0] !== CR) { - callback(createMaxEventSizeExceededError()) - return + // Cache the chunk in the buffer, as the data might not be complete while + // processing it + // TODO: Investigate if there is a more performant way to handle + // incoming chunks + // see: https://github.com/nodejs/undici/issues/2630 + if (this.buffer) { + this.buffer = Buffer.concat([this.buffer, chunk]) + } else { + this.buffer = chunk } - this.chunks.push(chunk) // Strip leading byte-order-mark if we opened the stream and started // the processing of the incoming data if (this.checkBOM) { - if (this.handleBOM()) { - callback() - return + switch (this.buffer.length) { + case 1: + // Check if the first byte is the same as the first byte of the BOM + if (this.buffer[0] === BOM[0]) { + // If it is, we need to wait for more data + callback() + return + } + // Set the checkBOM flag to false as we don't need to check for the + // BOM anymore + this.checkBOM = false + + // The buffer only contains one byte so we need to wait for more data + callback() + return + case 2: + // Check if the first two bytes are the same as the first two bytes + // of the BOM + if ( + this.buffer[0] === BOM[0] && + this.buffer[1] === BOM[1] + ) { + // If it is, we need to wait for more data, because the third byte + // is needed to determine if it is the BOM or not + callback() + return + } + + // Set the checkBOM flag to false as we don't need to check for the + // BOM anymore + this.checkBOM = false + break + case 3: + // Check if the first three bytes are the same as the first three + // bytes of the BOM + if ( + this.buffer[0] === BOM[0] && + this.buffer[1] === BOM[1] && + this.buffer[2] === BOM[2] + ) { + // If it is, we can drop the buffered data, as it is only the BOM + this.buffer = Buffer.alloc(0) + // Set the checkBOM flag to false as we don't need to check for the + // BOM anymore + this.checkBOM = false + + // Await more data + callback() + return + } + // If it is not the BOM, we can start processing the data + this.checkBOM = false + break + default: + // The buffer is longer than 3 bytes, so we can drop the BOM if it is + // present + if ( + this.buffer[0] === BOM[0] && + this.buffer[1] === BOM[1] && + this.buffer[2] === BOM[2] + ) { + // Remove the BOM from the buffer + this.buffer = this.buffer.subarray(3) + } + + // Set the checkBOM flag to false as we don't need to check for the + this.checkBOM = false + break } } - while (this.hasCurrentByte()) { - const byte = this.currentByte() - + while (this.pos < this.buffer.length) { // If the previous line ended with an end-of-line, we need to check // if the next character is also an end-of-line. if (this.eventEndCheck) { @@ -23905,9 +23720,10 @@ class EventSourceStream extends Transform { if (this.crlfCheck) { // If the current character is a line feed, we can remove it // from the buffer and reset the crlfCheck flag - if (byte === LF) { + if (this.buffer[this.pos] === LF) { + this.buffer = this.buffer.subarray(this.pos + 1) + this.pos = 0 this.crlfCheck = false - this.consumeCurrentByte() // It is possible that the line feed is not the end of the // event. We need to check if the next character is an @@ -23923,17 +23739,19 @@ class EventSourceStream extends Transform { this.crlfCheck = false } - if (byte === LF || byte === CR) { + if (this.buffer[this.pos] === LF || this.buffer[this.pos] === CR) { // If the current character is a carriage return, we need to // set the crlfCheck flag to true, as we need to check if the // next character is a line feed so we can remove it from the // buffer - if (byte === CR) { + if (this.buffer[this.pos] === CR) { this.crlfCheck = true } - this.consumeCurrentByte() - if (this.hasPendingEvent()) { + this.buffer = this.buffer.subarray(this.pos + 1) + this.pos = 0 + if ( + this.event.data !== undefined || this.event.event || this.event.id || this.event.retry) { this.processEvent(this.event) } this.clearEvent() @@ -23947,23 +23765,22 @@ class EventSourceStream extends Transform { // If the current character is an end-of-line, we can process the // line - if (byte === LF || byte === CR) { + if (this.buffer[this.pos] === LF || this.buffer[this.pos] === CR) { // If the current character is a carriage return, we need to // set the crlfCheck flag to true, as we need to check if the // next character is a line feed - if (byte === CR) { + if (this.buffer[this.pos] === CR) { this.crlfCheck = true } // In any case, we can process the line as we reached an // end-of-line character - try { - this.parseLine(this.readLine(), this.event) - } catch (error) { - callback(error) - return - } - this.consumeCurrentByte() + this.parseLine(this.buffer.subarray(0, this.pos), this.event) + + // Remove the processed line from the buffer + this.buffer = this.buffer.subarray(this.pos + 1) + // Reset the position as we removed the processed line from the buffer + this.pos = 0 // A line was processed and this could be the end of the event. We need // to check if the next line is empty to determine if the event is // finished. @@ -23971,28 +23788,15 @@ class EventSourceStream extends Transform { continue } - // readLine() is only called after CR or LF. Reject an unfinished line - // before it can retain an unbounded number of decoded chunks. The seven - // extra bytes allow the longest field prefix ("event: " or "retry: "). - if (this.maxEventSize > 0 && ++this.lineLength > this.maxEventSize + 7) { - callback(createMaxEventSizeExceededError()) - return - } - this.advanceCursor() + this.pos++ } callback() } - _destroy (error, callback) { - this.chunks.length = 0 - this.clearEvent() - super._destroy(error, callback) - } - /** * @param {Buffer} line - * @param {EventSourceStreamEvent} event + * @param {EventStreamEvent} event */ parseLine (line, event) { // If the line is empty (a blank line) @@ -24009,72 +23813,65 @@ class EventSourceStream extends Transform { return } - let fieldLength = line.length - let valueStart = line.length + let field = '' + let value = '' // If the line contains a U+003A COLON character (:) if (colonPosition !== -1) { - fieldLength = colonPosition + // Collect the characters on the line before the first U+003A COLON + // character (:), and let field be that string. + // TODO: Investigate if there is a more performant way to extract the + // field + // see: https://github.com/nodejs/undici/issues/2630 + field = line.subarray(0, colonPosition).toString('utf8') // Collect the characters on the line after the first U+003A COLON // character (:), and let value be that string. // If value starts with a U+0020 SPACE character, remove it from value. - valueStart = colonPosition + 1 + let valueStart = colonPosition + 1 if (line[valueStart] === SPACE) { ++valueStart } + // TODO: Investigate if there is a more performant way to extract the + // value + // see: https://github.com/nodejs/undici/issues/2630 + value = line.subarray(valueStart).toString('utf8') + + // Otherwise, the string is not empty but does not contain a U+003A COLON + // character (:) + } else { + // Process the field using the steps described below, using the whole + // line as the field name, and the empty string as the field value. + field = line.toString('utf8') + value = '' } - if (isFieldName(line, fieldLength, DATA)) { - if (this.maxEventSize > 0 && this.dataLineCount >= this.maxDataLines) { - throw createMaxEventSizeExceededError() - } - const valueBytes = line.length - valueStart - const dataSize = this.eventFieldSizes.data + (event.data === undefined ? 0 : 1) + valueBytes - this.setEventFieldSize('data', dataSize) - - const value = line.toString('utf8', valueStart) - - if (event.data === undefined) { - event.data = value - } else { - event.data += `\n${value}` - } - this.dataLineCount++ - return + // Modify the event with the field name and value. The value is also + // decoded as UTF-8 + switch (field) { + case 'data': + if (event[field] === undefined) { + event[field] = value + } else { + event[field] += `\n${value}` + } + break + case 'retry': + if (isASCIINumber(value)) { + event[field] = value + } + break + case 'id': + if (isValidLastEventId(value)) { + event[field] = value + } + break + case 'event': + if (value.length > 0) { + event[field] = value + } + break } - - if (isFieldName(line, fieldLength, RETRY)) { - if (isASCIINumberBytes(line, valueStart)) { - this.setEventFieldSize('retry', line.length - valueStart) - event.retry = line.toString('utf8', valueStart) - } - return - } - - if (isFieldName(line, fieldLength, ID)) { - if (isValidLastEventIdBytes(line, valueStart)) { - this.setEventFieldSize('id', line.length - valueStart) - event.id = line.toString('utf8', valueStart) - } - return - } - - if (isFieldName(line, fieldLength, EVENT)) { - if (valueStart < line.length) { - this.setEventFieldSize('event', line.length - valueStart) - event.event = line.toString('utf8', valueStart) - } - } - } - - setEventFieldSize (field, size) { - const eventSize = this.eventSize - this.eventFieldSizes[field] + size - if (this.maxEventSize > 0 && eventSize > this.maxEventSize) { - throw createMaxEventSizeExceededError() - } - this.eventSize = eventSize - this.eventFieldSizes[field] = size } /** @@ -24103,160 +23900,13 @@ class EventSourceStream extends Transform { } clearEvent () { - this.event.data = undefined - this.event.event = undefined - this.event.id = undefined - this.event.retry = undefined - this.eventSize = 0 - this.dataLineCount = 0 - this.eventFieldSizes.data = 0 - this.eventFieldSizes.event = 0 - this.eventFieldSizes.id = 0 - this.eventFieldSizes.retry = 0 - } - - hasPendingEvent () { - return this.event.data !== undefined || - this.event.event !== undefined || - this.event.id !== undefined || - this.event.retry !== undefined - } - - hasCurrentByte () { - return this.chunkIndex < this.chunks.length && - this.pos < this.chunks[this.chunkIndex].length - } - - currentByte () { - return this.chunks[this.chunkIndex][this.pos] - } - - consumeCurrentByte () { - this.advanceCursor() - this.syncLineStartToCursor() - } - - advanceCursor () { - this.pos++ - - while (this.chunkIndex < this.chunks.length && this.pos >= this.chunks[this.chunkIndex].length) { - this.chunkIndex++ - this.pos = 0 + this.event = { + data: undefined, + event: undefined, + id: undefined, + retry: undefined } } - - syncLineStartToCursor () { - this.lineChunkIndex = this.chunkIndex - this.linePos = this.pos - this.lineLength = 0 - this.dropConsumedChunks() - } - - dropConsumedChunks () { - if (this.lineChunkIndex > 0) { - const consumed = this.lineChunkIndex - this.chunks.splice(0, consumed) - this.lineChunkIndex = 0 - this.chunkIndex -= consumed - } - - if (this.chunkIndex === this.chunks.length) { - this.chunks.length = 0 - this.chunkIndex = 0 - this.pos = 0 - this.lineChunkIndex = 0 - this.linePos = 0 - } - } - - readLine () { - if (this.lineChunkIndex === this.chunkIndex) { - return this.chunks[this.chunkIndex].subarray(this.linePos, this.pos) - } - - const chunks = [] - let length = 0 - - for (let i = this.lineChunkIndex; i <= this.chunkIndex; i++) { - const chunk = this.chunks[i] - const start = i === this.lineChunkIndex ? this.linePos : 0 - const end = i === this.chunkIndex ? this.pos : chunk.length - const slice = chunk.subarray(start, end) - length += slice.length - chunks.push(slice) - } - - return Buffer.concat(chunks, length) - } - - peekBufferedByte (offset) { - let chunkIndex = this.lineChunkIndex - let pos = this.linePos - - while (chunkIndex < this.chunks.length) { - const chunk = this.chunks[chunkIndex] - const remaining = chunk.length - pos - - if (offset < remaining) { - return chunk[pos + offset] - } - - offset -= remaining - chunkIndex++ - pos = 0 - } - } - - discardLeadingBytes (count) { - while (count > 0 && this.lineChunkIndex < this.chunks.length) { - const chunk = this.chunks[this.lineChunkIndex] - const remaining = chunk.length - this.linePos - - if (count < remaining) { - this.linePos += count - count = 0 - } else { - count -= remaining - this.lineChunkIndex++ - this.linePos = 0 - } - } - - this.chunkIndex = this.lineChunkIndex - this.pos = this.linePos - this.dropConsumedChunks() - } - - handleBOM () { - const first = this.peekBufferedByte(0) - const second = this.peekBufferedByte(1) - const third = this.peekBufferedByte(2) - - if (second === undefined) { - if (first === BOM[0]) { - return true - } - - this.checkBOM = false - return true - } - - if (third === undefined) { - if (first === BOM[0] && second === BOM[1]) { - return true - } - - this.checkBOM = false - return false - } - - if (first === BOM[0] && second === BOM[1] && third === BOM[2]) { - this.discardLeadingBytes(3) - } - - this.checkBOM = false - return !this.hasCurrentByte() - } } module.exports = { @@ -24282,7 +23932,6 @@ const { isNetworkError } = __nccwpck_require__(9051) const { delay } = __nccwpck_require__(4811) const { kEnumerableProperty } = __nccwpck_require__(3440) const { environmentSettingsObject } = __nccwpck_require__(3168) -const { getGlobalDispatcher } = __nccwpck_require__(2581) let experimentalWarned = false @@ -24564,7 +24213,6 @@ class EventSource extends EventTarget { const eventSourceStream = new EventSourceStream({ eventSourceSettings: this.#state, - maxEventSize: this.#dispatcher.eventSourceOptions?.maxEventSize, push: (event) => { this.dispatchEvent(createFastMessageEvent( event.type, @@ -24743,8 +24391,7 @@ webidl.converters.EventSourceInitDict = webidl.dictionaryConverter([ }, { key: 'dispatcher', // undici only - converter: webidl.converters.any, - defaultValue: () => getGlobalDispatcher() + converter: webidl.converters.any } ]) @@ -29840,10 +29487,8 @@ async function httpNetworkFetch ( // 19. Run these steps in parallel: // 1. Run these steps, but abort when fetchParams is canceled: - if (!fetchParams.controller.resume) { - fetchParams.controller.on('terminated', onAborted) - } - + fetchParams.controller.onAborted = onAborted + fetchParams.controller.on('terminated', onAborted) fetchParams.controller.resume = async () => { // 1. While true while (true) { @@ -30012,6 +29657,8 @@ async function httpNetworkFetch ( } location = headersList.get('location', true) + this.body = new Readable({ read: resume }) + const decoders = [] const willFollow = location && request.redirect === 'follow' && @@ -30029,7 +29676,7 @@ async function httpNetworkFetch ( // CVE fix similar to urllib3 (GHSA-gm62-xv2j-4w53) and curl (CVE-2022-32206). const maxContentEncodings = 5 if (codings.length > maxContentEncodings) { - this.abort(new Error(`too many content-encodings in response: ${codings.length}, maximum allowed is ${maxContentEncodings}`)) + reject(new Error(`too many content-encodings in response: ${codings.length}, maximum allowed is ${maxContentEncodings}`)) return true } @@ -30062,8 +29709,6 @@ async function httpNetworkFetch ( } } - this.body = new Readable({ read: resume }) - const onError = this.onError.bind(this) resolve({ @@ -30110,6 +29755,10 @@ async function httpNetworkFetch ( fetchParams.controller.off('terminated', this.abort) } + if (fetchParams.controller.onAborted) { + fetchParams.controller.off('terminated', fetchParams.controller.onAborted) + } + fetchParams.controller.ended = true this.body.push(null) @@ -35525,7 +35174,7 @@ function establishWebSocketConnection (url, protocols, client, ws, onEstablish, // is specified, the server needs to include the same field and one of // the selected subprotocol values in its response for the connection to // be established. - if (requestProtocols === null || !requestProtocols.includes(secProtocol)) { + if (!requestProtocols.includes(secProtocol)) { failWebsocketConnection(ws, 'Protocol was not set in the opening handshake.') return } @@ -36286,12 +35935,7 @@ class PerMessageDeflate { if (this.#maxPayloadSize > 0 && this.#inflate[kLength] > this.#maxPayloadSize) { callback(new MessageSizeExceededError()) - // The inflater may still hold buffered input that can emit a late - // zlib error. Remove the data listener, then deterministically stop - // the stream so a subsequent 'error' cannot fire without a listener - // (which would terminate the process as an unhandled error event). this.#inflate.removeAllListeners() - this.#inflate.destroy() this.#inflate = null return } @@ -36584,20 +36228,6 @@ class ByteParser extends Writable { if (isControlFrame(this.#info.opcode)) { this.#loop = this.parseControlFrame(body) this.#state = parserStates.INFO - - // If the automatic PONG/CLOSE reply filled the socket's write buffer, - // stop parsing and resume on drain so a PING flood cannot grow the - // outbound queue without bound. - const socket = this.ws[kResponse].socket - if (this.#loop && socket.writableNeedDrain) { - this.#loop = false - socket.once('drain', () => { - if (this.destroyed) return - this.#loop = true - this.run(callback) - }) - return - } } else { if (!this.#info.compressed) { if (!this.writeFragments(body)) { @@ -43166,24 +42796,6 @@ const EXPANSION_MAX = 100_000; // realistic expansion (100k results hitting `EXPANSION_MAX` measure ~1M // characters) so legitimate input is unaffected. const EXPANSION_MAX_LENGTH = 4_000_000; -// `expand_` recurses once per level of brace *nesting* - both when expanding a -// set's comma members and when re-wrapping a set whose body is a single part. -// The CVE-2026-14257 fix made the *tail* iterative (recursion on `m.post`, one -// level per chained group), which left nesting depth unbounded: about 3,100 -// levels of `{{{...a,b...}}}` - only ~6KB of input - exhausted the native stack -// and crashed the process. `EXPANSION_MAX_DEPTH` bounds how deep the parser -// will follow nesting. It sits far above any realistic pattern and well below -// the depth at which the stack runs out. -const EXPANSION_MAX_DEPTH = 1_000; -// Bash keeps a quirk where a brace group followed by a comma set still expands -// (`{a},b}`). The parser implements it by rewriting the string and restarting -// the scan, absorbing one `}` per pass. `n` trailing braces therefore cost `n` -// full passes over a string that itself grows by one `escClose` sentinel each -// time - quadratic in `n`, with a ~26x constant from the sentinel's length. -// 128KB of `'{a}' + '}'.repeat(n) + ',z}'` blocked the event loop for 27 -// seconds to produce two results. `EXPANSION_MAX_REWRITES` bounds how many -// times the scan may restart. Real `{a},b}` input needs a handful. -const EXPANSION_MAX_REWRITES = 1_000; function numeric(str) { return !isNaN(str) ? parseInt(str, 10) : str.charCodeAt(0); } @@ -43203,52 +42815,37 @@ function unescapeBraces(str) { .replace(escCommaPattern, ',') .replace(escPeriodPattern, '.'); } -// Like `target.push(...items)` but doesn't overflow the stack -function pushAll(target, items) { - for (let i = 0; i < items.length; i++) { - target.push(items[i]); - } -} /** * Basically just str.split(","), but handling cases * where we have nested braced sections, which should be * treated as individual members, like {a,{b,c},d} */ function parseCommaParts(str) { - const parts = []; - // Walk the brace groups iteratively. Recursing on `post` once per group let a - // chain of them exhaust the stack - the parsing-side counterpart to - // the `expand_` overflow fixed for CVE-2026-14257, and not something `max` or - // `maxLength` can bound, since it happens before expansion. - // - // The part the next chunk continues - let carry = ''; - for (;;) { - const m = balanced('{', '}', str); - if (!m) { - const tail = str.split(','); - tail[0] = carry + tail[0]; - pushAll(parts, tail); - return parts; - } - const { pre, body, post } = m; - const p = pre.split(','); - p[0] = carry + p[0]; - p[p.length - 1] += '{' + body + '}'; - if (!post.length) { - pushAll(parts, p); - return parts; - } - carry = p.pop(); - pushAll(parts, p); - str = post; + if (!str) { + return ['']; } + const parts = []; + const m = balanced('{', '}', str); + if (!m) { + return str.split(','); + } + const { pre, body, post } = m; + const p = pre.split(','); + p[p.length - 1] += '{' + body + '}'; + const postParts = parseCommaParts(post); + if (post.length) { + ; + p[p.length - 1] += postParts.shift(); + p.push.apply(p, postParts); + } + parts.push.apply(parts, p); + return parts; } function expand(str, options = {}) { if (!str) { return []; } - const { max = EXPANSION_MAX, maxLength = EXPANSION_MAX_LENGTH, maxDepth = EXPANSION_MAX_DEPTH, maxRewrites = EXPANSION_MAX_REWRITES, } = options; + const { max = EXPANSION_MAX, maxLength = EXPANSION_MAX_LENGTH } = options; // I don't know why Bash 4.3 does this, but it does. // Anything starting with {} will have the first two bytes preserved // but *only* at the top level, so {},a}b will not expand to anything, @@ -43258,7 +42855,7 @@ function expand(str, options = {}) { if (str.slice(0, 2) === '{}') { str = '\\{\\}' + str.slice(2); } - return expand_(escapeBraces(str), max, maxLength, maxDepth, 0, maxRewrites, true).map(unescapeBraces); + return expand_(escapeBraces(str), max, maxLength, true).map(unescapeBraces); } function embrace(str) { return '{' + str + '}'; @@ -43353,13 +42950,7 @@ function expandSequence(body, isAlphaSequence, max, maxLength) { } return N; } -function expand_(str, max, maxLength, maxDepth, depth, maxRewrites, isTop) { - // Too deeply nested to keep following: treat the rest as literal, the same - // way a group that cannot expand is already handled. Truncating rather than - // throwing keeps `expand` total, matching `max` and `maxLength`. - if (depth > maxDepth) { - return [str]; - } +function expand_(str, max, maxLength, isTop) { // Consume the string's top-level brace groups left to right, threading a // running set of combined prefixes (`acc`). Expanding the tail iteratively - // rather than recursing on `m.post` once per group - keeps the native stack @@ -43371,9 +42962,6 @@ function expand_(str, max, maxLength, maxDepth, depth, maxRewrites, isTop) { // comma set - a sequence like `{a..\}` may legitimately yield ''. The drop // is on the final strings, so it is applied to whichever `combine` produces // them (the one with no brace set left in the tail). - // How many times the `{a},b}` rewrite below has restarted the scan. Each pass - // re-reads the whole string, so leaving this unbounded is quadratic. - let rewrites = 0; let dropEmpties = false; let firstGroup = true; for (;;) { @@ -43398,8 +42986,7 @@ function expand_(str, max, maxLength, maxDepth, depth, maxRewrites, isTop) { const isOptions = m.body.indexOf(',') >= 0; if (!isSequence && !isOptions) { // {a},b} - if (rewrites < maxRewrites && m.post.match(/,(?!,).*\}/)) { - rewrites++; + if (m.post.match(/,(?!,).*\}/)) { str = m.pre + '{' + m.body + escClose + m.post; isTop = true; continue; @@ -43419,7 +43006,7 @@ function expand_(str, max, maxLength, maxDepth, depth, maxRewrites, isTop) { let n = parseCommaParts(m.body); if (n.length === 1 && n[0] !== undefined) { // x{{a,b}}y ==> x{a}y x{b}y - n = expand_(n[0], max, maxLength, maxDepth, depth + 1, maxRewrites, false).map(embrace); + n = expand_(n[0], max, maxLength, false).map(embrace); //XXX is this necessary? Can't seem to hit it in tests. /* c8 ignore start */ if (n.length === 1) { @@ -43445,13 +43032,12 @@ function expand_(str, max, maxLength, maxDepth, depth, maxRewrites, isTop) { values = []; let valuesLength = 0; outer: for (let j = 0; j < n.length; j++) { - const expanded = expand_(n[j], max, maxLength, maxDepth, depth + 1, maxRewrites, false); + const expanded = expand_(n[j], max, maxLength, false); for (let k = 0; k < expanded.length; k++) { const v = expanded[k]; if (dropsEmpties && !v) continue; - if (values.length >= max || - valuesLength + v.length > maxLength) { + if (values.length >= max || valuesLength + v.length > maxLength) { break outer; } values.push(v); diff --git a/package-lock.json b/package-lock.json index 1e9ce8a..3666206 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2678,9 +2678,9 @@ } }, "node_modules/brace-expansion": { - "version": "5.0.12", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/brace-expansion/-/brace-expansion-5.0.12.tgz", - "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" @@ -3837,9 +3837,9 @@ "license": "MIT" }, "node_modules/glob/node_modules/brace-expansion": { - "version": "2.1.7", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/brace-expansion/-/brace-expansion-2.1.7.tgz", - "integrity": "sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==", + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", + "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", "dev": true, "license": "MIT", "dependencies": { @@ -3890,13 +3890,13 @@ "license": "ISC" }, "node_modules/handlebars": { - "version": "4.7.10", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/handlebars/-/handlebars-4.7.10.tgz", - "integrity": "sha512-P5VJMVM7qgBn6vjXMw8WG9uVI+ncf2pi72j4de4yz5ZULLj2RGqLYaKOYGsgyrViQ0tePOVlN1tDCCXXtFqXKg==", + "version": "4.7.9", + "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.9.tgz", + "integrity": "sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ==", "dev": true, "license": "MIT", "dependencies": { - "minimist": "^1.2.8", + "minimist": "^1.2.5", "neo-async": "^2.6.2", "source-map": "^0.6.1", "wordwrap": "^1.0.0" @@ -5920,9 +5920,9 @@ "license": "MIT" }, "node_modules/test-exclude/node_modules/brace-expansion": { - "version": "1.1.21", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/brace-expansion/-/brace-expansion-1.1.21.tgz", - "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", "dev": true, "license": "MIT", "dependencies": { @@ -6148,9 +6148,9 @@ } }, "node_modules/undici": { - "version": "6.29.1", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/undici/-/undici-6.29.1.tgz", - "integrity": "sha512-EvRlUt/QqAEvXdQeRMYUfwOQsfzpDztykrG2owGEE/rBOBoYv9uF39ZzsNDh4lTFHhTr2XOZjKkYgueD12ZmGA==", + "version": "6.28.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.28.0.tgz", + "integrity": "sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA==", "license": "MIT", "engines": { "node": ">=18.17"