From 3408502ec57cce692809ce929b805f8e6f02ae03 Mon Sep 17 00:00:00 2001 From: maclane Date: Wed, 21 Oct 2026 19:13:47 -0500 Subject: [PATCH] fix(ci): pin patched Node and bundle Undici 6.29.1 --- .github/workflows/reusable-solidity-docs.yml | 2 +- .github/workflows/test.yml | 2 +- MIGRATION.md | 2 +- .../notify-workflow-completed/dist/index.js | 352 +++++++++++++++--- actions/npm-version-bump/dist/index.js | 352 +++++++++++++++--- actions/run-workflow/dist/index.js | 352 +++++++++++++++--- actions/upstream-builds-query/dist/index.js | 352 +++++++++++++++--- package-lock.json | 6 +- 8 files changed, 1182 insertions(+), 238 deletions(-) diff --git a/.github/workflows/reusable-solidity-docs.yml b/.github/workflows/reusable-solidity-docs.yml index be214f7..6f519a1 100644 --- a/.github/workflows/reusable-solidity-docs.yml +++ b/.github/workflows/reusable-solidity-docs.yml @@ -22,7 +22,7 @@ on: description: "Node version required by the consumer Hardhat project." type: string required: false - default: "22.23.1" + default: "22.23.4" trimComments: description: "True if you want to remove space characters between `///` and a comment text in the Solidity files, before generating `.md` diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 886dbb8..6f4bb6b 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -14,7 +14,7 @@ jobs: persist-credentials: false - uses: actions/setup-node@v4 with: - node-version: 24 + node-version: 24.22.1 cache: npm - run: npm ci - run: npm run lint diff --git a/MIGRATION.md b/MIGRATION.md index 09a0b64..be535e8 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -28,7 +28,7 @@ An explicit `ref` is still supported for a different Threshold configuration revision. Existing cloud project values are preserved: changing GitHub ownership does not itself migrate cloud infrastructure. -The docs workflow accepts a `nodeVersion` input (default 22.23.1). The legacy +The docs workflow accepts a `nodeVersion` input (default 22.23.4). The legacy solidity-contracts Hardhat 2.10 stack selects 18.20.8 until that project upgrades its build dependencies; action execution still uses Node 24. diff --git a/actions/notify-workflow-completed/dist/index.js b/actions/notify-workflow-completed/dist/index.js index 362a2f4..553f071 100644 --- a/actions/notify-workflow-completed/dist/index.js +++ b/actions/notify-workflow-completed/dist/index.js @@ -5090,11 +5090,77 @@ class Request { } } - onUpgrade (statusCode, headers, socket) { + /** + * @param {number|null} statusCode + * @param {Buffer[]|null} headers + * @param {import('node:stream').Duplex} socket + * @param {string} [statusText] + */ + onUpgrade (statusCode, headers, socket, statusText = '') { + this.onFinally() + assert(!this.aborted) assert(!this.completed) - return this[kHandler].onUpgrade(statusCode, headers, socket) + 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: [] }) + } } onComplete (trailers) { @@ -6232,7 +6298,7 @@ function defaultFactory (origin, opts) { } class Agent extends DispatcherBase { - constructor ({ factory = defaultFactory, maxRedirections = 0, connect, ...options } = {}) { + constructor ({ factory = defaultFactory, maxRedirections = 0, connect, tls, ...options } = {}) { if (typeof factory !== 'function') { throw new InvalidArgumentError('factory must be a function.') } @@ -6251,11 +6317,15 @@ 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 } + this[kOptions] = { ...util.deepClone(options), connect, tls } this[kOptions].interceptors = options.interceptors ? { ...options.interceptors } : undefined @@ -6993,7 +7063,7 @@ class Parser { } onUpgrade (head) { - const { upgrade, client, socket, headers, statusCode } = this + const { upgrade, client, socket, headers, statusCode, statusText } = this assert(upgrade) assert(client[kSocket] === socket) @@ -7028,9 +7098,10 @@ class Parser { client.emit('disconnect', client[kUrl], [client], new InformationalError('upgrade')) try { - request.onUpgrade(statusCode, headers, socket) - } catch (err) { - util.destroy(socket, err) + request.onUpgrade(statusCode, headers, socket, statusText) + } catch (error) { + util.errorRequest(client, request, error) + util.destroy(socket, error) } client[kResume]() @@ -7287,8 +7358,6 @@ 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 @@ -7611,12 +7680,22 @@ function writeH1 (client, request) { const socket = client[kSocket] clearIdleSocketValidation(socket) - const abort = (err) => { - if (request.aborted || request.completed) { + /** + * @param {Error} [error] + */ + const abort = (error) => { + if (request.aborted) { return } - util.errorRequest(client, request, err || new RequestAbortedError()) + if (request.completed) { + if (request.upgrade || request.method === 'CONNECT') { + util.destroy(socket, new InformationalError('aborted')) + } + return + } + + util.errorRequest(client, request, error || new RequestAbortedError()) util.destroy(body) util.destroy(socket, new InformationalError('aborted')) @@ -8073,6 +8152,7 @@ module.exports = connectH1 const assert = __nccwpck_require__(4589) +const { errorMonitor } = __nccwpck_require__(8474) const { pipeline } = __nccwpck_require__(7075) const util = __nccwpck_require__(3440) const { @@ -8149,6 +8229,15 @@ 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 @@ -8198,8 +8287,6 @@ 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) @@ -8273,8 +8360,6 @@ function resumeH2 (client) { } function onHttp2SessionError (err) { - assert(err.code !== 'ERR_TLS_CERT_ALTNAME_INVALID') - this[kSocket][kError] = err this[kClient][kOnError](err) } @@ -8369,22 +8454,32 @@ function writeH2 (client, request) { headers[HTTP2_HEADER_AUTHORITY] = host || `${hostname}${port ? `:${port}` : ''}` headers[HTTP2_HEADER_METHOD] = method - const abort = (err) => { - if (request.aborted || request.completed) { + /** + * @param {Error} [error] + */ + const abort = (error) => { + if (request.aborted) { + 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, err) + util.errorRequest(client, request, error) if (stream != null) { - util.destroy(stream, err) + util.destroy(stream, error) } // 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, err) + util.destroy(body, error) client[kQueue][client[kRunningIdx]++] = null client[kResume]() } @@ -8403,25 +8498,57 @@ 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 - if (stream.id && !stream.pending) { - request.onUpgrade(null, null, stream) - ++session[kOpenStreams] - client[kQueue][client[kRunningIdx]++] = null - } else { - stream.once('ready', () => { + /** + * @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) - ++session[kOpenStreams] - client[kQueue][client[kRunningIdx]++] = null - }) + } catch (error) { + stream.off('response', onResponse) + abort(error) + return + } + + if (request.aborted) { + return + } + + stream.off('error', abort) + stream.once(errorMonitor, onUpgradeError) + 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() }) @@ -8930,9 +9057,10 @@ class Client extends DispatcherBase { // h2 maxConcurrentStreams, allowH2, - webSocket + webSocket, + eventSource } = {}) { - super({ webSocket }) + super({ webSocket, eventSource }) if (keepAlive !== undefined) { throw new InvalidArgumentError('unsupported keepAlive, use pipelining=0 instead') @@ -9453,6 +9581,7 @@ module.exports = Client +const buffer = __nccwpck_require__(4573) const Dispatcher = __nccwpck_require__(883) const { ClientDestroyedError, @@ -9465,6 +9594,7 @@ 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) { @@ -9475,6 +9605,7 @@ class DispatcherBase extends Dispatcher { this[kClosed] = false this[kOnClosed] = [] this[kWebSocketOptions] = opts?.webSocket ?? {} + this[kEventSourceOptions] = opts?.eventSource ?? {} } get webSocketOptions () { @@ -9484,6 +9615,12 @@ class DispatcherBase extends Dispatcher { } } + get eventSourceOptions () { + return { + maxEventSize: this[kEventSourceOptions].maxEventSize ?? buffer.kStringMaxLength + } + } + get destroyed () { return this[kDestroyed] } @@ -11235,7 +11372,10 @@ class RetryHandler { this.retryCount += 1 if (statusCode >= 300) { - if (this.retryOpts.statusCodes.includes(statusCode) === false) { + // 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) return this.handler.onHeaders( @@ -15839,6 +15979,7 @@ module.exports = { /***/ ((module, __unused_webpack_exports, __nccwpck_require__) => { +const buffer = __nccwpck_require__(4573) const { Transform } = __nccwpck_require__(7075) const { isASCIINumber, isValidLastEventId } = __nccwpck_require__(4811) @@ -15863,11 +16004,19 @@ 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 @@ -15906,6 +16055,12 @@ function isFieldName (line, length, field) { return true } +function createMaxEventSizeExceededError () { + const error = new Error('EventSource message size exceeded') + error.aborted = false + return error +} + /** * @typedef {object} EventSourceStreamEvent * @type {object} @@ -15954,6 +16109,13 @@ class EventSourceStream extends Transform { 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, @@ -15964,6 +16126,7 @@ class EventSourceStream extends Transform { /** * @param {object} options + * @param {number} [options.maxEventSize] * @param {eventSourceSettings} options.eventSourceSettings * @param {Function} [options.push] */ @@ -15975,6 +16138,12 @@ 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 } @@ -15992,6 +16161,14 @@ 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 + } this.chunks.push(chunk) // Strip leading byte-order-mark if we opened the stream and started @@ -16070,7 +16247,12 @@ class EventSourceStream extends Transform { // In any case, we can process the line as we reached an // end-of-line character - this.parseLine(this.readLine(), this.event) + try { + this.parseLine(this.readLine(), this.event) + } catch (error) { + callback(error) + return + } this.consumeCurrentByte() // 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 @@ -16079,15 +16261,28 @@ 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() } callback() } + _destroy (error, callback) { + this.chunks.length = 0 + this.clearEvent() + super._destroy(error, callback) + } + /** * @param {Buffer} line - * @param {EventStreamEvent} event + * @param {EventSourceStreamEvent} event */ parseLine (line, event) { // If the line is empty (a blank line) @@ -16121,6 +16316,13 @@ class EventSourceStream extends Transform { } 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) { @@ -16128,11 +16330,13 @@ class EventSourceStream extends Transform { } else { event.data += `\n${value}` } + this.dataLineCount++ return } if (isFieldName(line, fieldLength, RETRY)) { if (isASCIINumberBytes(line, valueStart)) { + this.setEventFieldSize('retry', line.length - valueStart) event.retry = line.toString('utf8', valueStart) } return @@ -16140,20 +16344,29 @@ class EventSourceStream extends Transform { 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)) { - const value = line.toString('utf8', valueStart) - - if (value.length > 0) { - event.event = value + 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 + } + /** * @param {EventSourceStreamEvent} event */ @@ -16184,6 +16397,12 @@ class EventSourceStream extends Transform { 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 () { @@ -16219,14 +16438,16 @@ class EventSourceStream extends Transform { syncLineStartToCursor () { this.lineChunkIndex = this.chunkIndex this.linePos = this.pos + this.lineLength = 0 this.dropConsumedChunks() } dropConsumedChunks () { - while (this.lineChunkIndex > 0) { - this.chunks.shift() - this.lineChunkIndex-- - this.chunkIndex-- + 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) { @@ -16351,6 +16572,7 @@ 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 @@ -16632,6 +16854,7 @@ class EventSource extends EventTarget { const eventSourceStream = new EventSourceStream({ eventSourceSettings: this.#state, + maxEventSize: this.#dispatcher.eventSourceOptions?.maxEventSize, push: (event) => { this.dispatchEvent(createFastMessageEvent( event.type, @@ -16810,7 +17033,8 @@ webidl.converters.EventSourceInitDict = webidl.dictionaryConverter([ }, { key: 'dispatcher', // undici only - converter: webidl.converters.any + converter: webidl.converters.any, + defaultValue: () => getGlobalDispatcher() } ]) @@ -21906,8 +22130,10 @@ async function httpNetworkFetch ( // 19. Run these steps in parallel: // 1. Run these steps, but abort when fetchParams is canceled: - fetchParams.controller.onAborted = onAborted - fetchParams.controller.on('terminated', onAborted) + if (!fetchParams.controller.resume) { + fetchParams.controller.on('terminated', onAborted) + } + fetchParams.controller.resume = async () => { // 1. While true while (true) { @@ -22076,8 +22302,6 @@ async function httpNetworkFetch ( } location = headersList.get('location', true) - this.body = new Readable({ read: resume }) - const decoders = [] const willFollow = location && request.redirect === 'follow' && @@ -22095,7 +22319,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) { - reject(new Error(`too many content-encodings in response: ${codings.length}, maximum allowed is ${maxContentEncodings}`)) + this.abort(new Error(`too many content-encodings in response: ${codings.length}, maximum allowed is ${maxContentEncodings}`)) return true } @@ -22128,6 +22352,8 @@ async function httpNetworkFetch ( } } + this.body = new Readable({ read: resume }) + const onError = this.onError.bind(this) resolve({ @@ -22174,10 +22400,6 @@ 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) @@ -28652,6 +28874,20 @@ 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)) { diff --git a/actions/npm-version-bump/dist/index.js b/actions/npm-version-bump/dist/index.js index c716eae..3b99e65 100644 --- a/actions/npm-version-bump/dist/index.js +++ b/actions/npm-version-bump/dist/index.js @@ -6005,11 +6005,77 @@ class Request { } } - onUpgrade (statusCode, headers, socket) { + /** + * @param {number|null} statusCode + * @param {Buffer[]|null} headers + * @param {import('node:stream').Duplex} socket + * @param {string} [statusText] + */ + onUpgrade (statusCode, headers, socket, statusText = '') { + this.onFinally() + assert(!this.aborted) assert(!this.completed) - return this[kHandler].onUpgrade(statusCode, headers, socket) + 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: [] }) + } } onComplete (trailers) { @@ -7147,7 +7213,7 @@ function defaultFactory (origin, opts) { } class Agent extends DispatcherBase { - constructor ({ factory = defaultFactory, maxRedirections = 0, connect, ...options } = {}) { + constructor ({ factory = defaultFactory, maxRedirections = 0, connect, tls, ...options } = {}) { if (typeof factory !== 'function') { throw new InvalidArgumentError('factory must be a function.') } @@ -7166,11 +7232,15 @@ 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 } + this[kOptions] = { ...util.deepClone(options), connect, tls } this[kOptions].interceptors = options.interceptors ? { ...options.interceptors } : undefined @@ -7908,7 +7978,7 @@ class Parser { } onUpgrade (head) { - const { upgrade, client, socket, headers, statusCode } = this + const { upgrade, client, socket, headers, statusCode, statusText } = this assert(upgrade) assert(client[kSocket] === socket) @@ -7943,9 +8013,10 @@ class Parser { client.emit('disconnect', client[kUrl], [client], new InformationalError('upgrade')) try { - request.onUpgrade(statusCode, headers, socket) - } catch (err) { - util.destroy(socket, err) + request.onUpgrade(statusCode, headers, socket, statusText) + } catch (error) { + util.errorRequest(client, request, error) + util.destroy(socket, error) } client[kResume]() @@ -8202,8 +8273,6 @@ 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 @@ -8526,12 +8595,22 @@ function writeH1 (client, request) { const socket = client[kSocket] clearIdleSocketValidation(socket) - const abort = (err) => { - if (request.aborted || request.completed) { + /** + * @param {Error} [error] + */ + const abort = (error) => { + if (request.aborted) { return } - util.errorRequest(client, request, err || new RequestAbortedError()) + if (request.completed) { + if (request.upgrade || request.method === 'CONNECT') { + util.destroy(socket, new InformationalError('aborted')) + } + return + } + + util.errorRequest(client, request, error || new RequestAbortedError()) util.destroy(body) util.destroy(socket, new InformationalError('aborted')) @@ -8988,6 +9067,7 @@ module.exports = connectH1 const assert = __nccwpck_require__(4589) +const { errorMonitor } = __nccwpck_require__(8474) const { pipeline } = __nccwpck_require__(7075) const util = __nccwpck_require__(3440) const { @@ -9064,6 +9144,15 @@ 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 @@ -9113,8 +9202,6 @@ 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) @@ -9188,8 +9275,6 @@ function resumeH2 (client) { } function onHttp2SessionError (err) { - assert(err.code !== 'ERR_TLS_CERT_ALTNAME_INVALID') - this[kSocket][kError] = err this[kClient][kOnError](err) } @@ -9284,22 +9369,32 @@ function writeH2 (client, request) { headers[HTTP2_HEADER_AUTHORITY] = host || `${hostname}${port ? `:${port}` : ''}` headers[HTTP2_HEADER_METHOD] = method - const abort = (err) => { - if (request.aborted || request.completed) { + /** + * @param {Error} [error] + */ + const abort = (error) => { + if (request.aborted) { + 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, err) + util.errorRequest(client, request, error) if (stream != null) { - util.destroy(stream, err) + util.destroy(stream, error) } // 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, err) + util.destroy(body, error) client[kQueue][client[kRunningIdx]++] = null client[kResume]() } @@ -9318,25 +9413,57 @@ 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 - if (stream.id && !stream.pending) { - request.onUpgrade(null, null, stream) - ++session[kOpenStreams] - client[kQueue][client[kRunningIdx]++] = null - } else { - stream.once('ready', () => { + /** + * @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) - ++session[kOpenStreams] - client[kQueue][client[kRunningIdx]++] = null - }) + } catch (error) { + stream.off('response', onResponse) + abort(error) + return + } + + if (request.aborted) { + return + } + + stream.off('error', abort) + stream.once(errorMonitor, onUpgradeError) + 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() }) @@ -9845,9 +9972,10 @@ class Client extends DispatcherBase { // h2 maxConcurrentStreams, allowH2, - webSocket + webSocket, + eventSource } = {}) { - super({ webSocket }) + super({ webSocket, eventSource }) if (keepAlive !== undefined) { throw new InvalidArgumentError('unsupported keepAlive, use pipelining=0 instead') @@ -10368,6 +10496,7 @@ module.exports = Client +const buffer = __nccwpck_require__(4573) const Dispatcher = __nccwpck_require__(883) const { ClientDestroyedError, @@ -10380,6 +10509,7 @@ 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) { @@ -10390,6 +10520,7 @@ class DispatcherBase extends Dispatcher { this[kClosed] = false this[kOnClosed] = [] this[kWebSocketOptions] = opts?.webSocket ?? {} + this[kEventSourceOptions] = opts?.eventSource ?? {} } get webSocketOptions () { @@ -10399,6 +10530,12 @@ class DispatcherBase extends Dispatcher { } } + get eventSourceOptions () { + return { + maxEventSize: this[kEventSourceOptions].maxEventSize ?? buffer.kStringMaxLength + } + } + get destroyed () { return this[kDestroyed] } @@ -12150,7 +12287,10 @@ class RetryHandler { this.retryCount += 1 if (statusCode >= 300) { - if (this.retryOpts.statusCodes.includes(statusCode) === false) { + // 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) return this.handler.onHeaders( @@ -16754,6 +16894,7 @@ module.exports = { /***/ ((module, __unused_webpack_exports, __nccwpck_require__) => { +const buffer = __nccwpck_require__(4573) const { Transform } = __nccwpck_require__(7075) const { isASCIINumber, isValidLastEventId } = __nccwpck_require__(4811) @@ -16778,11 +16919,19 @@ 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 @@ -16821,6 +16970,12 @@ function isFieldName (line, length, field) { return true } +function createMaxEventSizeExceededError () { + const error = new Error('EventSource message size exceeded') + error.aborted = false + return error +} + /** * @typedef {object} EventSourceStreamEvent * @type {object} @@ -16869,6 +17024,13 @@ class EventSourceStream extends Transform { 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, @@ -16879,6 +17041,7 @@ class EventSourceStream extends Transform { /** * @param {object} options + * @param {number} [options.maxEventSize] * @param {eventSourceSettings} options.eventSourceSettings * @param {Function} [options.push] */ @@ -16890,6 +17053,12 @@ 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 } @@ -16907,6 +17076,14 @@ 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 + } this.chunks.push(chunk) // Strip leading byte-order-mark if we opened the stream and started @@ -16985,7 +17162,12 @@ class EventSourceStream extends Transform { // In any case, we can process the line as we reached an // end-of-line character - this.parseLine(this.readLine(), this.event) + try { + this.parseLine(this.readLine(), this.event) + } catch (error) { + callback(error) + return + } this.consumeCurrentByte() // 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 @@ -16994,15 +17176,28 @@ 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() } callback() } + _destroy (error, callback) { + this.chunks.length = 0 + this.clearEvent() + super._destroy(error, callback) + } + /** * @param {Buffer} line - * @param {EventStreamEvent} event + * @param {EventSourceStreamEvent} event */ parseLine (line, event) { // If the line is empty (a blank line) @@ -17036,6 +17231,13 @@ class EventSourceStream extends Transform { } 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) { @@ -17043,11 +17245,13 @@ class EventSourceStream extends Transform { } else { event.data += `\n${value}` } + this.dataLineCount++ return } if (isFieldName(line, fieldLength, RETRY)) { if (isASCIINumberBytes(line, valueStart)) { + this.setEventFieldSize('retry', line.length - valueStart) event.retry = line.toString('utf8', valueStart) } return @@ -17055,20 +17259,29 @@ class EventSourceStream extends Transform { 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)) { - const value = line.toString('utf8', valueStart) - - if (value.length > 0) { - event.event = value + 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 + } + /** * @param {EventSourceStreamEvent} event */ @@ -17099,6 +17312,12 @@ class EventSourceStream extends Transform { 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 () { @@ -17134,14 +17353,16 @@ class EventSourceStream extends Transform { syncLineStartToCursor () { this.lineChunkIndex = this.chunkIndex this.linePos = this.pos + this.lineLength = 0 this.dropConsumedChunks() } dropConsumedChunks () { - while (this.lineChunkIndex > 0) { - this.chunks.shift() - this.lineChunkIndex-- - this.chunkIndex-- + 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) { @@ -17266,6 +17487,7 @@ 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 @@ -17547,6 +17769,7 @@ class EventSource extends EventTarget { const eventSourceStream = new EventSourceStream({ eventSourceSettings: this.#state, + maxEventSize: this.#dispatcher.eventSourceOptions?.maxEventSize, push: (event) => { this.dispatchEvent(createFastMessageEvent( event.type, @@ -17725,7 +17948,8 @@ webidl.converters.EventSourceInitDict = webidl.dictionaryConverter([ }, { key: 'dispatcher', // undici only - converter: webidl.converters.any + converter: webidl.converters.any, + defaultValue: () => getGlobalDispatcher() } ]) @@ -22821,8 +23045,10 @@ async function httpNetworkFetch ( // 19. Run these steps in parallel: // 1. Run these steps, but abort when fetchParams is canceled: - fetchParams.controller.onAborted = onAborted - fetchParams.controller.on('terminated', onAborted) + if (!fetchParams.controller.resume) { + fetchParams.controller.on('terminated', onAborted) + } + fetchParams.controller.resume = async () => { // 1. While true while (true) { @@ -22991,8 +23217,6 @@ async function httpNetworkFetch ( } location = headersList.get('location', true) - this.body = new Readable({ read: resume }) - const decoders = [] const willFollow = location && request.redirect === 'follow' && @@ -23010,7 +23234,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) { - reject(new Error(`too many content-encodings in response: ${codings.length}, maximum allowed is ${maxContentEncodings}`)) + this.abort(new Error(`too many content-encodings in response: ${codings.length}, maximum allowed is ${maxContentEncodings}`)) return true } @@ -23043,6 +23267,8 @@ async function httpNetworkFetch ( } } + this.body = new Readable({ read: resume }) + const onError = this.onError.bind(this) resolve({ @@ -23089,10 +23315,6 @@ 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) @@ -29567,6 +29789,20 @@ 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)) { diff --git a/actions/run-workflow/dist/index.js b/actions/run-workflow/dist/index.js index 53624cc..47e7065 100644 --- a/actions/run-workflow/dist/index.js +++ b/actions/run-workflow/dist/index.js @@ -5090,11 +5090,77 @@ class Request { } } - onUpgrade (statusCode, headers, socket) { + /** + * @param {number|null} statusCode + * @param {Buffer[]|null} headers + * @param {import('node:stream').Duplex} socket + * @param {string} [statusText] + */ + onUpgrade (statusCode, headers, socket, statusText = '') { + this.onFinally() + assert(!this.aborted) assert(!this.completed) - return this[kHandler].onUpgrade(statusCode, headers, socket) + 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: [] }) + } } onComplete (trailers) { @@ -6232,7 +6298,7 @@ function defaultFactory (origin, opts) { } class Agent extends DispatcherBase { - constructor ({ factory = defaultFactory, maxRedirections = 0, connect, ...options } = {}) { + constructor ({ factory = defaultFactory, maxRedirections = 0, connect, tls, ...options } = {}) { if (typeof factory !== 'function') { throw new InvalidArgumentError('factory must be a function.') } @@ -6251,11 +6317,15 @@ 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 } + this[kOptions] = { ...util.deepClone(options), connect, tls } this[kOptions].interceptors = options.interceptors ? { ...options.interceptors } : undefined @@ -6993,7 +7063,7 @@ class Parser { } onUpgrade (head) { - const { upgrade, client, socket, headers, statusCode } = this + const { upgrade, client, socket, headers, statusCode, statusText } = this assert(upgrade) assert(client[kSocket] === socket) @@ -7028,9 +7098,10 @@ class Parser { client.emit('disconnect', client[kUrl], [client], new InformationalError('upgrade')) try { - request.onUpgrade(statusCode, headers, socket) - } catch (err) { - util.destroy(socket, err) + request.onUpgrade(statusCode, headers, socket, statusText) + } catch (error) { + util.errorRequest(client, request, error) + util.destroy(socket, error) } client[kResume]() @@ -7287,8 +7358,6 @@ 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 @@ -7611,12 +7680,22 @@ function writeH1 (client, request) { const socket = client[kSocket] clearIdleSocketValidation(socket) - const abort = (err) => { - if (request.aborted || request.completed) { + /** + * @param {Error} [error] + */ + const abort = (error) => { + if (request.aborted) { return } - util.errorRequest(client, request, err || new RequestAbortedError()) + if (request.completed) { + if (request.upgrade || request.method === 'CONNECT') { + util.destroy(socket, new InformationalError('aborted')) + } + return + } + + util.errorRequest(client, request, error || new RequestAbortedError()) util.destroy(body) util.destroy(socket, new InformationalError('aborted')) @@ -8073,6 +8152,7 @@ module.exports = connectH1 const assert = __nccwpck_require__(4589) +const { errorMonitor } = __nccwpck_require__(8474) const { pipeline } = __nccwpck_require__(7075) const util = __nccwpck_require__(3440) const { @@ -8149,6 +8229,15 @@ 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 @@ -8198,8 +8287,6 @@ 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) @@ -8273,8 +8360,6 @@ function resumeH2 (client) { } function onHttp2SessionError (err) { - assert(err.code !== 'ERR_TLS_CERT_ALTNAME_INVALID') - this[kSocket][kError] = err this[kClient][kOnError](err) } @@ -8369,22 +8454,32 @@ function writeH2 (client, request) { headers[HTTP2_HEADER_AUTHORITY] = host || `${hostname}${port ? `:${port}` : ''}` headers[HTTP2_HEADER_METHOD] = method - const abort = (err) => { - if (request.aborted || request.completed) { + /** + * @param {Error} [error] + */ + const abort = (error) => { + if (request.aborted) { + 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, err) + util.errorRequest(client, request, error) if (stream != null) { - util.destroy(stream, err) + util.destroy(stream, error) } // 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, err) + util.destroy(body, error) client[kQueue][client[kRunningIdx]++] = null client[kResume]() } @@ -8403,25 +8498,57 @@ 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 - if (stream.id && !stream.pending) { - request.onUpgrade(null, null, stream) - ++session[kOpenStreams] - client[kQueue][client[kRunningIdx]++] = null - } else { - stream.once('ready', () => { + /** + * @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) - ++session[kOpenStreams] - client[kQueue][client[kRunningIdx]++] = null - }) + } catch (error) { + stream.off('response', onResponse) + abort(error) + return + } + + if (request.aborted) { + return + } + + stream.off('error', abort) + stream.once(errorMonitor, onUpgradeError) + 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() }) @@ -8930,9 +9057,10 @@ class Client extends DispatcherBase { // h2 maxConcurrentStreams, allowH2, - webSocket + webSocket, + eventSource } = {}) { - super({ webSocket }) + super({ webSocket, eventSource }) if (keepAlive !== undefined) { throw new InvalidArgumentError('unsupported keepAlive, use pipelining=0 instead') @@ -9453,6 +9581,7 @@ module.exports = Client +const buffer = __nccwpck_require__(4573) const Dispatcher = __nccwpck_require__(883) const { ClientDestroyedError, @@ -9465,6 +9594,7 @@ 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) { @@ -9475,6 +9605,7 @@ class DispatcherBase extends Dispatcher { this[kClosed] = false this[kOnClosed] = [] this[kWebSocketOptions] = opts?.webSocket ?? {} + this[kEventSourceOptions] = opts?.eventSource ?? {} } get webSocketOptions () { @@ -9484,6 +9615,12 @@ class DispatcherBase extends Dispatcher { } } + get eventSourceOptions () { + return { + maxEventSize: this[kEventSourceOptions].maxEventSize ?? buffer.kStringMaxLength + } + } + get destroyed () { return this[kDestroyed] } @@ -11235,7 +11372,10 @@ class RetryHandler { this.retryCount += 1 if (statusCode >= 300) { - if (this.retryOpts.statusCodes.includes(statusCode) === false) { + // 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) return this.handler.onHeaders( @@ -15839,6 +15979,7 @@ module.exports = { /***/ ((module, __unused_webpack_exports, __nccwpck_require__) => { +const buffer = __nccwpck_require__(4573) const { Transform } = __nccwpck_require__(7075) const { isASCIINumber, isValidLastEventId } = __nccwpck_require__(4811) @@ -15863,11 +16004,19 @@ 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 @@ -15906,6 +16055,12 @@ function isFieldName (line, length, field) { return true } +function createMaxEventSizeExceededError () { + const error = new Error('EventSource message size exceeded') + error.aborted = false + return error +} + /** * @typedef {object} EventSourceStreamEvent * @type {object} @@ -15954,6 +16109,13 @@ class EventSourceStream extends Transform { 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, @@ -15964,6 +16126,7 @@ class EventSourceStream extends Transform { /** * @param {object} options + * @param {number} [options.maxEventSize] * @param {eventSourceSettings} options.eventSourceSettings * @param {Function} [options.push] */ @@ -15975,6 +16138,12 @@ 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 } @@ -15992,6 +16161,14 @@ 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 + } this.chunks.push(chunk) // Strip leading byte-order-mark if we opened the stream and started @@ -16070,7 +16247,12 @@ class EventSourceStream extends Transform { // In any case, we can process the line as we reached an // end-of-line character - this.parseLine(this.readLine(), this.event) + try { + this.parseLine(this.readLine(), this.event) + } catch (error) { + callback(error) + return + } this.consumeCurrentByte() // 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 @@ -16079,15 +16261,28 @@ 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() } callback() } + _destroy (error, callback) { + this.chunks.length = 0 + this.clearEvent() + super._destroy(error, callback) + } + /** * @param {Buffer} line - * @param {EventStreamEvent} event + * @param {EventSourceStreamEvent} event */ parseLine (line, event) { // If the line is empty (a blank line) @@ -16121,6 +16316,13 @@ class EventSourceStream extends Transform { } 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) { @@ -16128,11 +16330,13 @@ class EventSourceStream extends Transform { } else { event.data += `\n${value}` } + this.dataLineCount++ return } if (isFieldName(line, fieldLength, RETRY)) { if (isASCIINumberBytes(line, valueStart)) { + this.setEventFieldSize('retry', line.length - valueStart) event.retry = line.toString('utf8', valueStart) } return @@ -16140,20 +16344,29 @@ class EventSourceStream extends Transform { 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)) { - const value = line.toString('utf8', valueStart) - - if (value.length > 0) { - event.event = value + 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 + } + /** * @param {EventSourceStreamEvent} event */ @@ -16184,6 +16397,12 @@ class EventSourceStream extends Transform { 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 () { @@ -16219,14 +16438,16 @@ class EventSourceStream extends Transform { syncLineStartToCursor () { this.lineChunkIndex = this.chunkIndex this.linePos = this.pos + this.lineLength = 0 this.dropConsumedChunks() } dropConsumedChunks () { - while (this.lineChunkIndex > 0) { - this.chunks.shift() - this.lineChunkIndex-- - this.chunkIndex-- + 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) { @@ -16351,6 +16572,7 @@ 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 @@ -16632,6 +16854,7 @@ class EventSource extends EventTarget { const eventSourceStream = new EventSourceStream({ eventSourceSettings: this.#state, + maxEventSize: this.#dispatcher.eventSourceOptions?.maxEventSize, push: (event) => { this.dispatchEvent(createFastMessageEvent( event.type, @@ -16810,7 +17033,8 @@ webidl.converters.EventSourceInitDict = webidl.dictionaryConverter([ }, { key: 'dispatcher', // undici only - converter: webidl.converters.any + converter: webidl.converters.any, + defaultValue: () => getGlobalDispatcher() } ]) @@ -21906,8 +22130,10 @@ async function httpNetworkFetch ( // 19. Run these steps in parallel: // 1. Run these steps, but abort when fetchParams is canceled: - fetchParams.controller.onAborted = onAborted - fetchParams.controller.on('terminated', onAborted) + if (!fetchParams.controller.resume) { + fetchParams.controller.on('terminated', onAborted) + } + fetchParams.controller.resume = async () => { // 1. While true while (true) { @@ -22076,8 +22302,6 @@ async function httpNetworkFetch ( } location = headersList.get('location', true) - this.body = new Readable({ read: resume }) - const decoders = [] const willFollow = location && request.redirect === 'follow' && @@ -22095,7 +22319,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) { - reject(new Error(`too many content-encodings in response: ${codings.length}, maximum allowed is ${maxContentEncodings}`)) + this.abort(new Error(`too many content-encodings in response: ${codings.length}, maximum allowed is ${maxContentEncodings}`)) return true } @@ -22128,6 +22352,8 @@ async function httpNetworkFetch ( } } + this.body = new Readable({ read: resume }) + const onError = this.onError.bind(this) resolve({ @@ -22174,10 +22400,6 @@ 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) @@ -28652,6 +28874,20 @@ 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)) { diff --git a/actions/upstream-builds-query/dist/index.js b/actions/upstream-builds-query/dist/index.js index 41082d8..963330e 100644 --- a/actions/upstream-builds-query/dist/index.js +++ b/actions/upstream-builds-query/dist/index.js @@ -5090,11 +5090,77 @@ class Request { } } - onUpgrade (statusCode, headers, socket) { + /** + * @param {number|null} statusCode + * @param {Buffer[]|null} headers + * @param {import('node:stream').Duplex} socket + * @param {string} [statusText] + */ + onUpgrade (statusCode, headers, socket, statusText = '') { + this.onFinally() + assert(!this.aborted) assert(!this.completed) - return this[kHandler].onUpgrade(statusCode, headers, socket) + 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: [] }) + } } onComplete (trailers) { @@ -6232,7 +6298,7 @@ function defaultFactory (origin, opts) { } class Agent extends DispatcherBase { - constructor ({ factory = defaultFactory, maxRedirections = 0, connect, ...options } = {}) { + constructor ({ factory = defaultFactory, maxRedirections = 0, connect, tls, ...options } = {}) { if (typeof factory !== 'function') { throw new InvalidArgumentError('factory must be a function.') } @@ -6251,11 +6317,15 @@ 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 } + this[kOptions] = { ...util.deepClone(options), connect, tls } this[kOptions].interceptors = options.interceptors ? { ...options.interceptors } : undefined @@ -6993,7 +7063,7 @@ class Parser { } onUpgrade (head) { - const { upgrade, client, socket, headers, statusCode } = this + const { upgrade, client, socket, headers, statusCode, statusText } = this assert(upgrade) assert(client[kSocket] === socket) @@ -7028,9 +7098,10 @@ class Parser { client.emit('disconnect', client[kUrl], [client], new InformationalError('upgrade')) try { - request.onUpgrade(statusCode, headers, socket) - } catch (err) { - util.destroy(socket, err) + request.onUpgrade(statusCode, headers, socket, statusText) + } catch (error) { + util.errorRequest(client, request, error) + util.destroy(socket, error) } client[kResume]() @@ -7287,8 +7358,6 @@ 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 @@ -7611,12 +7680,22 @@ function writeH1 (client, request) { const socket = client[kSocket] clearIdleSocketValidation(socket) - const abort = (err) => { - if (request.aborted || request.completed) { + /** + * @param {Error} [error] + */ + const abort = (error) => { + if (request.aborted) { return } - util.errorRequest(client, request, err || new RequestAbortedError()) + if (request.completed) { + if (request.upgrade || request.method === 'CONNECT') { + util.destroy(socket, new InformationalError('aborted')) + } + return + } + + util.errorRequest(client, request, error || new RequestAbortedError()) util.destroy(body) util.destroy(socket, new InformationalError('aborted')) @@ -8073,6 +8152,7 @@ module.exports = connectH1 const assert = __nccwpck_require__(4589) +const { errorMonitor } = __nccwpck_require__(8474) const { pipeline } = __nccwpck_require__(7075) const util = __nccwpck_require__(3440) const { @@ -8149,6 +8229,15 @@ 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 @@ -8198,8 +8287,6 @@ 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) @@ -8273,8 +8360,6 @@ function resumeH2 (client) { } function onHttp2SessionError (err) { - assert(err.code !== 'ERR_TLS_CERT_ALTNAME_INVALID') - this[kSocket][kError] = err this[kClient][kOnError](err) } @@ -8369,22 +8454,32 @@ function writeH2 (client, request) { headers[HTTP2_HEADER_AUTHORITY] = host || `${hostname}${port ? `:${port}` : ''}` headers[HTTP2_HEADER_METHOD] = method - const abort = (err) => { - if (request.aborted || request.completed) { + /** + * @param {Error} [error] + */ + const abort = (error) => { + if (request.aborted) { + 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, err) + util.errorRequest(client, request, error) if (stream != null) { - util.destroy(stream, err) + util.destroy(stream, error) } // 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, err) + util.destroy(body, error) client[kQueue][client[kRunningIdx]++] = null client[kResume]() } @@ -8403,25 +8498,57 @@ 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 - if (stream.id && !stream.pending) { - request.onUpgrade(null, null, stream) - ++session[kOpenStreams] - client[kQueue][client[kRunningIdx]++] = null - } else { - stream.once('ready', () => { + /** + * @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) - ++session[kOpenStreams] - client[kQueue][client[kRunningIdx]++] = null - }) + } catch (error) { + stream.off('response', onResponse) + abort(error) + return + } + + if (request.aborted) { + return + } + + stream.off('error', abort) + stream.once(errorMonitor, onUpgradeError) + 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() }) @@ -8930,9 +9057,10 @@ class Client extends DispatcherBase { // h2 maxConcurrentStreams, allowH2, - webSocket + webSocket, + eventSource } = {}) { - super({ webSocket }) + super({ webSocket, eventSource }) if (keepAlive !== undefined) { throw new InvalidArgumentError('unsupported keepAlive, use pipelining=0 instead') @@ -9453,6 +9581,7 @@ module.exports = Client +const buffer = __nccwpck_require__(4573) const Dispatcher = __nccwpck_require__(883) const { ClientDestroyedError, @@ -9465,6 +9594,7 @@ 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) { @@ -9475,6 +9605,7 @@ class DispatcherBase extends Dispatcher { this[kClosed] = false this[kOnClosed] = [] this[kWebSocketOptions] = opts?.webSocket ?? {} + this[kEventSourceOptions] = opts?.eventSource ?? {} } get webSocketOptions () { @@ -9484,6 +9615,12 @@ class DispatcherBase extends Dispatcher { } } + get eventSourceOptions () { + return { + maxEventSize: this[kEventSourceOptions].maxEventSize ?? buffer.kStringMaxLength + } + } + get destroyed () { return this[kDestroyed] } @@ -11235,7 +11372,10 @@ class RetryHandler { this.retryCount += 1 if (statusCode >= 300) { - if (this.retryOpts.statusCodes.includes(statusCode) === false) { + // 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) return this.handler.onHeaders( @@ -15839,6 +15979,7 @@ module.exports = { /***/ ((module, __unused_webpack_exports, __nccwpck_require__) => { +const buffer = __nccwpck_require__(4573) const { Transform } = __nccwpck_require__(7075) const { isASCIINumber, isValidLastEventId } = __nccwpck_require__(4811) @@ -15863,11 +16004,19 @@ 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 @@ -15906,6 +16055,12 @@ function isFieldName (line, length, field) { return true } +function createMaxEventSizeExceededError () { + const error = new Error('EventSource message size exceeded') + error.aborted = false + return error +} + /** * @typedef {object} EventSourceStreamEvent * @type {object} @@ -15954,6 +16109,13 @@ class EventSourceStream extends Transform { 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, @@ -15964,6 +16126,7 @@ class EventSourceStream extends Transform { /** * @param {object} options + * @param {number} [options.maxEventSize] * @param {eventSourceSettings} options.eventSourceSettings * @param {Function} [options.push] */ @@ -15975,6 +16138,12 @@ 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 } @@ -15992,6 +16161,14 @@ 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 + } this.chunks.push(chunk) // Strip leading byte-order-mark if we opened the stream and started @@ -16070,7 +16247,12 @@ class EventSourceStream extends Transform { // In any case, we can process the line as we reached an // end-of-line character - this.parseLine(this.readLine(), this.event) + try { + this.parseLine(this.readLine(), this.event) + } catch (error) { + callback(error) + return + } this.consumeCurrentByte() // 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 @@ -16079,15 +16261,28 @@ 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() } callback() } + _destroy (error, callback) { + this.chunks.length = 0 + this.clearEvent() + super._destroy(error, callback) + } + /** * @param {Buffer} line - * @param {EventStreamEvent} event + * @param {EventSourceStreamEvent} event */ parseLine (line, event) { // If the line is empty (a blank line) @@ -16121,6 +16316,13 @@ class EventSourceStream extends Transform { } 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) { @@ -16128,11 +16330,13 @@ class EventSourceStream extends Transform { } else { event.data += `\n${value}` } + this.dataLineCount++ return } if (isFieldName(line, fieldLength, RETRY)) { if (isASCIINumberBytes(line, valueStart)) { + this.setEventFieldSize('retry', line.length - valueStart) event.retry = line.toString('utf8', valueStart) } return @@ -16140,20 +16344,29 @@ class EventSourceStream extends Transform { 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)) { - const value = line.toString('utf8', valueStart) - - if (value.length > 0) { - event.event = value + 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 + } + /** * @param {EventSourceStreamEvent} event */ @@ -16184,6 +16397,12 @@ class EventSourceStream extends Transform { 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 () { @@ -16219,14 +16438,16 @@ class EventSourceStream extends Transform { syncLineStartToCursor () { this.lineChunkIndex = this.chunkIndex this.linePos = this.pos + this.lineLength = 0 this.dropConsumedChunks() } dropConsumedChunks () { - while (this.lineChunkIndex > 0) { - this.chunks.shift() - this.lineChunkIndex-- - this.chunkIndex-- + 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) { @@ -16351,6 +16572,7 @@ 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 @@ -16632,6 +16854,7 @@ class EventSource extends EventTarget { const eventSourceStream = new EventSourceStream({ eventSourceSettings: this.#state, + maxEventSize: this.#dispatcher.eventSourceOptions?.maxEventSize, push: (event) => { this.dispatchEvent(createFastMessageEvent( event.type, @@ -16810,7 +17033,8 @@ webidl.converters.EventSourceInitDict = webidl.dictionaryConverter([ }, { key: 'dispatcher', // undici only - converter: webidl.converters.any + converter: webidl.converters.any, + defaultValue: () => getGlobalDispatcher() } ]) @@ -21906,8 +22130,10 @@ async function httpNetworkFetch ( // 19. Run these steps in parallel: // 1. Run these steps, but abort when fetchParams is canceled: - fetchParams.controller.onAborted = onAborted - fetchParams.controller.on('terminated', onAborted) + if (!fetchParams.controller.resume) { + fetchParams.controller.on('terminated', onAborted) + } + fetchParams.controller.resume = async () => { // 1. While true while (true) { @@ -22076,8 +22302,6 @@ async function httpNetworkFetch ( } location = headersList.get('location', true) - this.body = new Readable({ read: resume }) - const decoders = [] const willFollow = location && request.redirect === 'follow' && @@ -22095,7 +22319,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) { - reject(new Error(`too many content-encodings in response: ${codings.length}, maximum allowed is ${maxContentEncodings}`)) + this.abort(new Error(`too many content-encodings in response: ${codings.length}, maximum allowed is ${maxContentEncodings}`)) return true } @@ -22128,6 +22352,8 @@ async function httpNetworkFetch ( } } + this.body = new Readable({ read: resume }) + const onError = this.onError.bind(this) resolve({ @@ -22174,10 +22400,6 @@ 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) @@ -28652,6 +28874,20 @@ 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)) { diff --git a/package-lock.json b/package-lock.json index cae2ff7..001cfd4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1982,9 +1982,9 @@ } }, "node_modules/undici": { - "version": "6.28.1", - "resolved": "https://registry.npmjs.org/undici/-/undici-6.28.1.tgz", - "integrity": "sha512-zWpdTVD54H48CIybL0rWQ3ukpb9d23wM7eH5RtfdmeP70cWHNjtfo7P4vZX+5CoDcO53J4Pu5uXp7lNfjc6DRA==", + "version": "6.29.1", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.29.1.tgz", + "integrity": "sha512-EvRlUt/QqAEvXdQeRMYUfwOQsfzpDztykrG2owGEE/rBOBoYv9uF39ZzsNDh4lTFHhTr2XOZjKkYgueD12ZmGA==", "license": "MIT", "engines": { "node": ">=18.17"