From 2170bb7b0efbf2c3d0215d05e46046b2768c9df0 Mon Sep 17 00:00:00 2001 From: satsukies Date: Fri, 30 Oct 2026 12:33:25 +0900 Subject: [PATCH 1/2] chore(deps): rebuild dist for undici 7.30.1 --- dist/index.js | 1102 ++++++++++++++++++++++++++++++++++++------------- 1 file changed, 810 insertions(+), 292 deletions(-) diff --git a/dist/index.js b/dist/index.js index 1a171d8..1a4b427 100644 --- a/dist/index.js +++ b/dist/index.js @@ -65411,16 +65411,22 @@ const SessionCache = class WeakSessionCache { if (this._sessionCache.has(sessionKey)) { this._sessionCache.delete(sessionKey) } else if (this._sessionCache.size >= this._maxCachedSessions) { + // Make room for the new session: prefer an entry whose session has + // already been collected, otherwise evict the oldest one. + let evicted = false for (const [key, ref] of this._sessionCache) { if (ref.deref() === undefined) { this._sessionCache.delete(key) - return + evicted = true + break } } - const oldest = this._sessionCache.keys().next() - if (!oldest.done) { - this._sessionCache.delete(oldest.value) + if (!evicted) { + const oldest = this._sessionCache.keys().next() + if (!oldest.done) { + this._sessionCache.delete(oldest.value) + } } } @@ -66704,11 +66710,32 @@ class Request { } } - onUpgrade (statusCode, headers, socket) { + /** + * @param {number} statusCode + * @param {Buffer[]|string[]} 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 (channels.headers.hasSubscribers) { + channels.headers.publish({ request: this, response: { statusCode, headers, statusText } }) + } + + const result = this[kHandler].onUpgrade(statusCode, headers, socket) + + if (!this.aborted) { + this.completed = true + if (channels.trailers.hasSubscribers) { + channels.trailers.publish({ request: this, trailers: [] }) + } + } + + return result } onComplete (trailers) { @@ -67529,6 +67556,7 @@ module.exports = { kDestroy: Symbol('destroy'), kDispatch: Symbol('dispatch'), kUrl: Symbol('url'), + kRedirectCacheControl: Symbol('redirect cache control'), kWriting: Symbol('writing'), kResuming: Symbol('resuming'), kQueue: Symbol('queue'), @@ -68797,7 +68825,7 @@ function defaultFactory (origin, opts) { } class Agent extends DispatcherBase { - constructor ({ factory = defaultFactory, maxOrigins = Infinity, connect, ...options } = {}) { + constructor ({ factory = defaultFactory, maxOrigins = Infinity, connect, tls, ...options } = {}) { if (typeof factory !== 'function') { throw new InvalidArgumentError('factory must be a function.') } @@ -68816,7 +68844,11 @@ class Agent extends DispatcherBase { connect = { ...connect } } - this[kOptions] = { ...util.deepClone(options), maxOrigins, connect } + if (tls && typeof tls !== 'function') { + tls = { ...tls } + } + + this[kOptions] = { ...util.deepClone(options), maxOrigins, connect, tls } this[kFactory] = factory this[kClients] = new Map() this[kOrigins] = new Set() @@ -69239,8 +69271,14 @@ function lazyllhttp () { let mod - // We disable wasm SIMD on ppc64 as it seems to be broken on Power 9 architectures. - let useWasmSIMD = process.arch !== 'ppc64' + // We disable wasm SIMD on older versions of Node.js on ppc64 that are broken on Power >=9 architectures. + let useWasmSIMD = true + if (process.arch === 'ppc64') { + const [major, minor] = process.versions.node.split('.').map(n => parseInt(n, 10)) + if (major < 24 || (major === 24 && minor < 12)) { + useWasmSIMD = false + } + } // The Env Variable UNDICI_NO_WASM_SIMD allows explicitly overriding the default behavior if (process.env.UNDICI_NO_WASM_SIMD === '1') { useWasmSIMD = false @@ -69691,7 +69729,7 @@ class Parser { * @param {Buffer} head */ onUpgrade (head) { - const { upgrade, client, socket, headers, statusCode } = this + const { upgrade, client, socket, headers, statusCode, statusText } = this assert(upgrade) assert(client[kSocket] === socket) @@ -69726,8 +69764,9 @@ class Parser { client.emit('disconnect', client[kUrl], [client], new InformationalError('upgrade')) try { - request.onUpgrade(statusCode, headers, socket) + request.onUpgrade(statusCode, headers, socket, statusText) } catch (err) { + util.errorRequest(client, request, err) util.destroy(socket, err) } @@ -70091,8 +70130,6 @@ function connectH1 (client, socket) { } function onHttpSocketError (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 @@ -71210,8 +71247,6 @@ function onHttp2SendPing (session) { } function onHttp2SessionError (err) { - assert(err.code !== 'ERR_TLS_CERT_ALTNAME_INVALID') - this[kSocket][kError] = err this[kClient][kOnError](err) } @@ -71353,8 +71388,6 @@ function onHttp2SocketClose () { } function onHttp2SocketError (err) { - assert(err.code !== 'ERR_TLS_CERT_ALTNAME_INVALID') - this[kError] = err this[kClient][kOnError](err) @@ -71492,13 +71525,17 @@ function writeH2 (client, request) { stream = session.request(headers, { endStream: false, signal }) stream[kHTTP2Stream] = true + ++session[kOpenStreams] stream.once('response', (headers, _flags) => { const { [HTTP2_HEADER_STATUS]: statusCode, ...realHeaders } = headers - request.onUpgrade(statusCode, parseH2Headers(realHeaders), stream) - - ++session[kOpenStreams] + try { + request.onUpgrade(statusCode, parseH2Headers(realHeaders), stream) + } catch (err) { + abort(err) + return + } completeRequest(client, request) }) @@ -71527,11 +71564,16 @@ function writeH2 (client, request) { // We disabled endStream to allow the user to write to the stream stream = session.request(headers, { endStream: false, signal }) stream[kHTTP2Stream] = true + ++session[kOpenStreams] stream.on('response', headers => { const { [HTTP2_HEADER_STATUS]: statusCode, ...realHeaders } = headers - request.onUpgrade(statusCode, parseH2Headers(realHeaders), stream) - ++session[kOpenStreams] + try { + request.onUpgrade(statusCode, parseH2Headers(realHeaders), stream) + } catch (err) { + abort(err) + return + } completeRequest(client, request) }) stream.on('error', abort) @@ -72091,7 +72133,8 @@ class Client extends DispatcherBase { initialWindowSize, connectionWindowSize, pingInterval, - webSocket + webSocket, + eventSource } = {}) { if (keepAlive !== undefined) { throw new InvalidArgumentError('unsupported keepAlive, use pipelining=0 instead') @@ -72199,7 +72242,7 @@ class Client extends DispatcherBase { throw new InvalidArgumentError('pingInterval must be a positive integer, greater or equal to 0') } - super({ webSocket }) + super({ webSocket, eventSource }) if (typeof connect !== 'function') { connect = buildConnector({ @@ -72652,6 +72695,7 @@ module.exports = Client "use strict"; +const buffer = __nccwpck_require__(4573) const Dispatcher = __nccwpck_require__(883) const UnwrapHandler = __nccwpck_require__(2365) const { @@ -72664,6 +72708,7 @@ const { kDestroy, kClose, kClosed, kDestroyed, kDispatch } = __nccwpck_require__ const kOnDestroyed = Symbol('onDestroyed') const kOnClosed = Symbol('onClosed') const kWebSocketOptions = Symbol('webSocketOptions') +const kEventSourceOptions = Symbol('eventSourceOptions') class DispatcherBase extends Dispatcher { /** @type {boolean} */ @@ -72684,6 +72729,7 @@ class DispatcherBase extends Dispatcher { constructor (opts) { super() this[kWebSocketOptions] = opts?.webSocket ?? {} + this[kEventSourceOptions] = opts?.eventSource ?? {} } /** @@ -72696,6 +72742,15 @@ class DispatcherBase extends Dispatcher { } } + /** + * @returns {import('../../types/client').Client.EventSourceOptions} + */ + get eventSourceOptions () { + return { + maxEventSize: this[kEventSourceOptions].maxEventSize ?? buffer.kStringMaxLength + } + } + /** @returns {boolean} */ get destroyed () { return this[kDestroyed] @@ -74496,6 +74551,7 @@ module.exports = { const util = __nccwpck_require__(3440) +const { kRedirectCacheControl } = __nccwpck_require__(8824) const { parseCacheControlHeader, hasInvalidCacheControlDirective, @@ -74508,6 +74564,19 @@ const { parseHttpDate } = __nccwpck_require__(5453) function noop () {} +/** + * @param {string | URL} a + * @param {string | URL} b + * @returns {boolean} + */ +function sameOrigin (a, b) { + try { + return new URL(String(a)).origin === new URL(String(b)).origin + } catch { + return String(a) === String(b) + } +} + // Status codes that we can use some heuristics on to cache const HEURISTICALLY_CACHEABLE_STATUS_CODES = [ 200, 203, 204, 206, 300, 301, 308, 404, 405, 410, 414, 501 @@ -74646,6 +74715,14 @@ class CacheHandler { */ #writeStream + /** + * The URLs visited while following redirects, shared by reference from the + * redirect handler through the request context. Carries the suppressed hops' + * cache-control under {@link kRedirectCacheControl}. + * @type {URL[] | undefined} + */ + #history + /** * @param {import('../../types/cache-interceptor.d.ts').default.CacheHandlerOptions} opts * @param {import('../../types/cache-interceptor.d.ts').default.CacheKey} cacheKey @@ -74662,6 +74739,9 @@ class CacheHandler { onRequestStart (controller, context) { this.#writeStream?.destroy() this.#writeStream = undefined + // The redirect handler mutates this shared history as redirects are + // followed, allowing the cache to identify the response's actual origin. + this.#history = context?.history this.#handler.onRequestStart?.(controller, context) } @@ -74701,6 +74781,29 @@ class CacheHandler { return downstreamOnHeaders() } + // Never store a response produced by a different origin under the original + // request's cache key. + if (this.#history && this.#history.length > 0) { + const finalUrl = this.#history[this.#history.length - 1] + if (!sameOrigin(finalUrl.origin, this.#cacheKey.origin)) { + return downstreamOnHeaders() + } + } + + // Preserve restrictive cache policy from suppressed redirect responses. + const redirectCacheControl = this.#history?.[kRedirectCacheControl] + if (redirectCacheControl) { + for (const hopCacheControl of redirectCacheControl) { + const hopDirectives = hopCacheControl ? parseCacheControlHeader(hopCacheControl) : {} + if ( + hopDirectives['no-store'] === true || + (this.#cacheType === 'shared' && hopDirectives.private === true) + ) { + return downstreamOnHeaders() + } + } + } + const cacheControlHeader = resHeaders['cache-control'] const cacheControlDirectives = cacheControlHeader ? parseCacheControlHeader(cacheControlHeader) : {} @@ -75502,6 +75605,7 @@ const DEFAULT_MAX_BUFFER_SIZE = 5 * 1024 * 1024 * @property {number} bufferedBytes * @property {object | null} pendingTrailers * @property {boolean} done + * @property {boolean} [pendingStart] onRequestStart deferred until the primary request's context is known */ /** @@ -75516,6 +75620,20 @@ class DeduplicationHandler { */ #primaryHandler + /** + * Whether the primary request has started (onRequestStart received). Until + * then the request context is unknown and cannot be given to waiting handlers. + * @type {boolean} + */ + #requestStarted = false + + /** + * The primary request context, replayed to waiting handlers so they observe + * the same redirect history as the primary. + * @type {any} + */ + #context = null + /** * @type {WaitingHandler[]} */ @@ -75598,20 +75716,30 @@ class DeduplicationHandler { const waitingController = waitingHandler.controller try { - handler.onRequestStart?.(waitingController, null) + if (this.#requestStarted) { + // The primary request has started, so its context (including the + // redirect history) is known and can be delivered immediately. + handler.onRequestStart?.(waitingController, this.#context) - if (waitingController.aborted) { - waitingHandler.done = true - return true - } + if (waitingController.aborted) { + waitingHandler.done = true + return true + } - if (this.#responseStarted) { - handler.onResponseStart?.( - waitingController, - this.#statusCode, - this.#headers, - this.#statusMessage - ) + if (this.#responseStarted) { + handler.onResponseStart?.( + waitingController, + this.#statusCode, + this.#headers, + this.#statusMessage + ) + } + } else { + // The primary request has not started yet. Defer onRequestStart until + // its context is known, so the waiting handler observes the same + // redirect history as the primary (a cache handler relies on it to + // reject cross-origin redirect responses). + waitingHandler.pendingStart = true } } catch { // Ignore errors from waiting handlers @@ -75632,6 +75760,24 @@ class DeduplicationHandler { */ onRequestStart (controller, context) { this.#controller = controller + if (!this.#requestStarted) { + this.#requestStarted = true + this.#context = context + // Deliver the now-known context to handlers that joined before the + // primary request started. + for (const waitingHandler of this.#waitingHandlers) { + if (waitingHandler.pendingStart && !waitingHandler.done && !waitingHandler.controller.aborted) { + waitingHandler.pendingStart = false + try { + waitingHandler.handler.onRequestStart?.(waitingHandler.controller, context) + } catch { + // Ignore errors from waiting handlers + waitingHandler.done = true + } + } + } + this.#pruneDoneWaitingHandlers() + } this.#primaryHandler.onRequestStart?.(controller, context) } @@ -75805,7 +75951,8 @@ class DeduplicationHandler { bufferedChunks: [], bufferedBytes: 0, pendingTrailers: null, - done: false + done: false, + pendingStart: false } const state = { @@ -75955,7 +76102,7 @@ module.exports = DeduplicationHandler const util = __nccwpck_require__(3440) -const { kBodyUsed } = __nccwpck_require__(8824) +const { kBodyUsed, kRedirectCacheControl } = __nccwpck_require__(8824) const assert = __nccwpck_require__(4589) const { InvalidArgumentError } = __nccwpck_require__(8707) const EE = __nccwpck_require__(8474) @@ -76001,6 +76148,11 @@ class RedirectHandler { this.maxRedirections = maxRedirections this.handler = handler this.history = [] + // Retain cache-control from suppressed redirect hops for an outer cache. + Object.defineProperty(this.history, kRedirectCacheControl, { + value: [], + enumerable: false + }) if (util.isStream(this.opts.body)) { // TODO (fix): Provide some way for the user to cache the file to e.g. /tmp @@ -76084,6 +76236,8 @@ class RedirectHandler { return } + this.history[kRedirectCacheControl].push(headers['cache-control']) + const { origin, pathname, search } = util.parseURL(new URL(this.location, this.opts.origin && new URL(this.opts.path, this.opts.origin))) const path = search ? `${pathname}${search}` : pathname @@ -76235,6 +76389,55 @@ function validatePartialResponseContentLength (headers, range, statusCode, retry } } +// A stable controller handed to the downstream handler for the lifetime of the +// request. Each transparent retry/resume is a separate dispatch with its own +// connection controller. The proxy always forwards to the active connection +// while preserving a downstream pause across controller replacement. +class RetryController { + #paused = false + #target = null + + set target (target) { + this.#target = target + if (this.#paused) { + target?.pause() + } + } + + get target () { return this.#target } + + pause () { + this.#paused = true + this.#target?.pause() + } + + resume () { + this.#paused = false + this.#target?.resume() + } + + abort (reason) { + this.#target?.abort(reason) + } + + get paused () { return this.#paused || (this.#target?.paused ?? false) } + get aborted () { return this.#target?.aborted ?? false } + get reason () { return this.#target?.reason ?? null } + get rawHeaders () { return this.#target?.rawHeaders ?? null } + set rawHeaders (value) { + if (this.#target) { + this.#target.rawHeaders = value + } + } + + get rawTrailers () { return this.#target?.rawTrailers ?? null } + set rawTrailers (value) { + if (this.#target) { + this.#target.rawTrailers = value + } + } +} + class RetryHandler { constructor (opts, { dispatch, handler }) { const { retryOptions, ...dispatchOpts } = opts @@ -76289,6 +76492,7 @@ class RetryHandler { this.start = 0 this.end = null this.etag = null + this.controllerProxy = new RetryController() } onResponseStartWithRetry (controller, statusCode, headers, statusMessage, err) { @@ -76299,11 +76503,11 @@ class RetryHandler { // The downstream handler already received the response from an // earlier attempt. Forwarding this response would replace the // downstream body and leave the original body pending forever. - this.handler.onResponseError?.(controller, err) + this.handler.onResponseError?.(this.controllerProxy, err) } else { this.headersSent = true this.checkpointResponseEnd(headers) - this.handler.onResponseStart?.(controller, statusCode, headers, statusMessage) + this.handler.onResponseStart?.(this.controllerProxy, statusCode, headers, statusMessage) } } else { this.error = err @@ -76315,7 +76519,7 @@ class RetryHandler { if (isDisturbed(this.opts.body)) { this.headersSent = true this.checkpointResponseEnd(headers) - this.handler.onResponseStart?.(controller, statusCode, headers, statusMessage) + this.handler.onResponseStart?.(this.controllerProxy, statusCode, headers, statusMessage) return } @@ -76325,11 +76529,11 @@ class RetryHandler { // The downstream handler already received the response from an // earlier attempt. Forwarding this response would replace the // downstream body and leave the original body pending forever. - this.handler.onResponseError?.(controller, passedErr) + this.handler.onResponseError?.(this.controllerProxy, passedErr) } else { this.headersSent = true this.checkpointResponseEnd(headers) - this.handler.onResponseStart?.(controller, statusCode, headers, statusMessage) + this.handler.onResponseStart?.(this.controllerProxy, statusCode, headers, statusMessage) } controller.resume() return @@ -76365,13 +76569,14 @@ class RetryHandler { } onRequestStart (controller, context) { + this.controllerProxy.target = controller if (!this.headersSent) { - this.handler.onRequestStart?.(controller, context) + this.handler.onRequestStart?.(this.controllerProxy, context) } } - onRequestUpgrade (controller, statusCode, headers, socket) { - this.handler.onRequestUpgrade?.(controller, statusCode, headers, socket) + onRequestUpgrade (_controller, statusCode, headers, socket) { + this.handler.onRequestUpgrade?.(this.controllerProxy, statusCode, headers, socket) } static [kRetryHandlerDefaultRetry] (err, { state, opts }, cb) { @@ -76502,7 +76707,7 @@ class RetryHandler { if (range == null) { this.headersSent = true this.handler.onResponseStart?.( - controller, + this.controllerProxy, statusCode, headers, statusMessage @@ -76551,7 +76756,7 @@ class RetryHandler { this.headersSent = true this.handler.onResponseStart?.( - controller, + this.controllerProxy, statusCode, headers, statusMessage @@ -76564,30 +76769,30 @@ class RetryHandler { } } - onResponseData (controller, chunk) { + onResponseData (_controller, chunk) { if (this.error) { return } this.start += chunk.length - this.handler.onResponseData?.(controller, chunk) + this.handler.onResponseData?.(this.controllerProxy, chunk) } - onResponseEnd (controller, trailers) { + onResponseEnd (_controller, trailers) { if (this.error && this.retryOpts.throwOnError) { throw this.error } if (!this.error) { this.retryCount = 0 - return this.handler.onResponseEnd?.(controller, trailers) + return this.handler.onResponseEnd?.(this.controllerProxy, trailers) } - this.retry(controller) + this.retry() } - retry (controller) { + retry () { if (this.start !== 0) { const headers = { range: `bytes=${this.start}-${this.end ?? ''}` } @@ -76609,23 +76814,23 @@ class RetryHandler { this.retryCountCheckpoint = this.retryCount this.dispatch(this.opts, this) } catch (err) { - this.handler.onResponseError?.(controller, err) + this.handler.onResponseError?.(this.controllerProxy, err) } } onResponseError (controller, err) { if (controller?.aborted || isDisturbed(this.opts.body) || (this.headersSent && !this.resume)) { - this.handler.onResponseError?.(controller, err) + this.handler.onResponseError?.(this.controllerProxy, err) return } function shouldRetry (returnedErr) { if (!returnedErr) { - this.retry(controller) + this.retry() return } - this.handler?.onResponseError?.(controller, returnedErr) + this.handler?.onResponseError?.(this.controllerProxy, returnedErr) } // We reconcile in case of a mix between network errors @@ -77527,6 +77732,60 @@ const { runtimeFeatures } = __nccwpck_require__(313) /** @typedef {import('node:stream').Transform} Controller */ /** @typedef {Transform&import('node:zlib').Zlib} DecompressorStream */ +class DecompressController { + #onPause + #onResume + #onAbort + #paused = false + + constructor (onPause, onResume, onAbort) { + this.#onPause = onPause + this.#onResume = onResume + this.#onAbort = onAbort + this.target = null + } + + pause () { + if (this.#paused) { + return + } + + this.#paused = true + this.#onPause() + } + + resume () { + if (!this.#paused) { + return + } + + this.#paused = false + this.#onResume() + } + + abort (reason) { + this.target?.abort(reason) + this.#onAbort(reason) + } + + get paused () { return this.#paused } + get aborted () { return this.target?.aborted ?? false } + get reason () { return this.target?.reason ?? null } + get rawHeaders () { return this.target?.rawHeaders ?? null } + set rawHeaders (value) { + if (this.target) { + this.target.rawHeaders = value + } + } + + get rawTrailers () { return this.target?.rawTrailers ?? null } + set rawTrailers (value) { + if (this.target) { + this.target.rawTrailers = value + } + } +} + /** @type {Record DecompressorStream>} */ const supportedEncodings = { gzip: createGunzip, @@ -77539,7 +77798,7 @@ const supportedEncodings = { } const defaultSkipStatusCodes = /** @type {const} */ ([204, 304]) -const defaultMaxSize = 64 * 1024 * 1024 +const defaultMaxSize = 0 /** * Limits the output of one stage in a decompression chain. @@ -77571,7 +77830,7 @@ let warningEmitted = /** @type {boolean} */ (false) * @typedef {Object} DecompressHandlerOptions * @property {number[]|Readonly} [skipStatusCodes=[204, 304]] - List of status codes to skip decompression for * @property {boolean} [skipErrorResponses] - Whether to skip decompression for error responses (status codes >= 400) - * @property {number} [maxSize=67108864] - Maximum decompressed response size in bytes + * @property {number} [maxSize=0] - Maximum decompressed response size in bytes. 0 disables the limit */ class DecompressHandler extends DecoratorHandler { @@ -77591,16 +77850,140 @@ class DecompressHandler extends DecoratorHandler { #terminated = false /** @type {boolean} */ #inputEnded = false + /** @type {boolean} */ + #inputBackpressured = false + /** @type {boolean} */ + #upstreamPaused = false + /** @type {boolean} */ + #draining = false + /** @type {boolean} */ + #drainRequested = false + /** @type {boolean} */ + #completionPending = false + /** @type {DecompressorStream | undefined} */ + #finalDecompressor + /** @type {DecompressController} */ + #controller constructor (handler, { skipStatusCodes = defaultSkipStatusCodes, skipErrorResponses = true, maxSize = defaultMaxSize } = {}) { - if (!Number.isSafeInteger(maxSize) || maxSize < 1) { - throw new InvalidArgumentError('maxSize must be a positive integer') + if (!Number.isSafeInteger(maxSize) || maxSize < 0) { + throw new InvalidArgumentError('maxSize must be a non-negative integer') } super(handler) this.#skipStatusCodes = skipStatusCodes this.#skipErrorResponses = skipErrorResponses this.#maxSize = maxSize + this.#controller = new DecompressController( + () => this.#onDownstreamPause(), + () => this.#onDownstreamResume(), + reason => { + if (this.#inputEnded && !this.#terminated) { + this.onResponseError(this.#controller, reason) + } + } + ) + } + + #onDownstreamPause () { + this.#pauseUpstream() + } + + #onDownstreamResume () { + const drainWasDeferred = this.#draining + this.#drainOutput() + if (!drainWasDeferred) { + this.#resumeUpstreamIfNeeded() + this.#finishIfReady() + } + } + + #pauseUpstream () { + if (!this.#upstreamPaused && !this.#terminated) { + this.#upstreamPaused = true + this.#controller.target?.pause() + } + } + + #resumeUpstreamIfNeeded () { + if (this.#upstreamPaused && !this.#controller.paused && !this.#inputBackpressured) { + this.#upstreamPaused = false + if (!this.#inputEnded) { + this.#controller.target?.resume() + } + } + } + + #drainOutput () { + if (this.#terminated || this.#controller.paused || !this.#finalDecompressor) { + return + } + + if (this.#draining) { + this.#drainRequested = true + return + } + + this.#draining = true + try { + do { + this.#drainRequested = false + let chunk + while (!this.#terminated && !this.#controller.paused && (chunk = this.#finalDecompressor.read()) !== null) { + if (this.#maxSize > 0) { + const decompressedSize = this.#decompressedSize + chunk.length + if (decompressedSize > this.#maxSize) { + this.#fail(new ResponseExceededMaxSizeError( + `Decompressed response size (${decompressedSize}) exceeded maxSize (${this.#maxSize})` + )) + return + } + + this.#decompressedSize = decompressedSize + } + + try { + const result = super.onResponseData(this.#controller, chunk) + if (result === false && !this.#controller.paused) { + this.#controller.pause() + } + } catch (err) { + this.#fail(err) + return + } + } + } while (this.#drainRequested && !this.#terminated && !this.#controller.paused) + } finally { + this.#draining = false + } + + this.#resumeUpstreamIfNeeded() + this.#finishIfReady() + } + + #finishIfReady () { + if (this.#terminated || !this.#completionPending || this.#controller.paused || this.#draining) { + return + } + + this.#cleanupDecompressors() + try { + super.onResponseEnd(this.#controller, this.#trailers) + } catch (err) { + this.#fail(err) + return + } + this.#terminated = true + } + + #onDecompressionEnd () { + if (this.#terminated) { + return + } + + this.#completionPending = true + this.#drainOutput() + this.#finishIfReady() } /** @@ -77656,7 +78039,7 @@ class DecompressHandler extends DecoratorHandler { const streams = [] for (let i = 0; i < decompressors.length; i++) { streams.push(decompressors[i]) - if (i < decompressors.length - 1) { + if (i < decompressors.length - 1 && this.#maxSize > 0) { streams.push(createMaxSizeLimiter(this.#maxSize)) } } @@ -77666,11 +78049,10 @@ class DecompressHandler extends DecoratorHandler { /** * Stops decompression and reports an error. - * @param {Controller} controller - The controller to coordinate with * @param {Error} error - The decompression error * @returns {void} */ - #fail (controller, error) { + #fail (error) { if (this.#terminated) { return } @@ -77678,75 +78060,41 @@ class DecompressHandler extends DecoratorHandler { if (this.#inputEnded) { // The request is already marked complete once the compressed input ends, // so controller.abort() can no longer propagate decoder flush errors. - this.onResponseError(controller, error) + this.onResponseError(this.#controller, error) } else { - controller.abort(error) + this.#controller.abort(error) } } /** - * Sets up event handlers for a decompressor stream using readable events + * Sets up event handlers for the final decompressor stream. * @param {DecompressorStream} decompressor - The decompressor stream - * @param {Controller} controller - The controller to coordinate with * @returns {void} */ - #setupDecompressorEvents (decompressor, controller) { - decompressor.on('readable', () => { - if (this.#terminated) { - return - } - - let chunk - while ((chunk = decompressor.read()) !== null) { - const decompressedSize = this.#decompressedSize + chunk.length - if (decompressedSize > this.#maxSize) { - this.#fail(controller, new ResponseExceededMaxSizeError( - `Decompressed response size (${decompressedSize}) exceeded maxSize (${this.#maxSize})` - )) - return - } - - this.#decompressedSize = decompressedSize - const result = super.onResponseData(controller, chunk) - if (result === false) { - break - } - } - }) - - decompressor.on('error', (error) => { - this.#fail(controller, error) - }) + #setupDecompressorEvents (decompressor) { + this.#finalDecompressor = decompressor + decompressor.on('readable', () => this.#drainOutput()) + decompressor.on('error', (error) => this.#fail(error)) } /** * Sets up event handling for a single decompressor - * @param {Controller} controller - The controller to handle events * @returns {void} */ - #setupSingleDecompressor (controller) { + #setupSingleDecompressor () { const decompressor = this.#decompressors[0] - this.#setupDecompressorEvents(decompressor, controller) + this.#setupDecompressorEvents(decompressor) - decompressor.on('end', () => { - if (this.#terminated) { - return - } - - this.#terminated = true - this.#cleanupDecompressors() - super.onResponseEnd(controller, this.#trailers) - }) + decompressor.on('end', () => this.#onDecompressionEnd()) } /** * Sets up event handling for multiple chained decompressors using pipeline - * @param {Controller} controller - The controller to handle events * @returns {void} */ - #setupMultipleDecompressors (controller) { + #setupMultipleDecompressors () { const lastDecompressor = this.#decompressors[this.#decompressors.length - 1] - this.#setupDecompressorEvents(lastDecompressor, controller) + this.#setupDecompressorEvents(lastDecompressor) pipeline(this.#decompressors, (err) => { if (this.#terminated) { @@ -77754,13 +78102,26 @@ class DecompressHandler extends DecoratorHandler { } if (err) { - this.#fail(controller, err) + this.#fail(err) return } - this.#terminated = true - this.#cleanupDecompressors() - super.onResponseEnd(controller, this.#trailers) + this.#onDecompressionEnd() + }) + } + + #setupInputBackpressure () { + const decompressor = this.#decompressors[0] + decompressor.on('drain', () => { + if (this.#terminated) { + return + } + + this.#inputBackpressured = false + if (!this.#controller.paused) { + this.#drainOutput() + this.#resumeUpstreamIfNeeded() + } }) } @@ -77770,6 +78131,16 @@ class DecompressHandler extends DecoratorHandler { */ #cleanupDecompressors () { this.#decompressors.length = 0 + this.#finalDecompressor = undefined + } + + onRequestStart (controller, context) { + this.#controller.target = controller + return super.onRequestStart(this.#controller, context) + } + + onRequestUpgrade (controller, statusCode, headers, socket) { + return super.onRequestUpgrade(this.#controller, statusCode, headers, socket) } /** @@ -77784,14 +78155,14 @@ class DecompressHandler extends DecoratorHandler { // If content encoding is not supported or status code is in skip list if (this.#shouldSkipDecompression(contentEncoding, statusCode)) { - return super.onResponseStart(controller, statusCode, headers, statusMessage) + return super.onResponseStart(this.#controller, statusCode, headers, statusMessage) } const decompressors = this.#createDecompressionChain(contentEncoding.toLowerCase()) if (decompressors.length === 0) { this.#cleanupDecompressors() - return super.onResponseStart(controller, statusCode, headers, statusMessage) + return super.onResponseStart(this.#controller, statusCode, headers, statusMessage) } this.#decompressors = decompressors @@ -77799,8 +78170,8 @@ class DecompressHandler extends DecoratorHandler { // Remove compression headers since we're decompressing const { 'content-encoding': _, 'content-length': __, ...newHeaders } = headers - if (controller?.rawHeaders) { - const rawHeaders = controller.rawHeaders + if (this.#controller.rawHeaders) { + const rawHeaders = this.#controller.rawHeaders if (Array.isArray(rawHeaders)) { const filteredHeaders = [] @@ -77826,13 +78197,14 @@ class DecompressHandler extends DecoratorHandler { } } + this.#setupInputBackpressure() if (this.#decompressors.length === 1) { - this.#setupSingleDecompressor(controller) + this.#setupSingleDecompressor() } else { - this.#setupMultipleDecompressors(controller) + this.#setupMultipleDecompressors() } - return super.onResponseStart(controller, statusCode, newHeaders, statusMessage) + return super.onResponseStart(this.#controller, statusCode, newHeaders, statusMessage) } /** @@ -77842,10 +78214,13 @@ class DecompressHandler extends DecoratorHandler { */ onResponseData (controller, chunk) { if (this.#decompressors.length > 0) { - this.#decompressors[0].write(chunk) + if (!this.#decompressors[0].write(chunk)) { + this.#inputBackpressured = true + this.#pauseUpstream() + } return } - super.onResponseData(controller, chunk) + return super.onResponseData(this.#controller, chunk) } /** @@ -77860,7 +78235,7 @@ class DecompressHandler extends DecoratorHandler { this.#decompressors[0].end() return } - super.onResponseEnd(controller, trailers) + return super.onResponseEnd(this.#controller, trailers) } /** @@ -77878,7 +78253,7 @@ class DecompressHandler extends DecoratorHandler { decompressor.destroy() } this.#cleanupDecompressors() - super.onResponseError(controller, err) + super.onResponseError(this.#controller, err) } } @@ -85243,8 +85618,10 @@ function getCookies (headers) { const cookie = headers.get('cookie') + // A null prototype keeps a cookie named `__proto__` from hitting the + // Object.prototype setter, which would silently drop it. /** @type {Record} */ - const out = {} + const out = { __proto__: null } if (!cookie) { return out @@ -85510,220 +85887,221 @@ function parseSetCookie (header) { * @param {Object.} [cookieAttributeList={}] */ function parseUnparsedAttributes (unparsedAttributes, cookieAttributeList = {}) { - // 1. If the unparsed-attributes string is empty, skip the rest of - // these steps. - if (unparsedAttributes.length === 0) { - return cookieAttributeList - } + while (true) { + // 1. If the unparsed-attributes string is empty, skip the rest of + // these steps. + if (unparsedAttributes.length === 0) { + return cookieAttributeList + } - // 2. Discard the first character of the unparsed-attributes (which - // will be a %x3B (";") character). - assert(unparsedAttributes[0] === ';') - unparsedAttributes = unparsedAttributes.slice(1) + // 2. Discard the first character of the unparsed-attributes (which + // will be a %x3B (";") character). + assert(unparsedAttributes[0] === ';') + unparsedAttributes = unparsedAttributes.slice(1) - let cookieAv = '' + let cookieAv = '' - // 3. If the remaining unparsed-attributes contains a %x3B (";") - // character: - if (unparsedAttributes.includes(';')) { + // 3. If the remaining unparsed-attributes contains a %x3B (";") + // character: + if (unparsedAttributes.includes(';')) { // 1. Consume the characters of the unparsed-attributes up to, but // not including, the first %x3B (";") character. - cookieAv = collectASequenceOfCodePointsFast( - ';', - unparsedAttributes, - { position: 0 } - ) - unparsedAttributes = unparsedAttributes.slice(cookieAv.length) - } else { + cookieAv = collectASequenceOfCodePointsFast( + ';', + unparsedAttributes, + { position: 0 } + ) + unparsedAttributes = unparsedAttributes.slice(cookieAv.length) + } else { // Otherwise: - // 1. Consume the remainder of the unparsed-attributes. - cookieAv = unparsedAttributes - unparsedAttributes = '' - } + // 1. Consume the remainder of the unparsed-attributes. + cookieAv = unparsedAttributes + unparsedAttributes = '' + } - // Let the cookie-av string be the characters consumed in this step. + // Let the cookie-av string be the characters consumed in this step. - let attributeName = '' - let attributeValue = '' + let attributeName = '' + let attributeValue = '' - // 4. If the cookie-av string contains a %x3D ("=") character: - if (cookieAv.includes('=')) { + // 4. If the cookie-av string contains a %x3D ("=") character: + if (cookieAv.includes('=')) { // 1. The (possibly empty) attribute-name string consists of the // characters up to, but not including, the first %x3D ("=") // character, and the (possibly empty) attribute-value string // consists of the characters after the first %x3D ("=") // character. - const position = { position: 0 } + const position = { position: 0 } - attributeName = collectASequenceOfCodePointsFast( - '=', - cookieAv, - position - ) - attributeValue = cookieAv.slice(position.position + 1) - } else { + attributeName = collectASequenceOfCodePointsFast( + '=', + cookieAv, + position + ) + attributeValue = cookieAv.slice(position.position + 1) + } else { // Otherwise: - // 1. The attribute-name string consists of the entire cookie-av - // string, and the attribute-value string is empty. - attributeName = cookieAv - } + // 1. The attribute-name string consists of the entire cookie-av + // string, and the attribute-value string is empty. + attributeName = cookieAv + } - // 5. Remove any leading or trailing WSP characters from the attribute- - // name string and the attribute-value string. - attributeName = attributeName.trim() - attributeValue = attributeValue.trim() + // 5. Remove any leading or trailing WSP characters from the attribute- + // name string and the attribute-value string. + attributeName = attributeName.trim() + attributeValue = attributeValue.trim() - // 6. If the attribute-value is longer than 1024 octets, ignore the - // cookie-av string and return to Step 1 of this algorithm. - if (attributeValue.length > maxAttributeValueSize) { - return parseUnparsedAttributes(unparsedAttributes, cookieAttributeList) - } + // 6. If the attribute-value is longer than 1024 octets, ignore the + // cookie-av string and return to Step 1 of this algorithm. + if (attributeValue.length > maxAttributeValueSize) { + continue + } - // 7. Process the attribute-name and attribute-value according to the - // requirements in the following subsections. (Notice that - // attributes with unrecognized attribute-names are ignored.) - const attributeNameLowercase = attributeName.toLowerCase() + // 7. Process the attribute-name and attribute-value according to the + // requirements in the following subsections. (Notice that + // attributes with unrecognized attribute-names are ignored.) + const attributeNameLowercase = attributeName.toLowerCase() - // https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-rfc6265bis#section-5.4.1 - // If the attribute-name case-insensitively matches the string - // "Expires", the user agent MUST process the cookie-av as follows. - if (attributeNameLowercase === 'expires') { + // https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-rfc6265bis#section-5.4.1 + // If the attribute-name case-insensitively matches the string + // "Expires", the user agent MUST process the cookie-av as follows. + if (attributeNameLowercase === 'expires') { // 1. Let the expiry-time be the result of parsing the attribute-value // as cookie-date (see Section 5.1.1). - const expiryTime = new Date(attributeValue) + const expiryTime = new Date(attributeValue) - // 2. If the attribute-value failed to parse as a cookie date, ignore - // the cookie-av. + // 2. If the attribute-value failed to parse as a cookie date, ignore + // the cookie-av. - cookieAttributeList.expires = expiryTime - } else if (attributeNameLowercase === 'max-age') { + cookieAttributeList.expires = expiryTime + } else if (attributeNameLowercase === 'max-age') { // https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-rfc6265bis#section-5.4.2 // If the attribute-name case-insensitively matches the string "Max- // Age", the user agent MUST process the cookie-av as follows. - // 1. If the first character of the attribute-value is not a DIGIT or a - // "-" character, ignore the cookie-av. - const charCode = attributeValue.charCodeAt(0) + // 1. If the first character of the attribute-value is not a DIGIT or a + // "-" character, ignore the cookie-av. + const charCode = attributeValue.charCodeAt(0) - if ((charCode < 48 || charCode > 57) && attributeValue[0] !== '-') { - return parseUnparsedAttributes(unparsedAttributes, cookieAttributeList) - } + if ((charCode < 48 || charCode > 57) && attributeValue[0] !== '-') { + continue + } - // 2. If the remainder of attribute-value contains a non-DIGIT - // character, ignore the cookie-av. - if (!/^\d+$/.test(attributeValue)) { - return parseUnparsedAttributes(unparsedAttributes, cookieAttributeList) - } + // 2. If the remainder of attribute-value contains a non-DIGIT + // character, ignore the cookie-av. + if (!/^\d+$/.test(attributeValue)) { + continue + } - // 3. Let delta-seconds be the attribute-value converted to an integer. - const deltaSeconds = Number(attributeValue) + // 3. Let delta-seconds be the attribute-value converted to an integer. + const deltaSeconds = Number(attributeValue) - // 4. Let cookie-age-limit be the maximum age of the cookie (which - // SHOULD be 400 days or less, see Section 4.1.2.2). + // 4. Let cookie-age-limit be the maximum age of the cookie (which + // SHOULD be 400 days or less, see Section 4.1.2.2). - // 5. Set delta-seconds to the smaller of its present value and cookie- - // age-limit. - // deltaSeconds = Math.min(deltaSeconds * 1000, maxExpiresMs) + // 5. Set delta-seconds to the smaller of its present value and cookie- + // age-limit. + // deltaSeconds = Math.min(deltaSeconds * 1000, maxExpiresMs) - // 6. If delta-seconds is less than or equal to zero (0), let expiry- - // time be the earliest representable date and time. Otherwise, let - // the expiry-time be the current date and time plus delta-seconds - // seconds. - // const expiryTime = deltaSeconds <= 0 ? Date.now() : Date.now() + deltaSeconds + // 6. If delta-seconds is less than or equal to zero (0), let expiry- + // time be the earliest representable date and time. Otherwise, let + // the expiry-time be the current date and time plus delta-seconds + // seconds. + // const expiryTime = deltaSeconds <= 0 ? Date.now() : Date.now() + deltaSeconds - // 7. Append an attribute to the cookie-attribute-list with an - // attribute-name of Max-Age and an attribute-value of expiry-time. - cookieAttributeList.maxAge = deltaSeconds - } else if (attributeNameLowercase === 'domain') { + // 7. Append an attribute to the cookie-attribute-list with an + // attribute-name of Max-Age and an attribute-value of expiry-time. + cookieAttributeList.maxAge = deltaSeconds + } else if (attributeNameLowercase === 'domain') { // https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-rfc6265bis#section-5.4.3 // If the attribute-name case-insensitively matches the string "Domain", // the user agent MUST process the cookie-av as follows. - // 1. Let cookie-domain be the attribute-value. - let cookieDomain = attributeValue + // 1. Let cookie-domain be the attribute-value. + let cookieDomain = attributeValue - // 2. If cookie-domain starts with %x2E ("."), let cookie-domain be - // cookie-domain without its leading %x2E ("."). - if (cookieDomain[0] === '.') { - cookieDomain = cookieDomain.slice(1) - } + // 2. If cookie-domain starts with %x2E ("."), let cookie-domain be + // cookie-domain without its leading %x2E ("."). + if (cookieDomain[0] === '.') { + cookieDomain = cookieDomain.slice(1) + } - // 3. Convert the cookie-domain to lower case. - cookieDomain = cookieDomain.toLowerCase() + // 3. Convert the cookie-domain to lower case. + cookieDomain = cookieDomain.toLowerCase() - // 4. Append an attribute to the cookie-attribute-list with an - // attribute-name of Domain and an attribute-value of cookie-domain. - cookieAttributeList.domain = cookieDomain - } else if (attributeNameLowercase === 'path') { + // 4. Append an attribute to the cookie-attribute-list with an + // attribute-name of Domain and an attribute-value of cookie-domain. + cookieAttributeList.domain = cookieDomain + } else if (attributeNameLowercase === 'path') { // https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-rfc6265bis#section-5.4.4 // If the attribute-name case-insensitively matches the string "Path", // the user agent MUST process the cookie-av as follows. - // 1. If the attribute-value is empty or if the first character of the - // attribute-value is not %x2F ("/"): - let cookiePath = '' - if (attributeValue.length === 0 || attributeValue[0] !== '/') { + // 1. If the attribute-value is empty or if the first character of the + // attribute-value is not %x2F ("/"): + let cookiePath = '' + if (attributeValue.length === 0 || attributeValue[0] !== '/') { // 1. Let cookie-path be the default-path. - cookiePath = '/' - } else { + cookiePath = '/' + } else { // Otherwise: - // 1. Let cookie-path be the attribute-value. - cookiePath = attributeValue - } + // 1. Let cookie-path be the attribute-value. + cookiePath = attributeValue + } - // 2. Append an attribute to the cookie-attribute-list with an - // attribute-name of Path and an attribute-value of cookie-path. - cookieAttributeList.path = cookiePath - } else if (attributeNameLowercase === 'secure') { + // 2. Append an attribute to the cookie-attribute-list with an + // attribute-name of Path and an attribute-value of cookie-path. + cookieAttributeList.path = cookiePath + } else if (attributeNameLowercase === 'secure') { // https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-rfc6265bis#section-5.4.5 // If the attribute-name case-insensitively matches the string "Secure", // the user agent MUST append an attribute to the cookie-attribute-list // with an attribute-name of Secure and an empty attribute-value. - cookieAttributeList.secure = true - } else if (attributeNameLowercase === 'httponly') { + cookieAttributeList.secure = true + } else if (attributeNameLowercase === 'httponly') { // https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-rfc6265bis#section-5.4.6 // If the attribute-name case-insensitively matches the string // "HttpOnly", the user agent MUST append an attribute to the cookie- // attribute-list with an attribute-name of HttpOnly and an empty // attribute-value. - cookieAttributeList.httpOnly = true - } else if (attributeNameLowercase === 'samesite') { + cookieAttributeList.httpOnly = true + } else if (attributeNameLowercase === 'samesite') { // https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-rfc6265bis#section-5.4.7 // If the attribute-name case-insensitively matches the string // "SameSite", the user agent MUST process the cookie-av as follows: - const attributeValueLowercase = attributeValue.toLowerCase() + const attributeValueLowercase = attributeValue.toLowerCase() - // 1. If cookie-av's attribute-value is a case-insensitive match for - // "None", append an attribute to the cookie-attribute-list with an - // attribute-name of "SameSite" and an attribute-value of "None". - if (attributeValueLowercase === 'none') { - cookieAttributeList.sameSite = 'None' - } else if (attributeValueLowercase === 'strict') { + // 1. If cookie-av's attribute-value is a case-insensitive match for + // "None", append an attribute to the cookie-attribute-list with an + // attribute-name of "SameSite" and an attribute-value of "None". + if (attributeValueLowercase === 'none') { + cookieAttributeList.sameSite = 'None' + } else if (attributeValueLowercase === 'strict') { // 2. If cookie-av's attribute-value is a case-insensitive match for // "Strict", append an attribute to the cookie-attribute-list with // an attribute-name of "SameSite" and an attribute-value of // "Strict". - cookieAttributeList.sameSite = 'Strict' - } else if (attributeValueLowercase === 'lax') { + cookieAttributeList.sameSite = 'Strict' + } else if (attributeValueLowercase === 'lax') { // 3. If cookie-av's attribute-value is a case-insensitive match for // "Lax", append an attribute to the cookie-attribute-list with an // attribute-name of "SameSite" and an attribute-value of "Lax". - cookieAttributeList.sameSite = 'Lax' - } - } else { - cookieAttributeList.unparsed ??= [] + cookieAttributeList.sameSite = 'Lax' + } + } else { + cookieAttributeList.unparsed ??= [] - cookieAttributeList.unparsed.push(`${attributeName}=${attributeValue}`) - } + cookieAttributeList.unparsed.push(`${attributeName}=${attributeValue}`) + } // 8. Return to Step 1 of this algorithm. - return parseUnparsedAttributes(unparsedAttributes, cookieAttributeList) + } } module.exports = { @@ -86055,8 +86433,27 @@ function stringify (cookie) { out.push(`Path=${cookie.path}`) } - if (cookie.expires && cookie.expires.toString() !== 'Invalid Date') { - out.push(`Expires=${toIMFDate(cookie.expires)}`) + // A numeric 0 is the Unix epoch, not an absent value -- the same reason the + // Max-Age check above tests the type rather than truthiness. + // + // expires is Date | number, so the "Invalid Date" check only guards the Date + // form. A number that is not a valid time value (NaN, +/-Infinity, or outside + // the Date range) would otherwise reach toIMFDate, which splices undefined and + // NaN into the header value instead of failing. + if (cookie.expires != null) { + const expiresMs = cookie.expires instanceof Date + ? cookie.expires.getTime() + : cookie.expires + + // The largest time value a Date can represent, in milliseconds, i.e. + // 100,000,000 days either side of the epoch (ECMA-262 Date Time Values). + // Anything within it is safe for toIMFDate to format; anything beyond it + // produces an invalid Date and must be ignored instead. + const maxTimeValue = 8640000000000000 + + if (typeof expiresMs === 'number' && Number.isFinite(expiresMs) && Math.abs(expiresMs) <= maxTimeValue) { + out.push(`Expires=${toIMFDate(cookie.expires)}`) + } } if (cookie.sameSite) { @@ -86099,6 +86496,7 @@ module.exports = { "use strict"; +const buffer = __nccwpck_require__(4573) const { Transform } = __nccwpck_require__(7075) const { isASCIINumber, isValidLastEventId } = __nccwpck_require__(4811) @@ -86123,11 +86521,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 @@ -86166,6 +86572,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} @@ -86214,6 +86626,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, @@ -86225,6 +86644,7 @@ class EventSourceStream extends Transform { /** * @param {object} options * @param {boolean} [options.readableObjectMode] + * @param {number} [options.maxEventSize] * @param {eventSourceSettings} [options.eventSourceSettings] * @param {(chunk: any, encoding?: BufferEncoding | undefined) => boolean} [options.push] */ @@ -86236,6 +86656,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 } @@ -86253,6 +86679,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 @@ -86331,7 +86765,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 @@ -86340,12 +86779,25 @@ 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 {EventSourceStreamEvent} event @@ -86382,6 +86834,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) { @@ -86389,11 +86848,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 @@ -86401,20 +86862,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 */ @@ -86445,6 +86915,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 () { @@ -86480,14 +86956,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) { @@ -86612,6 +87090,7 @@ const { createFastMessageEvent } = __nccwpck_require__(5188) const { isNetworkError } = __nccwpck_require__(9051) const { kEnumerableProperty } = __nccwpck_require__(3440) const { environmentSettingsObject } = __nccwpck_require__(3168) +const { getGlobalDispatcher } = __nccwpck_require__(2581) let experimentalWarned = false @@ -86894,6 +87373,7 @@ class EventSource extends EventTarget { const eventSourceStream = new EventSourceStream({ eventSourceSettings: this.#state, + maxEventSize: this.#dispatcher.eventSourceOptions?.maxEventSize, push: (event) => { this.dispatchEvent(createFastMessageEvent( event.type, @@ -87078,7 +87558,8 @@ webidl.converters.EventSourceInitDict = webidl.dictionaryConverter([ }, { key: 'dispatcher', // undici only - converter: webidl.converters.any + converter: webidl.converters.any, + defaultValue: () => getGlobalDispatcher() }, { key: 'node', // undici only @@ -92259,8 +92740,6 @@ async function httpNetworkFetch ( } const location = headersList.get('location', true) - this.body = new Readable({ read: resume }) - const willFollow = location && request.redirect === 'follow' && redirectStatusSet.has(status) @@ -92278,7 +92757,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 } @@ -92316,6 +92795,8 @@ async function httpNetworkFetch ( } } + this.body = new Readable({ read: resume }) + const onError = this.onError.bind(this) resolve({ @@ -95819,6 +96300,19 @@ function collectASequenceOfCodePointsFast (char, input, position) { } const ASCII_WHITESPACE_REPLACE_REGEX = /[\u0009\u000A\u000C\u000D\u0020]/g // eslint-disable-line no-control-regex +const nonASCIIRegex = /[^\x00-\x7F]/ // eslint-disable-line no-control-regex + +/** + * @param {string} str + * @returns {string} + * + * @see https://infra.spec.whatwg.org/#ascii-lowercase + */ +function asciiLowercase (str) { + return nonASCIIRegex.test(str) + ? str.replace(/[A-Z]+/g, (upper) => upper.toLowerCase()) + : str.toLowerCase() +} /** * @param {string} data @@ -95983,6 +96477,7 @@ function serializeJavascriptValueToJSONString (value) { } module.exports = { + asciiLowercase, collectASequenceOfCodePoints, collectASequenceOfCodePointsFast, forgivingBase64, @@ -96006,6 +96501,7 @@ module.exports = { const assert = __nccwpck_require__(4589) const { runtimeFeatures } = __nccwpck_require__(313) +const { asciiLowercase } = __nccwpck_require__(8116) /** * @typedef {object} Metadata @@ -96070,14 +96566,12 @@ const getSRIHashAlgorithmIndex = /** @type {GetSRIHashAlgorithmIndex} */ (Map.pr /** * @typedef IsValidSRIHashAlgorithm + * @see https://w3c.github.io/webappsec-subresource-integrity/#valid-sri-hash-algorithm-token * @type {(algorithm: string) => algorithm is SRIHashAlgorithm} * @param {*} algorithm * @returns {algorithm is SRIHashAlgorithm} */ - -const isValidSRIHashAlgorithm = /** @type {IsValidSRIHashAlgorithm} */ ( - Map.prototype.has.bind(validSRIHashAlgorithmTokenSet) -) +const isValidSRIHashAlgorithm = (algorithm) => validSRIHashAlgorithmTokenSet.has(asciiLowercase(algorithm)) /** * @param {Uint8Array} bytes @@ -96233,7 +96727,8 @@ function parseMetadata (metadata) { // 8. Let metadata be the ordered map // «["alg" → algorithm, "val" → base64-value]». const metadata = { - alg: algorithm, + // Possible spec bug? + alg: asciiLowercase(algorithm), val: base64Value } @@ -97434,13 +97929,13 @@ function establishWebSocketConnection (url, protocols, client, handler, options) // The presence of a session property on the socket indicates HTTP2 // HTTP1 if (response.socket?.session == null) { - failWebsocketConnection(handler, 1002, 'Received network error or non-101 status code.', response.error) + failHandshake(handler, response, 1002, 'Received network error or non-101 status code.', response.error) return } // HTTP2 if (response.status !== 200) { - failWebsocketConnection(handler, 1002, 'Received network error or non-200 status code.', response.error) + failHandshake(handler, response, 1002, 'Received network error or non-200 status code.', response.error) return } } @@ -97455,7 +97950,7 @@ function establishWebSocketConnection (url, protocols, client, handler, options) // header list results in null, failure, or the empty byte // sequence, then fail the WebSocket connection. if (protocols.length !== 0 && !response.headersList.get('Sec-WebSocket-Protocol')) { - failWebsocketConnection(handler, 1002, 'Server did not respond with sent protocols.') + failHandshake(handler, response, 1002, 'Server did not respond with sent protocols.') return } @@ -97471,7 +97966,7 @@ function establishWebSocketConnection (url, protocols, client, handler, options) // _Fail the WebSocket Connection_. // For H2, no upgrade header is expected. if (response.socket.session == null && response.headersList.get('Upgrade')?.toLowerCase() !== 'websocket') { - failWebsocketConnection(handler, 1002, 'Server did not set Upgrade header to "websocket".') + failHandshake(handler, response, 1002, 'Server did not set Upgrade header to "websocket".') return } @@ -97481,7 +97976,7 @@ function establishWebSocketConnection (url, protocols, client, handler, options) // MUST _Fail the WebSocket Connection_. // For H2, no connection header is expected. if (response.socket.session == null && response.headersList.get('Connection')?.toLowerCase() !== 'upgrade') { - failWebsocketConnection(handler, 1002, 'Server did not set Connection header to "upgrade".') + failHandshake(handler, response, 1002, 'Server did not set Connection header to "upgrade".') return } @@ -97495,7 +97990,7 @@ function establishWebSocketConnection (url, protocols, client, handler, options) const secWSAccept = response.headersList.get('Sec-WebSocket-Accept') const digest = crypto.hash('sha1', keyValue + uid, 'base64') if (secWSAccept !== digest) { - failWebsocketConnection(handler, 1002, 'Incorrect hash received in Sec-WebSocket-Accept header.') + failHandshake(handler, response, 1002, 'Incorrect hash received in Sec-WebSocket-Accept header.') return } @@ -97513,7 +98008,7 @@ function establishWebSocketConnection (url, protocols, client, handler, options) extensions = parseExtensions(secExtension) if (!extensions.has('permessage-deflate')) { - failWebsocketConnection(handler, 1002, 'Sec-WebSocket-Extensions header does not match.') + failHandshake(handler, response, 1002, 'Sec-WebSocket-Extensions header does not match.') return } } @@ -97534,7 +98029,7 @@ function establishWebSocketConnection (url, protocols, client, handler, options) // the selected subprotocol values in its response for the connection to // be established. if (requestProtocols === null || !requestProtocols.includes(secProtocol)) { - failWebsocketConnection(handler, 1002, 'Protocol was not set in the opening handshake.') + failHandshake(handler, response, 1002, 'Protocol was not set in the opening handshake.') return } } @@ -97629,6 +98124,16 @@ function closeWebSocketConnection (object, code, reason, validate = false) { } } +function failHandshake (handler, response, code, reason, cause) { + // The H2 upgrade request has already completed and handed off its stream. + // Aborting the request cannot close that stream after handshake validation fails. + if (response.socket?.session != null && !response.socket.destroyed) { + response.socket.destroy() + } + + failWebsocketConnection(handler, code, reason, cause) +} + /** * @param {import('./websocket').Handler} handler * @param {number} code @@ -98641,6 +99146,19 @@ 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. + if (this.#loop && this.#handler.socket.writableNeedDrain) { + this.#loop = false + this.#handler.socket.once('drain', () => { + if (this.destroyed) return + this.#loop = true + this.run(callback) + }) + return + } } else { if (!this.#info.compressed) { if (!this.writeFragments(body)) { From 42bcb6bfad9aa0a25f6e65ec5ea8c0ea21b6456a Mon Sep 17 00:00:00 2001 From: satsukies Date: Fri, 30 Oct 2026 12:36:12 +0900 Subject: [PATCH 2/2] chore(deps): bump nested undici to 6.29.1 and rebuild dist --- dist/index.js | 328 +++++++++++++++++++++++++++++++++++++--------- package-lock.json | 12 +- 2 files changed, 274 insertions(+), 66 deletions(-) diff --git a/dist/index.js b/dist/index.js index 1a4b427..cd585a4 100644 --- a/dist/index.js +++ b/dist/index.js @@ -6434,7 +6434,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.') } @@ -6453,11 +6453,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 @@ -7492,8 +7496,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 @@ -8424,8 +8426,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) @@ -8499,8 +8499,6 @@ function resumeH2 (client) { } function onHttp2SessionError (err) { - assert(err.code !== 'ERR_TLS_CERT_ALTNAME_INVALID') - this[kSocket][kError] = err this[kClient][kOnError](err) } @@ -9199,9 +9197,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') @@ -9723,6 +9722,7 @@ module.exports = Client "use strict"; +const buffer = __nccwpck_require__(4573) const Dispatcher = __nccwpck_require__(9784) const { ClientDestroyedError, @@ -9735,6 +9735,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) { @@ -9745,6 +9746,7 @@ class DispatcherBase extends Dispatcher { this[kClosed] = false this[kOnClosed] = [] this[kWebSocketOptions] = opts?.webSocket ?? {} + this[kEventSourceOptions] = opts?.eventSource ?? {} } get webSocketOptions () { @@ -9754,6 +9756,12 @@ class DispatcherBase extends Dispatcher { } } + get eventSourceOptions () { + return { + maxEventSize: this[kEventSourceOptions].maxEventSize ?? buffer.kStringMaxLength + } + } + get destroyed () { return this[kDestroyed] } @@ -16151,6 +16159,7 @@ module.exports = { "use strict"; +const buffer = __nccwpck_require__(4573) const { Transform } = __nccwpck_require__(7075) const { isASCIINumber, isValidLastEventId } = __nccwpck_require__(8794) @@ -16175,11 +16184,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 @@ -16218,6 +16235,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} @@ -16266,6 +16289,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, @@ -16276,6 +16306,7 @@ class EventSourceStream extends Transform { /** * @param {object} options + * @param {number} [options.maxEventSize] * @param {eventSourceSettings} options.eventSourceSettings * @param {Function} [options.push] */ @@ -16287,6 +16318,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 } @@ -16304,6 +16341,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 @@ -16382,7 +16427,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 @@ -16391,15 +16441,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) @@ -16433,6 +16496,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) { @@ -16440,11 +16510,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 @@ -16452,20 +16524,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 */ @@ -16496,6 +16577,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 () { @@ -16531,14 +16618,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) { @@ -16664,6 +16753,7 @@ const { isNetworkError } = __nccwpck_require__(6678) const { delay } = __nccwpck_require__(8794) const { kEnumerableProperty } = __nccwpck_require__(7375) const { environmentSettingsObject } = __nccwpck_require__(7241) +const { getGlobalDispatcher } = __nccwpck_require__(1088) let experimentalWarned = false @@ -16945,6 +17035,7 @@ class EventSource extends EventTarget { const eventSourceStream = new EventSourceStream({ eventSourceSettings: this.#state, + maxEventSize: this.#dispatcher.eventSourceOptions?.maxEventSize, push: (event) => { this.dispatchEvent(createFastMessageEvent( event.type, @@ -17123,7 +17214,8 @@ webidl.converters.EventSourceInitDict = webidl.dictionaryConverter([ }, { key: 'dispatcher', // undici only - converter: webidl.converters.any + converter: webidl.converters.any, + defaultValue: () => getGlobalDispatcher() } ]) @@ -22230,8 +22322,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) { @@ -22400,8 +22494,6 @@ async function httpNetworkFetch ( } location = headersList.get('location', true) - this.body = new Readable({ read: resume }) - const decoders = [] const willFollow = location && request.redirect === 'follow' && @@ -22419,7 +22511,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 } @@ -22452,6 +22544,8 @@ async function httpNetworkFetch ( } } + this.body = new Readable({ read: resume }) + const onError = this.onError.bind(this) resolve({ @@ -22498,10 +22592,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) @@ -28992,6 +29082,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)) { @@ -35443,7 +35547,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.') } @@ -35462,11 +35566,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 @@ -36501,8 +36609,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 @@ -37433,8 +37539,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) @@ -37508,8 +37612,6 @@ function resumeH2 (client) { } function onHttp2SessionError (err) { - assert(err.code !== 'ERR_TLS_CERT_ALTNAME_INVALID') - this[kSocket][kError] = err this[kClient][kOnError](err) } @@ -38208,9 +38310,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') @@ -38732,6 +38835,7 @@ module.exports = Client "use strict"; +const buffer = __nccwpck_require__(4573) const Dispatcher = __nccwpck_require__(2091) const { ClientDestroyedError, @@ -38744,6 +38848,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) { @@ -38754,6 +38859,7 @@ class DispatcherBase extends Dispatcher { this[kClosed] = false this[kOnClosed] = [] this[kWebSocketOptions] = opts?.webSocket ?? {} + this[kEventSourceOptions] = opts?.eventSource ?? {} } get webSocketOptions () { @@ -38763,6 +38869,12 @@ class DispatcherBase extends Dispatcher { } } + get eventSourceOptions () { + return { + maxEventSize: this[kEventSourceOptions].maxEventSize ?? buffer.kStringMaxLength + } + } + get destroyed () { return this[kDestroyed] } @@ -45160,6 +45272,7 @@ module.exports = { "use strict"; +const buffer = __nccwpck_require__(4573) const { Transform } = __nccwpck_require__(7075) const { isASCIINumber, isValidLastEventId } = __nccwpck_require__(6627) @@ -45184,11 +45297,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 @@ -45227,6 +45348,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} @@ -45275,6 +45402,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, @@ -45285,6 +45419,7 @@ class EventSourceStream extends Transform { /** * @param {object} options + * @param {number} [options.maxEventSize] * @param {eventSourceSettings} options.eventSourceSettings * @param {Function} [options.push] */ @@ -45296,6 +45431,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 } @@ -45313,6 +45454,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 @@ -45391,7 +45540,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 @@ -45400,15 +45554,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) @@ -45442,6 +45609,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) { @@ -45449,11 +45623,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 @@ -45461,20 +45637,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 */ @@ -45505,6 +45690,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 () { @@ -45540,14 +45731,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) { @@ -45673,6 +45866,7 @@ const { isNetworkError } = __nccwpck_require__(9107) const { delay } = __nccwpck_require__(6627) const { kEnumerableProperty } = __nccwpck_require__(1544) const { environmentSettingsObject } = __nccwpck_require__(4296) +const { getGlobalDispatcher } = __nccwpck_require__(5837) let experimentalWarned = false @@ -45954,6 +46148,7 @@ class EventSource extends EventTarget { const eventSourceStream = new EventSourceStream({ eventSourceSettings: this.#state, + maxEventSize: this.#dispatcher.eventSourceOptions?.maxEventSize, push: (event) => { this.dispatchEvent(createFastMessageEvent( event.type, @@ -46132,7 +46327,8 @@ webidl.converters.EventSourceInitDict = webidl.dictionaryConverter([ }, { key: 'dispatcher', // undici only - converter: webidl.converters.any + converter: webidl.converters.any, + defaultValue: () => getGlobalDispatcher() } ]) @@ -51239,8 +51435,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) { @@ -51409,8 +51607,6 @@ async function httpNetworkFetch ( } location = headersList.get('location', true) - this.body = new Readable({ read: resume }) - const decoders = [] const willFollow = location && request.redirect === 'follow' && @@ -51428,7 +51624,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 } @@ -51461,6 +51657,8 @@ async function httpNetworkFetch ( } } + this.body = new Readable({ read: resume }) + const onError = this.onError.bind(this) resolve({ @@ -51507,10 +51705,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) @@ -58001,6 +58195,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 6451ad3..1ea0652 100644 --- a/package-lock.json +++ b/package-lock.json @@ -61,9 +61,9 @@ } }, "node_modules/@actions/github/node_modules/undici": { - "version": "6.29.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-6.29.0.tgz", - "integrity": "sha512-R+RODBqp6i2pPflGdq+xIOUkl+RNfGgHwoinecKu/JCuf2uO06cOKoDbI2P7Dn6KcswdKwrczbU6IYJ6K8X+wg==", + "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" @@ -80,9 +80,9 @@ } }, "node_modules/@actions/http-client/node_modules/undici": { - "version": "6.29.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-6.29.0.tgz", - "integrity": "sha512-R+RODBqp6i2pPflGdq+xIOUkl+RNfGgHwoinecKu/JCuf2uO06cOKoDbI2P7Dn6KcswdKwrczbU6IYJ6K8X+wg==", + "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"