From dc83598990f9164faa71ba16bc54741be3534ced Mon Sep 17 00:00:00 2001 From: "Node.js GitHub Bot" Date: Fri, 24 Jul 2026 12:59:40 +0000 Subject: [PATCH] deps: update undici to 8.9.0 --- deps/undici/src/.gitignore | 3 + deps/undici/src/README.md | 5 + deps/undici/src/SECURITY.md | 21 + deps/undici/src/docs/docs/api/Client.md | 4 + deps/undici/src/docs/docs/api/Dispatcher.md | 6 + deps/undici/src/docs/docs/api/Interceptors.md | 59 +++ deps/undici/src/docs/docs/api/MockAgent.md | 27 ++ deps/undici/src/docs/docs/api/MockPool.md | 29 +- deps/undici/src/docs/docs/index.md | 5 + .../src/lib/cache/memory-cache-store.js | 63 ++- .../src/lib/cache/sqlite-cache-store.js | 8 +- deps/undici/src/lib/core/request.js | 15 +- deps/undici/src/lib/core/util.js | 7 +- deps/undici/src/lib/dispatcher/agent.js | 6 +- deps/undici/src/lib/dispatcher/client-h1.js | 50 +- deps/undici/src/lib/dispatcher/client-h2.js | 77 ++- deps/undici/src/lib/global.js | 52 ++- deps/undici/src/lib/handler/cache-handler.js | 330 ++++++++++--- .../lib/handler/cache-revalidation-handler.js | 16 +- .../src/lib/handler/redirect-handler.js | 7 +- deps/undici/src/lib/handler/retry-handler.js | 34 +- deps/undici/src/lib/interceptor/cache.js | 211 ++++++--- deps/undici/src/lib/interceptor/decompress.js | 11 +- deps/undici/src/lib/llhttp/wasm_build_env.txt | 2 +- deps/undici/src/lib/mock/mock-interceptor.js | 32 +- deps/undici/src/lib/mock/mock-utils.js | 49 +- deps/undici/src/lib/util/cache.js | 440 +++++++++++++++--- deps/undici/src/lib/util/date.js | 29 +- deps/undici/src/lib/web/cookies/util.js | 86 +++- deps/undici/src/lib/web/fetch/body.js | 3 +- deps/undici/src/lib/web/fetch/response.js | 4 +- deps/undici/src/package-lock.json | 140 +++++- deps/undici/src/package.json | 4 +- deps/undici/src/types/client.d.ts | 4 +- deps/undici/src/types/dispatcher.d.ts | 6 +- deps/undici/src/types/mock-interceptor.d.ts | 6 +- deps/undici/undici.js | 154 +++--- src/undici_version.h | 2 +- 38 files changed, 1617 insertions(+), 390 deletions(-) diff --git a/deps/undici/src/.gitignore b/deps/undici/src/.gitignore index fc02456981b799..5da28e88726525 100644 --- a/deps/undici/src/.gitignore +++ b/deps/undici/src/.gitignore @@ -93,3 +93,6 @@ AGENTS.md .githuman benchmarks/package-lock.json + +# File generated by /scripts/platform-shell.js +scripts/.npmrc \ No newline at end of file diff --git a/deps/undici/src/README.md b/deps/undici/src/README.md index e5a779c4bb62d3..8ccd602b5c55cd 100644 --- a/deps/undici/src/README.md +++ b/deps/undici/src/README.md @@ -408,6 +408,11 @@ For more information about their behavior, please reference the body mixin from This section documents our most commonly used API methods. Additional APIs are documented in their own files within the [docs](./docs/) folder and are accessible via the navigation list on the left side of the docs site. +For the top-level APIs below, the `url` argument supplies the request origin and +path. Do not pass `origin` or `path` in the second `options` argument. The linked +`Dispatcher` option types include those fields because dispatcher methods are +lower-level APIs that do not receive a separate `url` argument. + ### `undici.request([url, options]): Promise` Arguments: diff --git a/deps/undici/src/SECURITY.md b/deps/undici/src/SECURITY.md index 4511de4dca41ce..ad984a32f281ed 100644 --- a/deps/undici/src/SECURITY.md +++ b/deps/undici/src/SECURITY.md @@ -87,6 +87,17 @@ meet the following criteria: * Dependencies installed by the application. * The DNS resolution results provided by the operating system or configured resolvers. +* The network environment in which the process runs, including any private or + internal networks it can reach, for the privacy of traffic on that network. + Deployments that require stronger isolation, monitoring, or egress controls + are responsible for providing them. +* Any proxy server configured by the application, runtime, or environment. + Proxies are trusted network intermediaries and must be authorized by the + relevant network or deployment authority. Undici's proxy support is for + routing traffic through such proxies, for example to reach external networks + through a firewall; it is not intended to provide anonymity, hide traffic, or + bypass organizational, regulatory, or legal controls. Untrusted proxies are + outside the supported threat model. In other words, if untrusted data passing through undici to the application can trigger actions other than those documented for the APIs, there is likely @@ -172,6 +183,16 @@ lead to a loss of confidentiality, integrity, or availability. input to request options) are the application's responsibility, not vulnerabilities in undici. +#### Unauthorized or untrusted proxy use + +* If an application configures undici to use a proxy that is untrusted, + malicious, or not authorized by the relevant network or deployment authority, + any resulting privacy loss, policy bypass, regulatory exposure, traffic + manipulation, or unavailability is the deployment's responsibility. Undici + cannot determine whether a proxy is authorized for a user's network or + jurisdiction, and preventing use of proxies to hide traffic or evade controls + is a non-goal. + ## Receiving security updates Security notifications will be distributed via diff --git a/deps/undici/src/docs/docs/api/Client.md b/deps/undici/src/docs/docs/api/Client.md index 7e48a612a7de22..dc6ab7a6d5f94b 100644 --- a/deps/undici/src/docs/docs/api/Client.md +++ b/deps/undici/src/docs/docs/api/Client.md @@ -49,6 +49,10 @@ added: v1.0.0 * `headersTimeout` {number|null} The timeout, in milliseconds, the parser waits to receive the complete HTTP headers before the request times out. Use `0` to disable it entirely. **Default:** `300e3`. + HTTP/1.1 headers/body parser timeouts are not guaranteed to fire with exact + millisecond precision: delays up to 1000ms use native timers, while larger + delays use undici's lower-overhead fast timers with a target resolution + around 500ms. * `connectTimeout` {number|null} The timeout, in milliseconds, for establishing a socket connection. Use `0` to disable it entirely. **Default:** `10e3`. diff --git a/deps/undici/src/docs/docs/api/Dispatcher.md b/deps/undici/src/docs/docs/api/Dispatcher.md index 1e03811f94c931..10d4fcf1968f5f 100644 --- a/deps/undici/src/docs/docs/api/Dispatcher.md +++ b/deps/undici/src/docs/docs/api/Dispatcher.md @@ -228,6 +228,10 @@ added: v4.0.0 * `bodyTimeout` {number|null} The time, in milliseconds, after which the request times out while receiving body data. Monitors the time between body chunks. Use `0` to disable it entirely. Defaults to 300 seconds. + HTTP/1.1 headers/body parser timeouts are not guaranteed to fire with exact + millisecond precision: delays up to 1000ms use native timers, while larger + delays use undici's lower-overhead fast timers with a target resolution + around 500ms. * `reset` {boolean} Whether the request should establish a keep-alive connection. **Default:** `false`. * `expectContinue` {boolean} For HTTP/2, appends the `expect: 100-continue` @@ -729,6 +733,8 @@ await client.request({ path: '/', method: 'GET' }) ``` For the full list of built-in interceptors provided by undici, see [Interceptors](Interceptors.md). +For an example of a custom interceptor that wraps handler callbacks, see +[Custom interceptors](Interceptors.md#custom-interceptors). ### Event: `'connect'` diff --git a/deps/undici/src/docs/docs/api/Interceptors.md b/deps/undici/src/docs/docs/api/Interceptors.md index c6a02a14b9c7ce..c692442d7f04f2 100644 --- a/deps/undici/src/docs/docs/api/Interceptors.md +++ b/deps/undici/src/docs/docs/api/Interceptors.md @@ -32,6 +32,65 @@ const client = new Client('https://example.com').compose( --- +## Custom interceptors + +Custom interceptors use the same shape as +[`dispatcher.compose()`](./Dispatcher.md#dispatchercomposeinterceptors-interceptor): +an interceptor takes a `dispatch` function and returns another dispatch-like +function with the same `(options, handler)` signature. + +When an interceptor wraps the handler, forward the callbacks that it does not +handle itself. The complete handler callback list is documented under +[`dispatcher.dispatch(options, handler)`](./Dispatcher.md#dispatcherdispatchoptions-handler). + +```js +import { Agent } from 'undici' + +const timingInterceptor = dispatch => { + return (options, handler) => { + const started = performance.now() + + return dispatch(options, { + ...handler, + onResponseStart (controller, statusCode, headers, statusMessage) { + const duration = Math.round(performance.now() - started) + const method = options.method ?? 'GET' + const origin = options.origin ?? '' + + console.log(`${method} ${origin}${options.path} -> ${statusCode} in ${duration}ms`) + + return handler.onResponseStart?.( + controller, + statusCode, + headers, + statusMessage + ) + }, + onResponseError (controller, error) { + const duration = Math.round(performance.now() - started) + + console.error(`request failed after ${duration}ms`, error) + + return handler.onResponseError?.(controller, error) + } + }) + } +} + +const dispatcher = new Agent().compose(timingInterceptor) + +const { body } = await dispatcher.request({ + origin: 'https://example.com', + path: '/', + method: 'GET' +}) + +await body.dump() +await dispatcher.close() +``` + +--- + ## `interceptors.dump([opts])` Reads and discards the response body up to a configurable size limit. Useful diff --git a/deps/undici/src/docs/docs/api/MockAgent.md b/deps/undici/src/docs/docs/api/MockAgent.md index f0a147d7ce5a28..20682c41c97d58 100644 --- a/deps/undici/src/docs/docs/api/MockAgent.md +++ b/deps/undici/src/docs/docs/api/MockAgent.md @@ -17,6 +17,13 @@ registered on the {MockClient} or {MockPool} instances returned by `MockAgent` is set as the dispatcher (for example through [`setGlobalDispatcher()`][] or a per-request `dispatcher` option). +> [!NOTE] +> [`setGlobalDispatcher()`][] only affects undici APIs that use the global +> dispatcher, such as `request()` and `fetch()`. It does not replace or +> monkeypatch {Pool} or {Client} instances that were created separately. To test +> code that accepts or creates a pool/client directly, pass the {MockPool} or +> {MockClient} returned by [`mockAgent.get(origin)`][] into that code. + ```mjs import { MockAgent } from 'undici' @@ -139,6 +146,26 @@ for await (const data of body) { } ``` +```mjs displayName="Testing code that accepts a pool" +import { MockAgent } from 'undici' + +async function getStatus (pool) { + const { statusCode } = await pool.request({ + path: '/foo', + method: 'GET' + }) + + return statusCode +} + +const mockAgent = new MockAgent() +const mockPool = mockAgent.get('http://localhost:3000') + +mockPool.intercept({ path: '/foo', method: 'GET' }).reply(200) + +console.log(await getStatus(mockPool)) // 200 +``` + ```mjs displayName="Returning a MockClient" import { MockAgent, request } from 'undici' diff --git a/deps/undici/src/docs/docs/api/MockPool.md b/deps/undici/src/docs/docs/api/MockPool.md index dc6474c5a2394f..e5c2da151ea19d 100644 --- a/deps/undici/src/docs/docs/api/MockPool.md +++ b/deps/undici/src/docs/docs/api/MockPool.md @@ -136,7 +136,8 @@ The reply behaviour of a matching request is defined through the returned computing all reply options dynamically rather than just the body. * `callback` {Function} A `(opts: MockResponseCallbackOptions) => { statusCode, data, responseOptions }` function invoked with the incoming - request. + request. The callback may be asynchronous; a returned promise is awaited + and must resolve to the same shape. * Returns: {MockScope} * `replyWithError(error)` {Function} Defines an error for a matching request to throw. @@ -263,6 +264,32 @@ for await (const data of body) { } ``` +```mjs displayName="Reply with an asynchronous options callback" +import { readFile } from 'node:fs/promises' +import { MockAgent, setGlobalDispatcher, request } from 'undici' + +const mockAgent = new MockAgent() +setGlobalDispatcher(mockAgent) + +const mockPool = mockAgent.get('http://localhost:3000') + +mockPool.intercept({ + path: '/fixture', + method: 'GET' +}).reply(async ({ path }) => ({ + statusCode: 200, + data: await readFile(new URL('./fixture.json', import.meta.url)) +})) + +const { statusCode, body } = await request('http://localhost:3000/fixture') + +console.log('response received', statusCode) // response received 200 + +for await (const data of body) { + console.log('data', data.toString('utf8')) // contents of fixture.json +} +``` + ```mjs displayName="Multiple intercepts" import { MockAgent, setGlobalDispatcher, request } from 'undici' diff --git a/deps/undici/src/docs/docs/index.md b/deps/undici/src/docs/docs/index.md index b1085611ad5c48..ab28166fcf354e 100644 --- a/deps/undici/src/docs/docs/index.md +++ b/deps/undici/src/docs/docs/index.md @@ -410,6 +410,11 @@ For more information about their behavior, please reference the body mixin from This section documents our most commonly used API methods. Additional APIs are documented in their own files within the [docs](./docs/) folder and are accessible via the navigation list on the left side of the docs site. +For the top-level APIs below, the `url` argument supplies the request origin and +path. Do not pass `origin` or `path` in the second `options` argument. The linked +`Dispatcher` option types include those fields because dispatcher methods are +lower-level APIs that do not receive a separate `url` argument. + ### `undici.request([url, options])` * `url` {string|URL|UrlObject} diff --git a/deps/undici/src/lib/cache/memory-cache-store.js b/deps/undici/src/lib/cache/memory-cache-store.js index ea9c6cae7e9418..5ea0f7247dd784 100644 --- a/deps/undici/src/lib/cache/memory-cache-store.js +++ b/deps/undici/src/lib/cache/memory-cache-store.js @@ -218,17 +218,62 @@ class MemoryCacheStore extends EventEmitter { } function findEntry (key, entries, now) { - return entries.find((entry) => ( - entry.deleteAt > now && - entry.method === key.method && - (entry.vary == null || Object.keys(entry.vary).every(headerName => { - if (entry.vary[headerName] === null) { - return key.headers[headerName] === undefined + for (let i = 0; i < entries.length; i++) { + const entry = entries[i] + if ( + entry.deleteAt > now && + entry.method === key.method && + varyMatches(key, entry) + ) { + return entry + } + } +} + +function varyMatches (key, entry) { + if (entry.vary == null) { + return true + } + + for (const headerName in entry.vary) { + if (Object.hasOwn(entry.vary, headerName) && !headerValueEquals(key.headers?.[headerName], entry.vary[headerName])) { + return false + } + } + + return true +} + +/** + * @param {string|string[]|null|undefined} lhs + * @param {string|string[]|null|undefined} rhs + * @returns {boolean} + */ +function headerValueEquals (lhs, rhs) { + if (lhs == null && rhs == null) { + return true + } + + if ((lhs == null && rhs != null) || + (lhs != null && rhs == null)) { + return false + } + + if (Array.isArray(lhs) && Array.isArray(rhs)) { + if (lhs.length !== rhs.length) { + return false + } + + for (let i = 0; i < lhs.length; i++) { + if (lhs[i] !== rhs[i]) { + return false } + } + + return true + } - return entry.vary[headerName] === key.headers[headerName] - })) - )) + return lhs === rhs } module.exports = MemoryCacheStore diff --git a/deps/undici/src/lib/cache/sqlite-cache-store.js b/deps/undici/src/lib/cache/sqlite-cache-store.js index bf1e04b791736b..ab97da6b5e42bd 100644 --- a/deps/undici/src/lib/cache/sqlite-cache-store.js +++ b/deps/undici/src/lib/cache/sqlite-cache-store.js @@ -456,7 +456,13 @@ function headerValueEquals (lhs, rhs) { return false } - return lhs.every((x, i) => x === rhs[i]) + for (let i = 0; i < lhs.length; i++) { + if (lhs[i] !== rhs[i]) { + return false + } + } + + return true } return lhs === rhs diff --git a/deps/undici/src/lib/core/request.js b/deps/undici/src/lib/core/request.js index 8e2072a46c7950..a7493c3c97cc38 100644 --- a/deps/undici/src/lib/core/request.js +++ b/deps/undici/src/lib/core/request.js @@ -173,7 +173,7 @@ class Request { this.method = method - this.typeOfService = typeOfService ?? 0 + this.typeOfService = typeOfService this.abort = null @@ -472,7 +472,13 @@ function processHeader (request, key, val) { } else if (typeof val[i] === 'object') { throw new InvalidArgumentError(`invalid ${key} header`) } else { - arr.push(`${val[i]}`) + // Coerce primitives (and reject unsafe coercions such as functions + // with a crafted toString/Symbol.toPrimitive). + const str = `${val[i]}` + if (!isValidHeaderValue(str)) { + throw new InvalidArgumentError(`invalid ${key} header`) + } + arr.push(str) } } val = arr @@ -483,7 +489,12 @@ function processHeader (request, key, val) { } else if (val === null) { val = '' } else { + // Coerce primitives (and reject unsafe coercions such as functions + // with a crafted toString/Symbol.toPrimitive). val = `${val}` + if (!isValidHeaderValue(val)) { + throw new InvalidArgumentError(`invalid ${key} header`) + } } if (headerName === 'host') { diff --git a/deps/undici/src/lib/core/util.js b/deps/undici/src/lib/core/util.js index 2586e3ee891668..f00ddfce0d1b9a 100644 --- a/deps/undici/src/lib/core/util.js +++ b/deps/undici/src/lib/core/util.js @@ -370,7 +370,12 @@ function destroy (stream, err) { stream.socket = null } - stream.destroy(err) + try { + stream.destroy(err) + } catch { + // stream.destroy may throw on managed sockets (e.g., http2). + // Silently ignore — the socket lifecycle is handled by the subsystem. + } } else if (err) { queueMicrotask(() => { stream.emit('error', err) diff --git a/deps/undici/src/lib/dispatcher/agent.js b/deps/undici/src/lib/dispatcher/agent.js index 858a5f248f7efb..1e670746e8e98f 100644 --- a/deps/undici/src/lib/dispatcher/agent.js +++ b/deps/undici/src/lib/dispatcher/agent.js @@ -107,15 +107,15 @@ class Agent extends DispatcherBase { } let hasOrigin = false - for (const client of this[kClients].values()) { - if (client[kUrl].origin === dispatcher[kUrl].origin) { + for (const k of this[kClients].keys()) { + if (k === origin || k === `${origin}#http1-only`) { hasOrigin = true break } } if (!hasOrigin) { - this[kOrigins].delete(dispatcher[kUrl].origin) + this[kOrigins].delete(origin) } } diff --git a/deps/undici/src/lib/dispatcher/client-h1.js b/deps/undici/src/lib/dispatcher/client-h1.js index 6d89dd09b5e2e2..abf381f65a469e 100644 --- a/deps/undici/src/lib/dispatcher/client-h1.js +++ b/deps/undici/src/lib/dispatcher/client-h1.js @@ -10,6 +10,7 @@ const { RequestContentLengthMismatchError, ResponseContentLengthMismatchError, RequestAbortedError, + InvalidArgumentError, HeadersTimeoutError, HeadersOverflowError, SocketError, @@ -60,6 +61,7 @@ const removeAllListeners = util.removeAllListeners const kIdleSocketValidation = Symbol('kIdleSocketValidation') const kIdleSocketValidationTimeout = Symbol('kIdleSocketValidationTimeout') const kSocketUsed = Symbol('kSocketUsed') +const kTypeOfService = Symbol('kTypeOfService') let extractBody @@ -1050,7 +1052,7 @@ function onSocketClose () { function clearIdleSocketValidation (socket) { if (socket[kIdleSocketValidationTimeout]) { - clearTimeout(socket[kIdleSocketValidationTimeout]) + clearImmediate(socket[kIdleSocketValidationTimeout]) socket[kIdleSocketValidationTimeout] = null } @@ -1059,14 +1061,14 @@ function clearIdleSocketValidation (socket) { function scheduleIdleSocketValidation (client, socket) { socket[kIdleSocketValidation] = 1 - socket[kIdleSocketValidationTimeout] = setTimeout(() => { + socket[kIdleSocketValidationTimeout] = setImmediate(() => { socket[kIdleSocketValidationTimeout] = null socket[kIdleSocketValidation] = 2 if (client[kSocket] === socket && !socket.destroyed) { client[kResume]() } - }, 0) + }) socket[kIdleSocketValidationTimeout].unref?.() } @@ -1134,6 +1136,32 @@ function shouldSendContentLength (method) { return method !== 'GET' && method !== 'HEAD' && method !== 'OPTIONS' && method !== 'TRACE' && method !== 'CONNECT' } +function setTypeOfService (socket, request) { + if (typeof socket.setTypeOfService !== 'function') { + return + } + + const typeOfService = request.typeOfService + + if (typeOfService === undefined) { + return + } + + const currentTypeOfService = socket[kTypeOfService] + + if (currentTypeOfService === typeOfService) { + return + } + + try { + socket.setTypeOfService(typeOfService) + socket[kTypeOfService] = typeOfService + } catch { + // QoS marking is best-effort. setTypeOfService() can throw synchronously on + // some platforms depending on socket state, but that must not abort the request. + } +} + /** * @param {import('./client.js')} client * @param {import('../core/request.js')} request @@ -1173,8 +1201,16 @@ function writeH1 (client, request) { } body = bodyStream.stream contentLength = bodyStream.length - } else if (util.isBlobLike(body) && request.contentType == null && body.type) { - headers.push('content-type', body.type) + } else if (util.isBlobLike(body) && request.contentType == null) { + const contentType = body.type + if (contentType) { + const contentTypeValue = `${contentType}` + if (!util.isValidHeaderValue(contentTypeValue)) { + util.errorRequest(client, request, new InvalidArgumentError('invalid content-type header')) + return false + } + headers.push('content-type', contentTypeValue) + } } if (body && typeof body.read === 'function') { @@ -1265,9 +1301,7 @@ function writeH1 (client, request) { socket[kBlocking] = true } - if (socket.setTypeOfService) { - socket.setTypeOfService(request.typeOfService) - } + setTypeOfService(socket, request) let header = `${method} ${path} HTTP/1.1\r\n` diff --git a/deps/undici/src/lib/dispatcher/client-h2.js b/deps/undici/src/lib/dispatcher/client-h2.js index 3e04911197b4ba..19622db68ace3c 100644 --- a/deps/undici/src/lib/dispatcher/client-h2.js +++ b/deps/undici/src/lib/dispatcher/client-h2.js @@ -157,9 +157,10 @@ function completeRequest (client, request, resetPendingIdx = false) { const queue = client[kQueue] const runningIdx = client[kRunningIdx] - // In-order completion: advance the running index instead of splicing. - // The client's resume loop compacts the queue once the index grows. + // In-order completion: clear the request and advance without splicing. + // The client's resume loop compacts cleared slots once the index grows. if (runningIdx < client[kPendingIdx] && queue[runningIdx] === request) { + queue[runningIdx] = null client[kRunningIdx] = runningIdx + 1 return } @@ -246,7 +247,6 @@ function connectH2 (client, socket) { util.addListener(session, 'error', onHttp2SessionError) util.addListener(session, 'frameError', onHttp2FrameError) - util.addListener(session, 'end', onHttp2SessionEnd) util.addListener(session, 'goaway', onHttp2SessionGoAway) util.addListener(session, 'close', onHttp2SessionClose) util.addListener(session, 'remoteSettings', onHttp2RemoteSettings) @@ -322,16 +322,6 @@ function connectH2 (client, socket) { // Don't dispatch an upgrade until all preceding requests have completed. // Possibly, we do not have remote settings confirmed yet. if ((request.upgrade === 'websocket' || request.method === 'CONNECT') && session[kRemoteSettings] === false) return true - // Request with stream or iterator body can error while other requests - // are inflight and indirectly error those as well. - // Ensure this doesn't happen by waiting for inflight - // to complete before dispatching. - - // Request with stream or iterator body cannot be retried. - // Ensure that no other requests are inflight and - // could cause failure. - if (util.bodyLength(request.body) !== 0 && - (util.isStream(request.body) || util.isAsyncIterable(request.body) || util.isFormDataLike(request.body))) return true } else { return (request.upgrade === 'websocket' || request.method === 'CONNECT') && session[kRemoteSettings] === false } @@ -505,12 +495,6 @@ function onHttp2FrameError (type, code, id) { } } -function onHttp2SessionEnd () { - const err = new SocketError('other side closed', util.getSocketInfo(this[kSocket])) - this.destroy(err) - util.destroy(this[kSocket], err) -} - /** * This is the root cause of #3011 * We need to handle GOAWAY frames properly, and trigger the session close @@ -654,7 +638,7 @@ function onHttp2SocketError (err) { return } - this[kClient][kOnError](err) + this[kHTTP2Session]?.[kClient]?.[kOnError](err) } function onHttp2SocketEnd () { @@ -688,20 +672,24 @@ function onUpgradeStreamClose () { closeStreamSession(this) } -function onRequestStreamClose () { +// Idempotent terminal cleanup, called from both 'end' and 'close': the +// null-state guard no-ops the later call. +function completeRequestStream () { const state = this[kRequestStreamState] - if (state) { - // Release the stream first so request references are cleared, - // then complete the response with trailers if available. - releaseRequestStream(this) + if (state == null) { + return + } + + // Release the stream first so request references are cleared, + // then complete the response with trailers if available. + releaseRequestStream(this) - if (state.pendingEnd && !state.request.aborted && !state.request.completed) { - state.request.onResponseEnd(state.trailers || {}) - finalizeRequest(state) - } + if (state.pendingEnd && !state.request.aborted && !state.request.completed) { + state.request.onResponseEnd(state.trailers || {}) } + finalizeRequest(state) closeStreamSession(this) this[kRequestStreamState] = null } @@ -943,14 +931,14 @@ function writeH2 (client, request) { // close() alone leaves cleanup waiting on the 'close' event; on a busy, // long-lived multiplexed session that event can fail to fire, leaving the // native Http2Stream (and the whole request graph it pins) alive for the - // session's life. Destroy the stream to release the handle - // deterministically, but defer it by a setImmediate so the RST_STREAM - // frame queued by close() gets a chance to be written first. - setImmediate(() => { - if (!stream.destroyed) { - util.destroy(stream) - } - }) + // session's life. Destroy the stream synchronously to release the handle + // deterministically. Deferring the destroy (e.g. via setImmediate) leaks + // the same way when the event loop is stalled and the callback never runs + // under abort churn (#5558); close() has already queued the RST_STREAM + // frame on the native session, so a synchronous destroy still sends it. + if (!stream.destroyed) { + util.destroy(stream) + } // We move the running index to the next request client[kOnError](err) @@ -1137,7 +1125,7 @@ function writeH2 (client, request) { } stream[kHTTP2Session] = session - stream.on('close', onRequestStreamClose) + stream.on('close', completeRequestStream) bindRequestToStream(request, stream, releaseRequestStream) if (expectContinue) { @@ -1280,13 +1268,16 @@ function onEnd () { stream.off('end', onEnd) - // If we received a response, this is a normal completion. - // Defer actual completion to onRequestStreamClose so that - // onTrailers (which may fire after 'end' on Windows) can - // store trailers first. + // onTrailers (which may fire after 'end' on Windows) has already stored + // trailers on the state by now, so completing here still delivers them. if (state.responseReceived) { if (!request.aborted && !request.completed) { state.pendingEnd = true + + // Complete on 'end': a blocked event loop can keep the stream's 'close' + // from firing, stranding its buffers until OOM. Idempotent, so a later + // 'close' no-ops. + completeRequestStream.call(stream) } } else { // Stream ended without receiving a response - this is an error @@ -1357,7 +1348,7 @@ function onTrailers (trailers) { return } - // Store trailers for onRequestStreamClose to use when completing + // Store trailers for completeRequestStream to use when completing state.trailers = trailers } diff --git a/deps/undici/src/lib/global.js b/deps/undici/src/lib/global.js index 81ab7d10195a29..d6204c703ce2db 100644 --- a/deps/undici/src/lib/global.js +++ b/deps/undici/src/lib/global.js @@ -8,6 +8,9 @@ const { InvalidArgumentError } = require('./core/errors') const Agent = require('./dispatcher/agent') const Dispatcher1Wrapper = require('./dispatcher/dispatcher1-wrapper') +// Fallback storage for when globalThis is not extensible (e.g. frozen) +let fallbackDispatcher + if (getGlobalDispatcher() === undefined) { setGlobalDispatcher(new Agent()) } @@ -17,25 +20,42 @@ function setGlobalDispatcher (agent) { throw new InvalidArgumentError('Argument agent must implement Agent') } - Object.defineProperty(globalThis, globalDispatcher, { - value: agent, - writable: true, - enumerable: false, - configurable: false - }) - - const legacyAgent = agent instanceof Dispatcher1Wrapper ? agent : new Dispatcher1Wrapper(agent) - - Object.defineProperty(globalThis, legacyGlobalDispatcher, { - value: legacyAgent, - writable: true, - enumerable: false, - configurable: false - }) + try { + Object.defineProperty(globalThis, globalDispatcher, { + value: agent, + writable: true, + enumerable: false, + configurable: false + }) + } catch (err) { + // globalThis is not extensible (e.g. Object.freeze(globalThis)) + // Use fallback storage instead + if (err instanceof TypeError) { + fallbackDispatcher = agent + return + } + throw err + } + + try { + const legacyAgent = agent instanceof Dispatcher1Wrapper ? agent : new Dispatcher1Wrapper(agent) + + Object.defineProperty(globalThis, legacyGlobalDispatcher, { + value: legacyAgent, + writable: true, + enumerable: false, + configurable: false + }) + } catch (err) { + // globalThis is not extensible; fallback storage is already set + if (!(err instanceof TypeError)) { + throw err + } + } } function getGlobalDispatcher () { - return globalThis[globalDispatcher] + return globalThis[globalDispatcher] ?? fallbackDispatcher } // These are the globals that can be installed by undici.install(). diff --git a/deps/undici/src/lib/handler/cache-handler.js b/deps/undici/src/lib/handler/cache-handler.js index 6e13f8eb17c254..bdfc0d94a43305 100644 --- a/deps/undici/src/lib/handler/cache-handler.js +++ b/deps/undici/src/lib/handler/cache-handler.js @@ -3,7 +3,10 @@ const util = require('../core/util') const { parseCacheControlHeader, + hasInvalidCacheControlDirective, parseVaryHeader, + hasVaryStar, + isInvalidOrWildcardVaryHeader, isEtagUsable } = require('../util/cache') const { parseHttpDate } = require('../util/date.js') @@ -26,6 +29,95 @@ const NOT_UNDERSTOOD_STATUS_CODES = [ const MAX_RESPONSE_AGE = 2147483647000 +// Retention for revalidation-only entries (zero freshness lifetime but a +// validator present); each successful revalidation re-stores the entry. +const REVALIDATION_ONLY_RETENTION = 86400000 // 24 hours + +function trimOWS (value) { + return value.replace(/^[\t ]+|[\t ]+$/g, '') +} + +function arrayIncludes (array, value) { + for (let i = 0; i < array.length; i++) { + if (array[i] === value) { + return true + } + } + + return false +} + +function appendConnectionHeaderTokens (headersToRemove, connectionHeader) { + const values = Array.isArray(connectionHeader) ? connectionHeader : [connectionHeader] + + for (let i = 0; i < values.length; i++) { + const tokens = values[i].split(',') + for (let j = 0; j < tokens.length; j++) { + headersToRemove.push(trimOWS(tokens[j]).toLowerCase()) + } + } +} + +function getSameOriginPath (cacheKey, location) { + if (typeof location !== 'string') { + return undefined + } + + let originUrl + let requestUrl + let locationUrl + try { + originUrl = new URL(cacheKey.origin) + requestUrl = new URL(cacheKey.path, originUrl) + locationUrl = new URL(location, requestUrl) + } catch { + return undefined + } + + if (locationUrl.origin !== originUrl.origin) { + return undefined + } + + return locationUrl.pathname + locationUrl.search +} + +function deleteCachedUri (store, cacheKey, path) { + deleteCachedValue(store, { + ...cacheKey, + path + }) + + for (let i = 0; i < util.safeHTTPMethods.length; i++) { + const method = util.safeHTTPMethods[i] + if (method !== cacheKey.method) { + deleteCachedValue(store, { + ...cacheKey, + method, + path + }) + } + } +} + +function deleteLocationTargets (store, cacheKey, headerValue) { + if (headerValue === undefined) { + return + } + + const values = Array.isArray(headerValue) ? headerValue : [headerValue] + for (let i = 0; i < values.length; i++) { + const path = getSameOriginPath(cacheKey, values[i]) + if (path !== undefined) { + deleteCachedUri(store, cacheKey, path) + } + } +} + +function invalidateUnsafeRequest (store, cacheKey, resHeaders) { + deleteCachedUri(store, cacheKey, cacheKey.path) + deleteLocationTargets(store, cacheKey, resHeaders.location) + deleteLocationTargets(store, cacheKey, resHeaders['content-location']) +} /** * @typedef {import('../../types/dispatcher.d.ts').default.DispatchHandler} DispatchHandler * @@ -107,28 +199,28 @@ class CacheHandler { const handler = this if ( - !util.safeHTTPMethods.includes(this.#cacheKey.method) && + !arrayIncludes(util.safeHTTPMethods, this.#cacheKey.method) && statusCode >= 200 && statusCode <= 399 ) { // Successful response to an unsafe method, delete it from cache // https://www.rfc-editor.org/rfc/rfc9111.html#name-invalidating-stored-response - try { - this.#store.delete(this.#cacheKey)?.catch?.(noop) - } catch { - // Fail silently - } + invalidateUnsafeRequest(this.#store, this.#cacheKey, resHeaders) return downstreamOnHeaders() } const cacheControlHeader = resHeaders['cache-control'] - const heuristicallyCacheable = resHeaders['last-modified'] && HEURISTICALLY_CACHEABLE_STATUS_CODES.includes(statusCode) + const heuristicallyCacheable = resHeaders['last-modified'] && arrayIncludes(HEURISTICALLY_CACHEABLE_STATUS_CODES, statusCode) if ( !cacheControlHeader && !resHeaders['expires'] && !heuristicallyCacheable && !this.#cacheByDefault ) { + if (statusCode === 304 && resHeaders.vary && isInvalidOrWildcardVaryHeader(resHeaders.vary)) { + deleteCachedValue(this.#store, this.#cacheKey) + } + // Don't have anything to tell us this response is cachable and we're not // caching by default return downstreamOnHeaders() @@ -136,31 +228,54 @@ class CacheHandler { const cacheControlDirectives = cacheControlHeader ? parseCacheControlHeader(cacheControlHeader) : {} if (!canCacheResponse(this.#cacheType, statusCode, resHeaders, cacheControlDirectives, this.#cacheKey.headers)) { + if (statusCode === 304 && (cacheControlHeader || revalidationResponseDisallowsCachedReuse(this.#cacheType, resHeaders, cacheControlDirectives))) { + deleteCachedValue(this.#store, this.#cacheKey) + } + return downstreamOnHeaders() } const now = Date.now() - const resAge = resHeaders.age ? getAge(resHeaders.age) : undefined - if (resAge && resAge >= MAX_RESPONSE_AGE) { + const resAge = Object.hasOwn(resHeaders, 'age') ? getAge(resHeaders.age) : undefined + if (resAge !== undefined && resAge >= MAX_RESPONSE_AGE) { // Response considered stale + deleteCachedValueIfNotModified(statusCode, this.#store, this.#cacheKey) + return downstreamOnHeaders() + } + + const resDate = Object.hasOwn(resHeaders, 'date') ? getDate(resHeaders.date) : undefined + if (resDate === null) { + deleteCachedValueIfNotModified(statusCode, this.#store, this.#cacheKey) return downstreamOnHeaders() } - const resDate = typeof resHeaders.date === 'string' - ? parseHttpDate(resHeaders.date) - : undefined + const apparentAge = resDate ? Math.max(0, now - resDate.getTime()) : 0 + const currentAge = Math.max(apparentAge, resAge ?? 0) + + const hasValidator = + (typeof resHeaders.etag === 'string' && isEtagUsable(resHeaders.etag)) || + typeof resHeaders['last-modified'] === 'string' const staleAt = - determineStaleAt(this.#cacheType, now, resAge, resHeaders, resDate, cacheControlDirectives) ?? + determineStaleAt(this.#cacheType, now, resAge, resHeaders, resDate, cacheControlDirectives, hasValidator) ?? this.#cacheByDefault - if (staleAt === undefined || (resAge && resAge > staleAt)) { + // Zero freshness lifetime but a validator: stale from the start, yet still + // storable since each reuse is preceded by a revalidation request. + // https://www.rfc-editor.org/rfc/rfc9111.html#section-5.2.2.4 + const revalidationOnly = staleAt === 0 && hasValidator + if (staleAt === undefined || (currentAge >= staleAt && !revalidationOnly)) { + if (cacheControlHeader || staleAt !== undefined) { + deleteCachedValueIfNotModified(statusCode, this.#store, this.#cacheKey) + } + return downstreamOnHeaders() } - const baseTime = resDate ? resDate.getTime() : now + const baseTime = now - currentAge const absoluteStaleAt = staleAt + baseTime - if (now >= absoluteStaleAt) { + if (now >= absoluteStaleAt && !revalidationOnly) { // Response is already stale + deleteCachedValueIfNotModified(statusCode, this.#store, this.#cacheKey) return downstreamOnHeaders() } @@ -173,8 +288,8 @@ class CacheHandler { } } - const cachedAt = resAge ? now - resAge : now - const deleteAt = determineDeleteAt(baseTime, cachedAt, cacheControlDirectives, absoluteStaleAt) + const cachedAt = baseTime + const deleteAt = determineDeleteAt(baseTime, now, cacheControlDirectives, absoluteStaleAt) const strippedHeaders = stripNecessaryHeaders(resHeaders, cacheControlDirectives) /** @@ -204,6 +319,7 @@ class CacheHandler { value.statusCode = cachedValue.statusCode value.statusMessage = cachedValue.statusMessage value.etag = cachedValue.etag + value.vary = varyDirectives ?? cachedValue.vary value.headers = { ...cachedValue.headers, ...strippedHeaders } downstreamOnHeaders() @@ -334,6 +450,36 @@ class CacheHandler { } } +/** + * @param {import('../../types/cache-interceptor.d.ts').default.CacheStore} store + * @param {import('../../types/cache-interceptor.d.ts').default.CacheKey} cacheKey + */ +function deleteCachedValue (store, cacheKey) { + try { + store.delete(cacheKey)?.catch?.(noop) + } catch { + // Fail silently + } +} + +function deleteCachedValueIfNotModified (statusCode, store, cacheKey) { + if (statusCode === 304) { + deleteCachedValue(store, cacheKey) + } +} + +/** + * @param {import('../../types/cache-interceptor.d.ts').default.CacheOptions['type']} cacheType + * @param {import('../../types/header.d.ts').IncomingHttpHeaders} resHeaders + * @param {import('../../types/cache-interceptor.d.ts').default.CacheControlDirectives} cacheControlDirectives + * @returns {boolean} + */ +function revalidationResponseDisallowsCachedReuse (cacheType, resHeaders, cacheControlDirectives) { + return cacheControlDirectives['no-store'] === true || + (cacheType === 'shared' && cacheControlDirectives.private === true) || + (resHeaders.vary ? isInvalidOrWildcardVaryHeader(resHeaders.vary) : false) +} + /** * @see https://www.rfc-editor.org/rfc/rfc9111.html#name-storing-responses-to-authen * @@ -345,12 +491,12 @@ class CacheHandler { */ function canCacheResponse (cacheType, statusCode, resHeaders, cacheControlDirectives, reqHeaders) { // Status code must be final and understood. - if (statusCode < 200 || NOT_UNDERSTOOD_STATUS_CODES.includes(statusCode)) { + if (statusCode < 200 || arrayIncludes(NOT_UNDERSTOOD_STATUS_CODES, statusCode)) { return false } // Responses with neither status codes that are heuristically cacheable, nor "explicit enough" caching // directives, are not cacheable. "Explicit enough": see https://www.rfc-editor.org/rfc/rfc9111.html#section-3 - if (!HEURISTICALLY_CACHEABLE_STATUS_CODES.includes(statusCode) && !resHeaders['expires'] && + if (!arrayIncludes(HEURISTICALLY_CACHEABLE_STATUS_CODES, statusCode) && !resHeaders['expires'] && !cacheControlDirectives.public && cacheControlDirectives['max-age'] === undefined && // RFC 9111: a private response directive, if the cache is not shared @@ -369,12 +515,12 @@ function canCacheResponse (cacheType, statusCode, resHeaders, cacheControlDirect } // https://www.rfc-editor.org/rfc/rfc9111.html#section-4.1-5 - if (resHeaders.vary?.includes('*')) { + if (resHeaders.vary && hasVaryStar(resHeaders.vary)) { return false } // https://www.rfc-editor.org/rfc/rfc9111.html#name-storing-responses-to-authen - if (reqHeaders?.authorization) { + if (reqHeaders != null && Object.hasOwn(reqHeaders, 'authorization')) { if ( !cacheControlDirectives.public && !cacheControlDirectives['s-maxage'] && @@ -389,14 +535,14 @@ function canCacheResponse (cacheType, statusCode, resHeaders, cacheControlDirect if ( Array.isArray(cacheControlDirectives['no-cache']) && - cacheControlDirectives['no-cache'].includes('authorization') + arrayIncludes(cacheControlDirectives['no-cache'], 'authorization') ) { return false } if ( Array.isArray(cacheControlDirectives['private']) && - cacheControlDirectives['private'].includes('authorization') + arrayIncludes(cacheControlDirectives['private'], 'authorization') ) { return false } @@ -405,14 +551,51 @@ function canCacheResponse (cacheType, statusCode, resHeaders, cacheControlDirect return true } +/** + * @param {string | string[]} dateHeader + * @returns {Date | null | undefined} + */ +function getDate (dateHeader) { + let dateValue = dateHeader + if (Array.isArray(dateValue)) { + if (dateValue.length !== 1) { + return null + } + + dateValue = dateValue[0] + } + + if (typeof dateValue !== 'string') { + return null + } + + return parseHttpDate(dateValue) +} + /** * @param {string | string[]} ageHeader * @returns {number | undefined} */ function getAge (ageHeader) { - const age = parseInt(Array.isArray(ageHeader) ? ageHeader[0] : ageHeader) + let ageValue = ageHeader + if (Array.isArray(ageValue)) { + if (ageValue.length !== 1) { + return MAX_RESPONSE_AGE + } - return isNaN(age) ? undefined : age * 1000 + ageValue = ageValue[0] + } + + if (typeof ageValue !== 'string' || !/^[\t ]*[0-9]+[\t ]*$/.test(ageValue)) { + return MAX_RESPONSE_AGE + } + + const age = BigInt(ageValue.replace(/^[\t ]+|[\t ]+$/g, '')) + if (age >= BigInt(MAX_RESPONSE_AGE / 1000)) { + return MAX_RESPONSE_AGE + } + + return Number(age) * 1000 } /** @@ -422,51 +605,80 @@ function getAge (ageHeader) { * @param {import('../../types/header.d.ts').IncomingHttpHeaders} resHeaders * @param {Date | undefined} responseDate * @param {import('../../types/cache-interceptor.d.ts').default.CacheControlDirectives} cacheControlDirectives + * @param {boolean} hasValidator whether the response has a validator (etag or + * last-modified) that revalidation requests can be made with * * @returns {number | undefined} time that the value is stale at in seconds or undefined if it shouldn't be cached */ -function determineStaleAt (cacheType, now, age, resHeaders, responseDate, cacheControlDirectives) { +function determineStaleAt (cacheType, now, age, resHeaders, responseDate, cacheControlDirectives, hasValidator) { if (cacheType === 'shared') { // Prioritize s-maxage since we're a shared cache // s-maxage > max-age > Expire // https://www.rfc-editor.org/rfc/rfc9111.html#section-5.2.2.10-3 + if (hasInvalidCacheControlDirective(cacheControlDirectives, 's-maxage')) { + return 0 + } + const sMaxAge = cacheControlDirectives['s-maxage'] if (sMaxAge !== undefined) { - return sMaxAge > 0 ? sMaxAge * 1000 : undefined + if (sMaxAge > 0) { + return sMaxAge * 1000 + } + + // Immediately stale, but storable if we can revalidate it before reuse. + return 0 } } + if (hasInvalidCacheControlDirective(cacheControlDirectives, 'max-age')) { + return 0 + } + const maxAge = cacheControlDirectives['max-age'] if (maxAge !== undefined) { - return maxAge > 0 ? maxAge * 1000 : undefined + if (maxAge > 0) { + return maxAge * 1000 + } + + // Immediately stale, but storable if we can revalidate it before reuse. + return 0 } - if (typeof resHeaders.expires === 'string') { + if (Object.hasOwn(resHeaders, 'expires')) { // https://www.rfc-editor.org/rfc/rfc9111.html#section-5.3 + if (typeof resHeaders.expires !== 'string') { + return 0 + } + const expiresDate = parseHttpDate(resHeaders.expires) - if (expiresDate) { - if (now >= expiresDate.getTime()) { - return undefined - } + if (!expiresDate) { + return 0 + } - if (responseDate) { - if (responseDate >= expiresDate) { - return undefined - } + if (now >= expiresDate.getTime()) { + return 0 + } - if (age !== undefined && age > (expiresDate - responseDate)) { - return undefined - } + if (responseDate) { + if (responseDate >= expiresDate) { + return 0 } - return expiresDate.getTime() - now + const freshnessLifetime = expiresDate.getTime() - responseDate.getTime() + if (age !== undefined && age >= freshnessLifetime) { + return 0 + } + + return freshnessLifetime } + + return expiresDate.getTime() - now } if (typeof resHeaders['last-modified'] === 'string') { // https://www.rfc-editor.org/rfc/rfc9111.html#name-calculating-heuristic-fresh - const lastModified = new Date(resHeaders['last-modified']) - if (isValidDate(lastModified)) { + const lastModified = parseHttpDate(resHeaders['last-modified']) + if (lastModified) { if (lastModified.getTime() >= now) { return undefined } @@ -482,6 +694,12 @@ function determineStaleAt (cacheType, now, age, resHeaders, responseDate, cacheC return 31536000000 } + if (cacheControlDirectives['no-cache'] === true && hasValidator) { + // No freshness source, but a validator lets us revalidate before reuse. + // https://www.rfc-editor.org/rfc/rfc9111.html#section-5.2.2.4 + return 0 + } + return undefined } @@ -518,6 +736,11 @@ function determineDeleteAt (baseTime, cachedAt, cacheControlDirectives, staleAt) // revalidated. if (staleWhileRevalidate === -Infinity && staleIfError === -Infinity && immutable === -Infinity) { const freshnessLifetime = staleAt - baseTime + if (freshnessLifetime <= 0) { + // Revalidation-only entry: no freshness lifetime to size the buffer on, + // so retain it for a bounded window instead. + return cachedAt + REVALIDATION_ONLY_RETENTION + } const datePrecisionPadding = Math.min(Math.max(cachedAt - baseTime, 0), 1000) return staleAt + freshnessLifetime + datePrecisionPadding } @@ -546,14 +769,7 @@ function stripNecessaryHeaders (resHeaders, cacheControlDirectives) { ] if (resHeaders['connection']) { - if (Array.isArray(resHeaders['connection'])) { - // connection: a - // connection: b - headersToRemove.push(...resHeaders['connection'].map(header => header.trim())) - } else { - // connection: a, b - headersToRemove.push(...resHeaders['connection'].split(',').map(header => header.trim())) - } + appendConnectionHeaderTokens(headersToRemove, resHeaders['connection']) } if (Array.isArray(cacheControlDirectives['no-cache'])) { @@ -566,7 +782,7 @@ function stripNecessaryHeaders (resHeaders, cacheControlDirectives) { let strippedHeaders for (const headerName of headersToRemove) { - if (resHeaders[headerName]) { + if (Object.hasOwn(resHeaders, headerName)) { strippedHeaders ??= { ...resHeaders } delete strippedHeaders[headerName] } @@ -575,12 +791,4 @@ function stripNecessaryHeaders (resHeaders, cacheControlDirectives) { return strippedHeaders ?? resHeaders } -/** - * @param {Date} date - * @returns {boolean} - */ -function isValidDate (date) { - return date instanceof Date && Number.isFinite(date.valueOf()) -} - module.exports = CacheHandler diff --git a/deps/undici/src/lib/handler/cache-revalidation-handler.js b/deps/undici/src/lib/handler/cache-revalidation-handler.js index 393d16d52c60fb..2473880d7d9a79 100644 --- a/deps/undici/src/lib/handler/cache-revalidation-handler.js +++ b/deps/undici/src/lib/handler/cache-revalidation-handler.js @@ -19,7 +19,7 @@ class CacheRevalidationHandler { #successful = false /** - * @type {((boolean, any) => void) | null} + * @type {((success: boolean, context?: any, statusCode?: number, headers?: import('../../types/header.d.ts').IncomingHttpHeaders) => void) | null} */ #callback @@ -36,7 +36,7 @@ class CacheRevalidationHandler { #allowErrorStatusCodes /** - * @param {(boolean) => void} callback Function to call if the cached value is valid + * @param {(success: boolean, context?: any, statusCode?: number, headers?: import('../../types/header.d.ts').IncomingHttpHeaders) => void} callback Function to call if the cached value is valid * @param {import('../../types/dispatcher.d.ts').default.DispatchHandlers} handler * @param {boolean} allowErrorStatusCodes */ @@ -71,7 +71,7 @@ class CacheRevalidationHandler { // https://datatracker.ietf.org/doc/html/rfc5861#section-4 this.#successful = statusCode === 304 || (this.#allowErrorStatusCodes && statusCode >= 500 && statusCode <= 504) - this.#callback(this.#successful, this.#context) + this.#callback(this.#successful, this.#context, statusCode, headers) this.#callback = null if (this.#successful) { @@ -109,6 +109,16 @@ class CacheRevalidationHandler { } if (this.#callback) { + // Serve the stale cached response on a connection error, per stale-if-error: + // RFC 5861 counts an unreachable origin (a would-be 5xx) as an error. + // https://datatracker.ietf.org/doc/html/rfc5861#section-4 + if (this.#allowErrorStatusCodes) { + this.#successful = true + this.#callback(true, this.#context) + this.#callback = null + return + } + this.#callback(false) this.#callback = null } diff --git a/deps/undici/src/lib/handler/redirect-handler.js b/deps/undici/src/lib/handler/redirect-handler.js index fd2158302e57e6..1b813dac98b9e8 100644 --- a/deps/undici/src/lib/handler/redirect-handler.js +++ b/deps/undici/src/lib/handler/redirect-handler.js @@ -52,6 +52,8 @@ class RedirectHandler { throw new Error('max redirects') } + let removeContentHeaders = statusCode === 303 + // https://tools.ietf.org/html/rfc7231#section-6.4.2 // https://fetch.spec.whatwg.org/#http-redirect-fetch // In case of HTTP 301 or 302 with POST, change the method to GET @@ -62,6 +64,7 @@ class RedirectHandler { util.destroy(this.opts.body.on('error', noop)) } this.opts.body = null + removeContentHeaders = true } // https://tools.ietf.org/html/rfc7231#section-6.4.4 @@ -101,9 +104,9 @@ class RedirectHandler { } // Remove headers referring to the original URL. - // By default it is Host only, unless it's a 303 (see below), which removes also all Content-* headers. + // By default it is Host only. A 303 or a 301/302 POST-to-GET redirect also removes all Content-* headers. // https://tools.ietf.org/html/rfc7231#section-6.4 - this.opts.headers = cleanRequestHeaders(this.opts.headers, statusCode === 303, this.opts.origin !== origin, this.stripHeadersOnRedirect, this.stripHeadersOnCrossOriginRedirect) + this.opts.headers = cleanRequestHeaders(this.opts.headers, removeContentHeaders, this.opts.origin !== origin, this.stripHeadersOnRedirect, this.stripHeadersOnCrossOriginRedirect) this.opts.path = path this.opts.origin = origin this.opts.query = null diff --git a/deps/undici/src/lib/handler/retry-handler.js b/deps/undici/src/lib/handler/retry-handler.js index 7928d447ee6595..3fc26229a1cc92 100644 --- a/deps/undici/src/lib/handler/retry-handler.js +++ b/deps/undici/src/lib/handler/retry-handler.js @@ -11,7 +11,27 @@ const { function calculateRetryAfterHeader (retryAfter) { const retryTime = new Date(retryAfter).getTime() - return isNaN(retryTime) ? 0 : retryTime - Date.now() + return isNaN(retryTime) ? null : retryTime - Date.now() +} + +function validatePartialResponseContentLength (headers, range, statusCode, retryCount) { + const contentLength = headers['content-length'] + if (contentLength == null) { + return + } + + if (!Number.isFinite(range.start) || !Number.isFinite(range.end)) { + return + } + + const length = Number(contentLength) + const expectedLength = range.end - range.start + 1 + if (!Number.isFinite(length) || length !== expectedLength) { + throw new RequestRetryError('Content-Length mismatch', statusCode, { + headers, + data: { count: retryCount } + }) + } } // A stable controller handed to the downstream handler for the lifetime of the @@ -211,9 +231,11 @@ class RetryHandler { } const retryTimeout = - retryAfterHeader > 0 - ? Math.min(retryAfterHeader, maxTimeout) - : Math.min(minTimeout * timeoutFactor ** (counter - 1), maxTimeout) + retryAfterHeader === 0 + ? 0 + : retryAfterHeader > 0 + ? Math.min(retryAfterHeader, maxTimeout) + : Math.min(minTimeout * timeoutFactor ** (counter - 1), maxTimeout) setTimeout(() => cb(null), retryTimeout) } @@ -268,6 +290,8 @@ class RetryHandler { }) } + validatePartialResponseContentLength(headers, contentRange, statusCode, this.retryCount) + const { start, size, end = size ? size - 1 : null } = contentRange assert(this.start === start, 'content-range mismatch') @@ -292,6 +316,8 @@ class RetryHandler { return } + validatePartialResponseContentLength(headers, range, statusCode, this.retryCount) + const { start, size, end = size ? size - 1 : null } = range assert( start != null && Number.isFinite(start), diff --git a/deps/undici/src/lib/interceptor/cache.js b/deps/undici/src/lib/interceptor/cache.js index fe56baa82c90d9..f50c1b7b67dcbc 100644 --- a/deps/undici/src/lib/interceptor/cache.js +++ b/deps/undici/src/lib/interceptor/cache.js @@ -6,8 +6,9 @@ const util = require('../core/util') const CacheHandler = require('../handler/cache-handler') const MemoryCacheStore = require('../cache/memory-cache-store') const CacheRevalidationHandler = require('../handler/cache-revalidation-handler') -const { assertCacheStore, assertCacheMethods, makeCacheKey, normalizeHeaders, parseCacheControlHeader } = require('../util/cache.js') +const { assertCacheStore, assertCacheMethods, makeCacheKey, normalizeHeaders, parseCacheControlHeader, isInvalidOrWildcardVaryHeader } = require('../util/cache.js') const { AbortError } = require('../core/errors.js') +const { parseHttpDate } = require('../util/date.js') /** * @param {(string | RegExp)[] | undefined} origins @@ -28,6 +29,44 @@ function assertCacheOrigins (origins, name) { const nop = () => {} +function trimOWS (value) { + return value.replace(/^[\t ]+|[\t ]+$/g, '') +} + +function arrayIncludes (array, value) { + for (let i = 0; i < array.length; i++) { + if (array[i] === value) { + return true + } + } + + return false +} + +function hasPragmaNoCache (headers) { + const pragma = headers?.pragma + if (!pragma) { + return false + } + + const values = Array.isArray(pragma) ? pragma : [pragma] + for (let i = 0; i < values.length; i++) { + const value = values[i] + if (typeof value !== 'string') { + continue + } + + const directives = value.split(',') + for (let j = 0; j < directives.length; j++) { + if (trimOWS(directives[j]).toLowerCase() === 'no-cache') { + return true + } + } + } + + return false +} + /** * @typedef {(options: import('../../types/dispatcher.d.ts').default.DispatchOptions, handler: import('../../types/dispatcher.d.ts').default.DispatchHandler) => void} DispatchFn */ @@ -57,16 +96,92 @@ function needsRevalidation (result, cacheControlDirectives, { headers = {} }) { return false } +/** + * @param {import('../../types/cache-interceptor.d.ts').default.GetResult} result + * @param {import('../../types/cache-interceptor.d.ts').default.CacheOptions['type']} cacheType + * @returns {boolean} + */ +function staleResponseRequiresRevalidation (result, cacheType) { + return result.cacheControlDirectives?.['must-revalidate'] === true || + (cacheType === 'shared' && ( + result.cacheControlDirectives?.['proxy-revalidate'] === true || + // https://www.rfc-editor.org/rfc/rfc9111.html#section-5.2.2.10 + // s-maxage implies proxy-revalidate for shared caches. + result.cacheControlDirectives?.['s-maxage'] !== undefined + )) +} + +/** + * @param {import('../../types/cache-interceptor.d.ts').default.CacheOptions['type']} cacheType + * @param {import('../../types/header.d.ts').IncomingHttpHeaders} headers + * @returns {boolean} + */ +function revalidationResponseDisallowsCachedReuse (cacheType, headers) { + if (headers.vary && isInvalidOrWildcardVaryHeader(headers.vary)) { + return true + } + + const cacheControl = headers['cache-control'] + if (!cacheControl) { + return false + } + + const cacheControlDirectives = parseCacheControlHeader(cacheControl) + return cacheControlDirectives['no-store'] === true || + (cacheType === 'shared' && cacheControlDirectives.private === true) +} + +function revalidationResponseUpdatesCacheControl (headers) { + return headers['cache-control'] !== undefined +} + +function deleteCachedValue (store, cacheKey) { + try { + store.delete(cacheKey)?.catch?.(nop) + } catch { + // Fail silently + } +} + +function getUsableLastModified (headers) { + const lastModified = headers?.['last-modified'] + if (typeof lastModified === 'string' && parseHttpDate(lastModified)) { + return lastModified + } +} + +function makeRevalidationHeaders (opts, result) { + const headers = { + ...opts.headers, + 'if-modified-since': getUsableLastModified(result.headers) ?? new Date(result.cachedAt).toUTCString() + } + + if (result.etag) { + headers['if-none-match'] = result.etag + } + + if (result.vary) { + for (const key in result.vary) { + if (result.vary[key] != null) { + headers[key] = result.vary[key] + } + } + } + + return headers +} + /** * @param {import('../../types/cache-interceptor.d.ts').default.GetResult} result * @param {import('../../types/cache-interceptor.d.ts').default.CacheControlDirectives | undefined} cacheControlDirectives + * @param {import('../../types/cache-interceptor.d.ts').default.CacheOptions['type']} cacheType * @returns {boolean} */ -function isStale (result, cacheControlDirectives) { +function isStale (result, cacheControlDirectives, cacheType) { const now = Date.now() if (now > result.staleAt) { // Response is stale - if (cacheControlDirectives?.['max-stale']) { + if (!staleResponseRequiresRevalidation(result, cacheType) && cacheControlDirectives?.['max-stale']) { // There's a threshold where we can serve stale responses, let's see if // we're in it // https://www.rfc-editor.org/rfc/rfc9111.html#name-max-stale @@ -93,11 +208,12 @@ function isStale (result, cacheControlDirectives) { /** * Check if we're within the stale-while-revalidate window for a stale response * @param {import('../../types/cache-interceptor.d.ts').default.GetResult} result + * @param {import('../../types/cache-interceptor.d.ts').default.CacheOptions['type']} cacheType * @returns {boolean} */ -function withinStaleWhileRevalidateWindow (result) { +function withinStaleWhileRevalidateWindow (result, cacheType) { const staleWhileRevalidate = result.cacheControlDirectives?.['stale-while-revalidate'] - if (!staleWhileRevalidate) { + if (!staleWhileRevalidate || staleResponseRequiresRevalidation(result, cacheType)) { return false } @@ -280,14 +396,10 @@ function handleResult ( } const age = Math.round((now - result.cachedAt) / 1000) - if (reqCacheControl?.['max-age'] && age >= reqCacheControl['max-age']) { - // Response is considered expired for this specific request - // https://www.rfc-editor.org/rfc/rfc9111.html#section-5.2.1.1 - return dispatch(opts, handler) - } + const requestMaxAgeExpired = reqCacheControl?.['max-age'] !== undefined && age >= reqCacheControl['max-age'] - const stale = isStale(result, reqCacheControl) - const revalidate = needsRevalidation(result, reqCacheControl, opts) + const stale = requestMaxAgeExpired || isStale(result, reqCacheControl, globalOpts.type) + const revalidate = requestMaxAgeExpired || needsRevalidation(result, reqCacheControl, opts) // Check if the response is stale if (stale || revalidate) { @@ -299,28 +411,13 @@ function handleResult ( // RFC 5861: If we're within stale-while-revalidate window, serve stale immediately // and revalidate in background, unless immediate revalidation is necessary - if (!revalidate && withinStaleWhileRevalidateWindow(result)) { + if (!revalidate && withinStaleWhileRevalidateWindow(result, globalOpts.type)) { // Serve stale response immediately sendCachedValue(handler, opts, result, age, null, true) // Start background revalidation (fire-and-forget) queueMicrotask(() => { - const headers = { - ...opts.headers, - 'if-modified-since': new Date(result.cachedAt).toUTCString() - } - - if (result.etag) { - headers['if-none-match'] = result.etag - } - - if (result.vary) { - for (const key in result.vary) { - if (result.vary[key] != null) { - headers[key] = result.vary[key] - } - } - } + const headers = makeRevalidationHeaders(opts, result) // Background revalidation - update cache if we get new data dispatch( @@ -344,28 +441,15 @@ function handleResult ( } let withinStaleIfErrorThreshold = false - const staleIfErrorExpiry = result.cacheControlDirectives['stale-if-error'] ?? reqCacheControl?.['stale-if-error'] - if (staleIfErrorExpiry) { - withinStaleIfErrorThreshold = now < (result.staleAt + (staleIfErrorExpiry * 1000)) - } - - const headers = { - ...opts.headers, - 'if-modified-since': new Date(result.cachedAt).toUTCString() - } - - if (result.etag) { - headers['if-none-match'] = result.etag - } - - if (result.vary) { - for (const key in result.vary) { - if (result.vary[key] != null) { - headers[key] = result.vary[key] - } + if (!staleResponseRequiresRevalidation(result, globalOpts.type)) { + const staleIfErrorExpiry = result.cacheControlDirectives['stale-if-error'] ?? reqCacheControl?.['stale-if-error'] + if (staleIfErrorExpiry) { + withinStaleIfErrorThreshold = now < (result.staleAt + (staleIfErrorExpiry * 1000)) } } + const headers = makeRevalidationHeaders(opts, result) + // We need to revalidate the response return dispatch( { @@ -373,8 +457,23 @@ function handleResult ( headers }, new CacheRevalidationHandler( - (success, context) => { + (success, context, statusCode, headers) => { if (success) { + if (statusCode === 304) { + if (revalidationResponseDisallowsCachedReuse(globalOpts.type, headers)) { + if (util.isStream(result.body)) { + result.body.on('error', nop).destroy() + } + + deleteCachedValue(globalOpts.store, cacheKey) + return dispatch(opts, new CacheHandler(globalOpts, cacheKey, handler)) + } + + if (revalidationResponseUpdatesCacheControl(headers)) { + deleteCachedValue(globalOpts.store, cacheKey) + } + } + // TODO: successful revalidation should be considered fresh (not give stale warning). sendCachedValue(handler, opts, result, age, context, stale) } else if (util.isStream(result.body)) { @@ -431,11 +530,17 @@ module.exports = (opts = {}) => { type } - const safeMethodsToNotCache = util.safeHTTPMethods.filter(method => methods.includes(method) === false) + const safeMethodsToNotCache = [] + for (let i = 0; i < util.safeHTTPMethods.length; i++) { + const method = util.safeHTTPMethods[i] + if (!arrayIncludes(methods, method)) { + safeMethodsToNotCache.push(method) + } + } return dispatch => { return (opts, handler) => { - if (!opts.origin || safeMethodsToNotCache.includes(opts.method)) { + if (!opts.origin || arrayIncludes(safeMethodsToNotCache, opts.method)) { // Not a method we want to cache or we don't have the origin, skip return dispatch(opts, handler) } @@ -470,7 +575,9 @@ module.exports = (opts = {}) => { const reqCacheControl = opts.headers?.['cache-control'] ? parseCacheControlHeader(opts.headers['cache-control']) - : undefined + : hasPragmaNoCache(opts.headers) + ? { 'no-cache': true } + : undefined if (reqCacheControl?.['no-store']) { return dispatch(opts, handler) diff --git a/deps/undici/src/lib/interceptor/decompress.js b/deps/undici/src/lib/interceptor/decompress.js index 295390ad9ceb21..c675de4197c385 100644 --- a/deps/undici/src/lib/interceptor/decompress.js +++ b/deps/undici/src/lib/interceptor/decompress.js @@ -32,6 +32,8 @@ let warningEmitted = /** @type {boolean} */ (false) class DecompressHandler extends DecoratorHandler { /** @type {Transform[]} */ #decompressors = [] + /** @type {Record | undefined} */ + #trailers /** @type {Readonly} */ #skipStatusCodes /** @type {boolean} */ @@ -123,7 +125,7 @@ class DecompressHandler extends DecoratorHandler { this.#setupDecompressorEvents(decompressor, controller) decompressor.on('end', () => { - super.onResponseEnd(controller, {}) + super.onResponseEnd(controller, this.#trailers) }) } @@ -141,7 +143,7 @@ class DecompressHandler extends DecoratorHandler { super.onResponseError(controller, err) return } - super.onResponseEnd(controller, {}) + super.onResponseEnd(controller, this.#trailers) }) } @@ -236,6 +238,7 @@ class DecompressHandler extends DecoratorHandler { */ onResponseEnd (controller, trailers) { if (this.#decompressors.length > 0) { + this.#trailers = trailers this.#decompressors[0].end() this.#cleanupDecompressors() return @@ -276,6 +279,10 @@ function createDecompressInterceptor (options = {}) { return (dispatch) => { return (opts, handler) => { + if (opts.method === 'HEAD') { + return dispatch(opts, handler) + } + const decompressHandler = new DecompressHandler(handler, options) return dispatch(opts, decompressHandler) } diff --git a/deps/undici/src/lib/llhttp/wasm_build_env.txt b/deps/undici/src/lib/llhttp/wasm_build_env.txt index 92313482375d25..e4cfa0c37626d1 100644 --- a/deps/undici/src/lib/llhttp/wasm_build_env.txt +++ b/deps/undici/src/lib/llhttp/wasm_build_env.txt @@ -1,5 +1,5 @@ -> undici@8.7.0 build:wasm +> undici@8.9.0 build:wasm > node build/wasm.js --docker > docker run --rm --platform=linux/x86_64 --user 1001:1001 --mount type=bind,source=/home/runner/work/node/node/deps/undici/src/lib/llhttp,target=/home/node/build/lib/llhttp --mount type=bind,source=/home/runner/work/node/node/deps/undici/src/build,target=/home/node/build/build --mount type=bind,source=/home/runner/work/node/node/deps/undici/src/deps,target=/home/node/build/deps -t ghcr.io/nodejs/wasm-builder@sha256:975f391d907e42a75b8c72eb77c782181e941608687d4d8694c3e9df415a0970 node build/wasm.js diff --git a/deps/undici/src/lib/mock/mock-interceptor.js b/deps/undici/src/lib/mock/mock-interceptor.js index 1ea7aac486da93..1c16c004a764ae 100644 --- a/deps/undici/src/lib/mock/mock-interceptor.js +++ b/deps/undici/src/lib/mock/mock-interceptor.js @@ -12,6 +12,11 @@ const { } = require('./mock-symbols') const { InvalidArgumentError } = require('../core/errors') const { serializePathWithQuery } = require('../core/util') +const { + types: { + isPromise + } +} = require('node:util') /** * Defines the scope API for an interceptor reply @@ -117,13 +122,9 @@ class MockInterceptor { // Values of reply aren't available right now as they // can only be available when the reply callback is invoked. if (typeof replyOptionsCallbackOrStatusCode === 'function') { - // We'll first wrap the provided callback in another function, - // this function will properly resolve the data from the callback - // when invoked. - const wrappedDefaultsCallback = (opts) => { - // Our reply options callback contains the parameter for statusCode, data and options. - const resolvedData = replyOptionsCallbackOrStatusCode(opts) - + // Resolves the data returned by a reply options callback into + // dispatch data, validating its format along the way. + const resolveReplyCallbackData = (resolvedData) => { // Check if it is in the right format if (typeof resolvedData !== 'object' || resolvedData === null) { throw new InvalidArgumentError('reply options callback must return an object') @@ -138,6 +139,23 @@ class MockInterceptor { } } + // We'll first wrap the provided callback in another function, + // this function will properly resolve the data from the callback + // when invoked. + const wrappedDefaultsCallback = (opts) => { + // Our reply options callback contains the parameter for statusCode, data and options. + const resolvedData = replyOptionsCallbackOrStatusCode(opts) + + // An asynchronous reply options callback resolves to the reply + // parameters, so the dispatch data can only be resolved once the + // returned promise settles. + if (isPromise(resolvedData)) { + return resolvedData.then(resolveReplyCallbackData) + } + + return resolveReplyCallbackData(resolvedData) + } + // Add usual dispatch data, but this time set the data parameter to function that will eventually provide data. const newMockDispatch = addMockDispatch(this[kDispatches], this[kDispatchKey], wrappedDefaultsCallback, { ignoreTrailingSlash: this[kIgnoreTrailingSlash] }) return new MockScope(newMockDispatch) diff --git a/deps/undici/src/lib/mock/mock-utils.js b/deps/undici/src/lib/mock/mock-utils.js index 5b6b5bd79cfa56..111a860e9aee17 100644 --- a/deps/undici/src/lib/mock/mock-utils.js +++ b/deps/undici/src/lib/mock/mock-utils.js @@ -292,25 +292,54 @@ function mockDispatch (opts, handler) { // Get mock dispatch from built key const key = buildKey(opts) const mockDispatch = getMockDispatch(this[kDispatches], key) + const mockDispatches = this[kDispatches] mockDispatch.timesInvoked++ + const { timesInvoked, times } = mockDispatch + + // If it's used up and not persistent, mark as consumed + mockDispatch.consumed = !mockDispatch.persist && timesInvoked >= times + mockDispatch.pending = timesInvoked < times + // Here's where we resolve a callback if a callback is present for the dispatch data. if (mockDispatch.data.callback) { - mockDispatch.data = { ...mockDispatch.data, ...mockDispatch.data.callback(opts) } + const callbackResult = mockDispatch.data.callback(opts) + + // An asynchronous reply options callback resolves to the reply data, so + // the dispatch can only continue once the returned promise settles. + // A rejection cannot be thrown synchronously from the dispatch at that + // point, so it is surfaced as a response error instead. + if (isPromise(callbackResult)) { + callbackResult.then( + (resolvedData) => { + mockDispatch.data = { ...mockDispatch.data, ...resolvedData } + dispatchMockReply(mockDispatches, mockDispatch, key, opts, handler) + }, + (error) => { + deleteMockDispatch(mockDispatches, key) + handler.onResponseError(null, error) + } + ) + return true + } + + mockDispatch.data = { ...mockDispatch.data, ...callbackResult } } - // Parse mockDispatch data - const { data: { statusCode, data, headers, trailers, error }, delay, persist } = mockDispatch - const { timesInvoked, times } = mockDispatch + return dispatchMockReply(mockDispatches, mockDispatch, key, opts, handler) +} - // If it's used up and not persistent, mark as consumed - mockDispatch.consumed = !persist && timesInvoked >= times - mockDispatch.pending = timesInvoked < times +/** + * Replies to a request once the mock dispatch data is fully resolved + */ +function dispatchMockReply (mockDispatches, mockDispatch, key, opts, handler) { + // Parse mockDispatch data + const { data: { statusCode, data, headers, trailers, error }, delay } = mockDispatch // If specified, trigger dispatch error if (error !== null) { - deleteMockDispatch(this[kDispatches], key) + deleteMockDispatch(mockDispatches, key) handler.onResponseError(null, error) return true } @@ -353,10 +382,10 @@ function mockDispatch (opts, handler) { if (typeof delay === 'number' && delay > 0) { timer = setTimeout(() => { timer = null - handleReply(this[kDispatches]) + handleReply(mockDispatches) }, delay) } else { - handleReply(this[kDispatches]) + handleReply(mockDispatches) } function handleReply (mockDispatches, _data = data) { diff --git a/deps/undici/src/lib/util/cache.js b/deps/undici/src/lib/util/cache.js index a8f112168e60fb..d156731d1b5da9 100644 --- a/deps/undici/src/lib/util/cache.js +++ b/deps/undici/src/lib/util/cache.js @@ -3,11 +3,147 @@ const { safeHTTPMethods, pathHasQueryOrFragment, - hasSafeIterator + hasSafeIterator, + isValidHTTPToken } = require('../core/util') const { serializePathWithQuery } = require('../core/util') +const MAX_DELTA_SECONDS = 2147483647 +const RESTRICTIVE_DIRECTIVE_NAMES = ['no-store', 'private', 'no-cache'] +const kInvalidCacheControlDirectives = Symbol('invalid cache-control directives') + +function trimOWS (value) { + return value.replace(/^[\t ]+|[\t ]+$/g, '') +} + +function arrayIncludes (array, value) { + for (let i = 0; i < array.length; i++) { + if (array[i] === value) { + return true + } + } + + return false +} + +function trimOWSStart (value) { + return value.replace(/^[\t ]+/, '') +} + +function trimOWSEnd (value) { + return value.replace(/[\t ]+$/, '') +} + +function findUnescapedQuote (value, start) { + let escaped = false + for (let i = start; i < value.length; i++) { + if (escaped) { + escaped = false + } else if (value[i] === '\\') { + escaped = true + } else if (value[i] === '"') { + return i + } + } + + return -1 +} + +function splitCacheControlHeaderValue (value) { + const directives = [] + let start = 0 + let quoteStart = -1 + let inQuote = false + let escaped = false + + for (let i = 0; i < value.length; i++) { + if (inQuote) { + if (escaped) { + escaped = false + } else if (value[i] === '\\') { + escaped = true + } else if (value[i] === '"') { + inQuote = false + quoteStart = -1 + } + } else if (value[i] === '"') { + inQuote = true + quoteStart = i + } else if (value[i] === ',') { + directives.push({ value: value.substring(start, i), fromMalformedQuote: false }) + start = i + 1 + } + } + + if (!inQuote) { + directives.push({ value: value.substring(start), fromMalformedQuote: false }) + return directives + } + + const tail = value.substring(start) + const quoteOffset = quoteStart - start + let tailStart = 0 + for (let i = 0; i < tail.length; i++) { + if (tail[i] === ',') { + directives.push({ + value: tail.substring(tailStart, i), + fromMalformedQuote: tailStart > quoteOffset + }) + tailStart = i + 1 + } + } + + directives.push({ + value: tail.substring(tailStart), + fromMalformedQuote: tailStart > quoteOffset + }) + return directives +} + +function markInvalidCacheControlDirective (directives, key) { + let invalidDirectives = directives[kInvalidCacheControlDirectives] + + if (invalidDirectives === undefined) { + invalidDirectives = new Set() + Object.defineProperty(directives, kInvalidCacheControlDirectives, { + value: invalidDirectives + }) + } + + invalidDirectives.add(key) +} + +function hasInvalidCacheControlDirective (directives, key) { + return directives[kInvalidCacheControlDirectives]?.has(key) === true +} + +function getMalformedRestrictiveDirectiveName (key) { + for (const directiveName of RESTRICTIVE_DIRECTIVE_NAMES) { + if ( + key.startsWith(directiveName) && + key.length > directiveName.length && + !isValidHTTPToken(key[directiveName.length]) + ) { + return directiveName + } + } + + let tokenOnlyKey = '' + let hasInvalidTokenChar = false + for (let i = 0; i < key.length; i++) { + if (isValidHTTPToken(key[i])) { + tokenOnlyKey += key[i] + } else { + hasInvalidTokenChar = true + } + } + + if (hasInvalidTokenChar && arrayIncludes(RESTRICTIVE_DIRECTIVE_NAMES, tokenOnlyKey)) { + return tokenOnlyKey + } +} + /** * @param {import('../../types/dispatcher.d.ts').default.DispatchOptions} opts */ @@ -30,6 +166,20 @@ function makeCacheKey (opts) { } } +function appendHeader (headers, key, val) { + const headerName = key.toLowerCase() + const current = headers[headerName] + const values = Array.isArray(val) ? val : [val] + + if (current === undefined) { + headers[headerName] = Array.isArray(val) ? val.slice() : val + } else if (Array.isArray(current)) { + current.push(...values) + } else { + headers[headerName] = [current, ...values] + } +} + /** * @param {Record} * @returns {Record} @@ -42,19 +192,61 @@ function normalizeHeaders (opts) { headers = {} if (hasSafeIterator(opts.headers)) { - for (const x of opts.headers) { - if (!Array.isArray(x)) { - throw new Error('opts.headers is not a valid header map') + if (Array.isArray(opts.headers)) { + // Array format: could be flat alternating [k, v, k, v, ...] + // or array-of-pairs [[k, v], ...] + const first = opts.headers[0] + if (Array.isArray(first)) { + for (const x of opts.headers) { + if (!Array.isArray(x)) { + throw new Error('opts.headers is not a valid header map') + } + const [key, val] = x + if (typeof key !== 'string' || typeof val !== 'string') { + throw new Error('opts.headers is not a valid header map') + } + appendHeader(headers, key, val) + } + } else { + // Flat alternating array [k, v, k, v, ...] + const len = opts.headers.length + if (len % 2 !== 0) { + throw new Error('opts.headers is not a valid header map') + } + for (let i = 0; i < len; i += 2) { + const key = opts.headers[i] + const val = opts.headers[i + 1] + if (typeof key !== 'string' || (typeof val !== 'string' && !Array.isArray(val))) { + throw new Error('opts.headers is not a valid header map') + } + if (typeof val === 'string') { + appendHeader(headers, key, val) + } else { + const mapped = [] + for (let j = 0; j < val.length; j++) { + const v = val[j] + mapped.push(typeof v === 'string' ? v : v.toString('latin1')) + } + appendHeader(headers, key, mapped) + } + } } - const [key, val] = x - if (typeof key !== 'string' || typeof val !== 'string') { - throw new Error('opts.headers is not a valid header map') + } else { + // Non-array iterable (e.g. Map) — use original iteration logic + for (const x of opts.headers) { + if (!Array.isArray(x)) { + throw new Error('opts.headers is not a valid header map') + } + const [key, val] = x + if (typeof key !== 'string' || typeof val !== 'string') { + throw new Error('opts.headers is not a valid header map') + } + appendHeader(headers, key, val) } - headers[key.toLowerCase()] = val } } else { for (const key of Object.keys(opts.headers)) { - headers[key.toLowerCase()] = opts.headers[key] + appendHeader(headers, key, opts.headers[key]) } } } else { @@ -126,29 +318,37 @@ function parseCacheControlHeader (header) { * @type {import('../../types/cache-interceptor.d.ts').default.CacheControlDirectives} */ const output = {} + const invalidNumericDirectives = new Set() + const invalidNoArgumentDirectives = new Set() - let directives - if (Array.isArray(header)) { - directives = [] - - for (const directive of header) { - directives.push(...directive.split(',')) - } - } else { - directives = header.split(',') - } + const directives = splitCacheControlHeaderValue(Array.isArray(header) ? header.join(',') : header) for (let i = 0; i < directives.length; i++) { - const directive = directives[i].toLowerCase() + const directiveRecord = directives[i] + const directive = directiveRecord.value.toLowerCase() + const fromMalformedQuote = directiveRecord.fromMalformedQuote const keyValueDelimiter = directive.indexOf('=') let key let value + let keyHasTrailingWhitespace = false + let valueHasLeadingWhitespace = false if (keyValueDelimiter !== -1) { - key = directive.substring(0, keyValueDelimiter).trimStart() - value = directive.substring(keyValueDelimiter + 1) + const rawKey = directive.substring(0, keyValueDelimiter) + const rawValue = directive.substring(keyValueDelimiter + 1) + + keyHasTrailingWhitespace = trimOWSEnd(rawKey) !== rawKey + valueHasLeadingWhitespace = trimOWSStart(rawValue) !== rawValue + key = trimOWS(rawKey) + value = trimOWSStart(rawValue) } else { - key = directive.trim() + key = trimOWS(directive) + } + + const malformedRestrictiveDirectiveName = getMalformedRestrictiveDirectiveName(key) + if (malformedRestrictiveDirectiveName !== undefined) { + output[malformedRestrictiveDirectiveName] = true + continue } switch (key) { @@ -158,7 +358,14 @@ function parseCacheControlHeader (header) { case 's-maxage': case 'stale-while-revalidate': case 'stale-if-error': { - if (value === undefined || value[0] === ' ') { + if (fromMalformedQuote || invalidNumericDirectives.has(key)) { + continue + } + + if (value === undefined || keyHasTrailingWhitespace || valueHasLeadingWhitespace) { + delete output[key] + invalidNumericDirectives.add(key) + markInvalidCacheControlDirective(output, key) continue } @@ -170,22 +377,37 @@ function parseCacheControlHeader (header) { value = value.substring(1, value.length - 1) } - const parsedValue = parseInt(value, 10) - // eslint-disable-next-line no-self-compare - if (parsedValue !== parsedValue) { + if (!/^[0-9]+$/.test(value)) { + delete output[key] + invalidNumericDirectives.add(key) + markInvalidCacheControlDirective(output, key) continue } - if (key === 'max-age' && key in output && output[key] >= parsedValue) { - continue - } + const parsedValue = Math.min(parseInt(value, 10), MAX_DELTA_SECONDS) - output[key] = parsedValue + if (key === 'min-fresh') { + if (!(key in output) || output[key] < parsedValue) { + output[key] = parsedValue + } + } else if (!(key in output) || output[key] > parsedValue) { + output[key] = parsedValue + } break } case 'private': case 'no-cache': { + if (fromMalformedQuote) { + output[key] = true + break + } + + if (value !== undefined && value.length === 0) { + output[key] = true + break + } + if (value) { // The private and no-cache directives can be unqualified (aka just // `private` or `no-cache`) or qualified (w/ a value). When they're @@ -193,45 +415,64 @@ function parseCacheControlHeader (header) { // `no-cache="header1"`, or `no-cache="header1, header2"` // If we're given multiple headers, the comma messes us up since // we split the full header by commas. So, let's loop through the - // remaining parts in front of us until we find one that ends in a - // quote. We can then just splice all of the parts in between the - // starting quote and the ending quote out of the directives array - // and continue parsing like normal. + // remaining parts in front of us until we find one that contains a + // closing quote. We can then skip the consumed quoted-list fragments and + // continue parsing like normal. // https://www.rfc-editor.org/rfc/rfc9111.html#name-no-cache-2 if (value[0] === '"') { // Something like `no-cache="some-header"` OR `no-cache="some-header, another-header"`. + value = trimOWSEnd(value) - // Add the first header on and cut off the leading quote - const headers = [value.substring(1)] + let fieldList = '' + let lastQuotedPart = i + let foundEndingQuote = false + const closingQuote = findUnescapedQuote(value, 1) - let foundEndingQuote = value[value.length - 1] === '"' - if (!foundEndingQuote) { + if (closingQuote !== -1) { + fieldList = value.substring(1, closingQuote) + foundEndingQuote = true + } else { // Something like `no-cache="some-header, another-header"` // This can still be something invalid, e.g. `no-cache="some-header, ...` + const fieldListParts = [value.substring(1)] + for (let j = i + 1; j < directives.length; j++) { - const nextPart = directives[j] - const nextPartLength = nextPart.length + const nextPart = trimOWS(directives[j].value) + const closingQuote = findUnescapedQuote(nextPart, 0) - headers.push(nextPart.trim()) + lastQuotedPart = j - if (nextPartLength !== 0 && nextPart[nextPartLength - 1] === '"') { + if (closingQuote !== -1) { + fieldListParts.push(nextPart.substring(0, closingQuote)) foundEndingQuote = true break } + + fieldListParts.push(nextPart) } + + fieldList = fieldListParts.join(',') } - if (foundEndingQuote) { - let lastHeader = headers[headers.length - 1] - if (lastHeader[lastHeader.length - 1] === '"') { - lastHeader = lastHeader.substring(0, lastHeader.length - 1) - headers[headers.length - 1] = lastHeader - } + if (!foundEndingQuote) { + output[key] = true + break + } - for (let j = 0; j < headers.length; j++) { - headers[j] = headers[j].trim() + i = lastQuotedPart + + const headers = fieldList.split(',') + let validFieldNames = true + for (let j = 0; j < headers.length; j++) { + headers[j] = trimOWS(headers[j]) + if (!isValidHTTPToken(headers[j])) { + validFieldNames = false } + } + if (!validFieldNames) { + output[key] = true + } else if (output[key] !== true) { if (key in output) { output[key] = output[key].concat(headers) } else { @@ -239,13 +480,17 @@ function parseCacheControlHeader (header) { } } } else { - // Something like `no-cache="some-header"` - const fieldName = value.trim() + // Something like `no-cache=some-header` + const fieldName = trimOWS(value) - if (key in output) { - output[key] = output[key].concat(fieldName) - } else { - output[key] = [fieldName] + if (!isValidHTTPToken(fieldName)) { + output[key] = true + } else if (output[key] !== true) { + if (key in output) { + output[key] = output[key].concat(fieldName) + } else { + output[key] = [fieldName] + } } } @@ -254,19 +499,27 @@ function parseCacheControlHeader (header) { } // eslint-disable-next-line no-fallthrough case 'public': - case 'no-store': case 'must-revalidate': case 'proxy-revalidate': case 'immutable': case 'no-transform': case 'must-understand': case 'only-if-cached': - if (value) { + if (fromMalformedQuote || invalidNoArgumentDirectives.has(key)) { + continue + } + + if (value !== undefined) { // These are qualified (something like `public=...`) when they aren't - // allowed to be, skip + // allowed to be, skip all instances of the malformed directive. + delete output[key] + invalidNoArgumentDirectives.add(key) continue } + output[key] = true + break + case 'no-store': output[key] = true break default: @@ -278,31 +531,79 @@ function parseCacheControlHeader (header) { return output } +/** + * @param {string | string[]} varyHeader Vary header from the server + * @returns {string[]} + */ +function splitVaryHeader (varyHeader) { + const values = Array.isArray(varyHeader) ? varyHeader : [varyHeader] + const output = [] + + for (let i = 0; i < values.length; i++) { + const parts = values[i].split(',') + for (let j = 0; j < parts.length; j++) { + output.push(parts[j]) + } + } + + return output +} + +/** + * @param {string | string[]} varyHeader Vary header from the server + * @returns {boolean} + */ +function hasVaryStar (varyHeader) { + const values = splitVaryHeader(varyHeader) + for (let i = 0; i < values.length; i++) { + if (trimOWS(values[i]).indexOf('*') !== -1) { + return true + } + } + + return false +} + /** * @param {string | string[]} varyHeader Vary header from the server * @param {Record} headers Request headers - * @returns {Record} + * @returns {Record | undefined} */ function parseVaryHeader (varyHeader, headers) { - if (typeof varyHeader === 'string' && varyHeader.includes('*')) { + if (hasVaryStar(varyHeader)) { return headers } const output = /** @type {Record} */ ({}) - const varyingHeaders = typeof varyHeader === 'string' - ? varyHeader.split(',') - : varyHeader + const varyingHeaders = splitVaryHeader(varyHeader) for (const header of varyingHeaders) { - const trimmedHeader = header.trim().toLowerCase() + const trimmedHeader = trimOWS(header).toLowerCase() + + if (trimmedHeader.length === 0) { + continue + } - output[trimmedHeader] = headers[trimmedHeader] ?? null + if (!isValidHTTPToken(trimmedHeader)) { + return undefined + } + + const headerValue = headers[trimmedHeader] + output[trimmedHeader] = Array.isArray(headerValue) ? headerValue.slice() : headerValue ?? null } return output } +/** + * @param {string | string[]} varyHeader Vary header from the server + * @returns {boolean} + */ +function isInvalidOrWildcardVaryHeader (varyHeader) { + return hasVaryStar(varyHeader) || parseVaryHeader(varyHeader, {}) === undefined +} + /** * Note: this deviates from the spec a little. Empty etags ("", W/"") are valid, * however, including them in cached resposnes serves little to no purpose. @@ -366,7 +667,7 @@ function assertCacheMethods (methods, name = 'CacheMethods') { } for (const method of methods) { - if (!safeHTTPMethods.includes(method)) { + if (!arrayIncludes(safeHTTPMethods, method)) { throw new TypeError(`element of ${name}-array needs to be one of following values: ${safeHTTPMethods.join(', ')}, got ${method}`) } } @@ -406,7 +707,10 @@ module.exports = { assertCacheKey, assertCacheValue, parseCacheControlHeader, + hasInvalidCacheControlDirective, parseVaryHeader, + hasVaryStar, + isInvalidOrWildcardVaryHeader, isEtagUsable, assertCacheMethods, assertCacheStore, diff --git a/deps/undici/src/lib/util/date.js b/deps/undici/src/lib/util/date.js index 99dd150ab38d0f..a1862a07dc187b 100644 --- a/deps/undici/src/lib/util/date.js +++ b/deps/undici/src/lib/util/date.js @@ -18,6 +18,26 @@ function parseHttpDate (date) { } } +function makeDate (year, monthIdx, day, hour, minute, second, weekday) { + const result = new Date(Date.UTC(year, monthIdx, day, hour, minute, second)) + + // Date.UTC treats years 0-99 as 1900-1999. Reset the full year so component + // checks below validate the HTTP date as written. + if (year >= 0 && year <= 99) { + result.setUTCFullYear(year) + } + + return result.getUTCFullYear() === year && + result.getUTCMonth() === monthIdx && + result.getUTCDate() === day && + result.getUTCHours() === hour && + result.getUTCMinutes() === minute && + result.getUTCSeconds() === second && + result.getUTCDay() === weekday + ? result + : undefined +} + /** * @see https://httpwg.org/specs/rfc9110.html#preferred.date.format * @@ -224,8 +244,7 @@ function parseImfDate (date) { second = (code1 - 48) * 10 + (code2 - 48) // Convert ASCII codes to number } - const result = new Date(Date.UTC(year, monthIdx, day, hour, minute, second)) - return result.getUTCDay() === weekday ? result : undefined + return makeDate(year, monthIdx, day, hour, minute, second, weekday) } /** @@ -429,8 +448,7 @@ function parseAscTimeDate (date) { } const year = (yearDigit1 - 48) * 1000 + (yearDigit2 - 48) * 100 + (yearDigit3 - 48) * 10 + (yearDigit4 - 48) - const result = new Date(Date.UTC(year, monthIdx, day, hour, minute, second)) - return result.getUTCDay() === weekday ? result : undefined + return makeDate(year, monthIdx, day, hour, minute, second, weekday) } /** @@ -644,8 +662,7 @@ function parseRfc850Date (date) { second = (code1 - 48) * 10 + (code2 - 48) // Convert ASCII codes to number } - const result = new Date(Date.UTC(year, monthIdx, day, hour, minute, second)) - return result.getUTCDay() === weekday ? result : undefined + return makeDate(year, monthIdx, day, hour, minute, second, weekday) } module.exports = { diff --git a/deps/undici/src/lib/web/cookies/util.js b/deps/undici/src/lib/web/cookies/util.js index 2e31782978187c..9ed048be874534 100644 --- a/deps/undici/src/lib/web/cookies/util.js +++ b/deps/undici/src/lib/web/cookies/util.js @@ -114,16 +114,80 @@ function validateCookiePath (path) { } /** - * I have no idea why these values aren't allowed to be honest, - * but Deno tests these. - Khafra + * ::= | + * + * ::= any one of the 52 alphabetic characters A through Z in + * upper case and a through z in lower case + * + * ::= any one of the ten digits 0 through 9r + * + * @see https://www.rfc-editor.org/rfc/rfc1034#section-3.5 + * @param {number} code + */ +function isLetterOrDigit (code) { + return ( + (code >= 0x30 && code <= 0x39) || // 0-9 + (code >= 0x41 && code <= 0x5A) || // A-Z + (code >= 0x61 && code <= 0x7A) // a-z + ) +} + +/** + * Validates a cookie domain against the "preferred name syntax". + * + * ::= | " " + * ::=