Proxy boundary handling / Information disclosure; Credential handling in URL

MEDIUM
nodejs/node
Commit: a6e9e3217833
Affected: undici 7.21.0 and earlier embedded in Node.js prior to this upgrade
2026-04-05 11:45 UTC

Description

This commit upgrades undici to 7.22.0 and includes several security-relevant changes that address proxy boundary handling, credential parsing, and websocket upgrade handling. Key points: - EnvHttpProxyAgent no_proxy handling: improved logic to avoid proxying requests when the destination hostname matches the no_proxy rule or is a subdomain, reducing information disclosure and unintended proxy usage for internal hosts. - includesCredentials fix: in fetch/util.js, the function now treats a URL as containing credentials if either the username or the password is present (previously required both). This fixes incorrect handling of URLs like http://user@host/ and http://user:pass@host/, which could affect credential handling in cross-origin contexts or proxy scenarios. - Other related fixes in cache/deduplicate/web fetch code and websocket upgrade handling were also touched. Collectively, these changes address potential security issues around proxy usage leakage and credential handling, rather than being purely cosmetic or dependency version bumps. Affected attack surface includes proxy boundary bypass (information disclosure) and credential handling in URLs (security policy correctness).

Proof of Concept

PoC: Demonstrating potential proxy-bypass information disclosure due to no_proxy handling Goal: Show that, before the fix, a request to a host listed in no_proxy could still be sent via an HTTP proxy, leaking the internal destination to the proxy. The PoC uses a local proxy to observe whether traffic intended to bypass the proxy actually goes through the proxy. Prereqs: Node.js environment with undici available (pre-fix 7.21.0 behavior). A local HTTP proxy and a local destination server. 1) Start a simple local HTTP proxy that logs incoming requests and forwards them (acts as a forward proxy). Code (proxy.js): const http = require('http'); const httpProxy = require('http-proxy'); const proxy = httpProxy.createProxyServer({}); const proxyServer = http.createServer((req, res) => { console.log('[Proxy] Received request for target:', req.url); // Determine target: if absolute URL is provided (as in a proxy request), use it; otherwise use host header const target = req.url.startsWith('http') ? req.url : `http://${req.headers.host}${req.url}`; proxy.web(req, res, { target }, (e) => { res.statusCode = 502; res.end('Bad gateway'); }); }); proxyServer.listen(8888, () => console.log('Proxy listening on 127.0.0.1:8888')); 2) Start a simple internal destination server (internal.example.local mapped to 127.0.0.1). Code (dest.js): const http = require('http'); http.createServer((req, res) => { res.statusCode = 200; res.end('ok'); }).listen(8080, () => console.log('Destination server listening on 127.0.0.1:8080')); 3) Client request using undici fetch, configured to use the proxy but with NO_PROXY including the destination host. Install undici in your project if not present and run the following script (client.js): const { fetch } = require('undici'); (async () => { const res = await fetch('http://127.0.0.1:8080/'); console.log('Status:', res.status); console.log(await res.text()); })(); 4) Environment setup (test both buggy and fixed behavior): - Run with HTTP_PROXY=http://127.0.0.1:8888 NO_PROXY=127.0.0.1 node client.js Expected (vulnerability scenario): the proxy logs a request targeting http://127.0.0.1:8080/ (demonstrating traffic going through proxy despite NO_PROXY containing 127.0.0.1). - After applying the fix (7.22.0): run the same commands; the traffic should bypass the proxy (no proxy log), hitting 127.0.0.1:8080 directly or be handled via direct connection as per NO_PROXY semantics. 5) Observation: If the pre-fix behavior proxies internal destinations despite NO_PROXY rules, the proxy will log the target URL (e.g., http://127.0.0.1:8080/), confirming information disclosure of internal addresses to an external proxy. The post-fix behavior should bypass the proxy as intended, preventing leakage. Note: This PoC is a simplified demonstration. In real environments, you would run two Node versions (pre-fix 7.21.0 vs post-fix 7.22.0) to observe the difference.

Commit Details

Author: Node.js GitHub Bot

Date: 2026-03-02 00:49 UTC

Message:

deps: update undici to 7.22.0 PR-URL: https://github.com/nodejs/node/pull/62035 Reviewed-By: Matthew Aitken <maitken033380023@gmail.com> Reviewed-By: Colin Ihrig <cjihrig@gmail.com> Reviewed-By: Richard Lau <richard.lau@ibm.com>

Triage Assessment

Vulnerability Type: Information disclosure / Credential handling / Proxy handling

Confidence: MEDIUM

Reasoning:

The commit updates the undici HTTP client library to 7.22.0 and includes several changes that touch security-relevant areas: proxy/hostname handling for no_proxy, handling of credentials in URLs (includesCredentials logic), and upgrade handling for websockets. These changes indicate vulnerability-related fixes (proxy boundary correctness, proper credential handling, and safer cache/stream behavior) rather than purely cosmetic updates.

Verification Assessment

Vulnerability Type: Proxy boundary handling / Information disclosure; Credential handling in URL

Confidence: MEDIUM

Affected Versions: undici 7.21.0 and earlier embedded in Node.js prior to this upgrade

Code Diff

diff --git a/deps/undici/src/lib/dispatcher/env-http-proxy-agent.js b/deps/undici/src/lib/dispatcher/env-http-proxy-agent.js index 018bc6ce28d56d..f88437f1936abb 100644 --- a/deps/undici/src/lib/dispatcher/env-http-proxy-agent.js +++ b/deps/undici/src/lib/dispatcher/env-http-proxy-agent.js @@ -95,16 +95,14 @@ class EnvHttpProxyAgent extends DispatcherBase { if (entry.port && entry.port !== port) { continue // Skip if ports don't match. } - if (!/^[.*]/.test(entry.hostname)) { - // No wildcards, so don't proxy only if there is not an exact match. - if (hostname === entry.hostname) { - return false - } - } else { - // Don't proxy if the hostname ends with the no_proxy host. - if (hostname.endsWith(entry.hostname.replace(/^\*/, ''))) { - return false - } + // Don't proxy if the hostname is equal with the no_proxy host. + if (hostname === entry.hostname) { + return false + } + // Don't proxy if the hostname is the subdomain of the no_proxy host. + // Reference - https://github.com/denoland/deno/blob/6fbce91e40cc07fc6da74068e5cc56fdd40f7b4c/ext/fetch/proxy.rs#L485 + if (hostname.slice(-(entry.hostname.length + 1)) === `.${entry.hostname}`) { + return false } } @@ -123,7 +121,8 @@ class EnvHttpProxyAgent extends DispatcherBase { } const parsed = entry.match(/^(.+):(\d+)$/) noProxyEntries.push({ - hostname: (parsed ? parsed[1] : entry).toLowerCase(), + // strip leading dot or asterisk with dot + hostname: (parsed ? parsed[1] : entry).replace(/^\*?\./, '').toLowerCase(), port: parsed ? Number.parseInt(parsed[2], 10) : 0 }) } diff --git a/deps/undici/src/lib/handler/cache-handler.js b/deps/undici/src/lib/handler/cache-handler.js index d074cb72dea054..93a70e80535eff 100644 --- a/deps/undici/src/lib/handler/cache-handler.js +++ b/deps/undici/src/lib/handler/cache-handler.js @@ -193,57 +193,92 @@ class CacheHandler { // Not modified, re-use the cached value // https://www.rfc-editor.org/rfc/rfc9111.html#name-handling-304-not-modified if (statusCode === 304) { - /** - * @type {import('../../types/cache-interceptor.d.ts').default.CacheValue} - */ - const cachedValue = this.#store.get(this.#cacheKey) - if (!cachedValue) { - // Do not create a new cache entry, as a 304 won't have a body - so cannot be cached. - return downstreamOnHeaders() - } - - // Re-use the cached value: statuscode, statusmessage, headers and body - value.statusCode = cachedValue.statusCode - value.statusMessage = cachedValue.statusMessage - value.etag = cachedValue.etag - value.headers = { ...cachedValue.headers, ...strippedHeaders } + const handle304 = (cachedValue) => { + if (!cachedValue) { + // Do not create a new cache entry, as a 304 won't have a body - so cannot be cached. + return downstreamOnHeaders() + } - downstreamOnHeaders() + // Re-use the cached value: statuscode, statusmessage, headers and body + value.statusCode = cachedValue.statusCode + value.statusMessage = cachedValue.statusMessage + value.etag = cachedValue.etag + value.headers = { ...cachedValue.headers, ...strippedHeaders } - this.#writeStream = this.#store.createWriteStream(this.#cacheKey, value) + downstreamOnHeaders() - if (!this.#writeStream || !cachedValue?.body) { - return - } + this.#writeStream = this.#store.createWriteStream(this.#cacheKey, value) - const bodyIterator = cachedValue.body.values() + if (!this.#writeStream || !cachedValue?.body) { + return + } - const streamCachedBody = () => { - for (const chunk of bodyIterator) { - const full = this.#writeStream.write(chunk) === false - this.#handler.onResponseData?.(controller, chunk) - // when stream is full stop writing until we get a 'drain' event - if (full) { - break + if (typeof cachedValue.body.values === 'function') { + const bodyIterator = cachedValue.body.values() + + const streamCachedBody = () => { + for (const chunk of bodyIterator) { + const full = this.#writeStream.write(chunk) === false + this.#handler.onResponseData?.(controller, chunk) + // when stream is full stop writing until we get a 'drain' event + if (full) { + break + } + } } - } - } - this.#writeStream - .on('error', function () { - handler.#writeStream = undefined - handler.#store.delete(handler.#cacheKey) - }) - .on('drain', () => { + this.#writeStream + .on('error', function () { + handler.#writeStream = undefined + handler.#store.delete(handler.#cacheKey) + }) + .on('drain', () => { + streamCachedBody() + }) + .on('close', function () { + if (handler.#writeStream === this) { + handler.#writeStream = undefined + } + }) + streamCachedBody() - }) - .on('close', function () { - if (handler.#writeStream === this) { - handler.#writeStream = undefined - } - }) + } else if (typeof cachedValue.body.on === 'function') { + // Readable stream body (e.g. from async/remote cache stores) + cachedValue.body + .on('data', (chunk) => { + this.#writeStream.write(chunk) + this.#handler.onResponseData?.(controller, chunk) + }) + .on('end', () => { + this.#writeStream.end() + }) + .on('error', () => { + this.#writeStream = undefined + this.#store.delete(this.#cacheKey) + }) + + this.#writeStream + .on('error', function () { + handler.#writeStream = undefined + handler.#store.delete(handler.#cacheKey) + }) + .on('close', function () { + if (handler.#writeStream === this) { + handler.#writeStream = undefined + } + }) + } + } - streamCachedBody() + /** + * @type {import('../../types/cache-interceptor.d.ts').default.CacheValue} + */ + const result = this.#store.get(this.#cacheKey) + if (result && typeof result.then === 'function') { + result.then(handle304) + } else { + handle304(result) + } } else { if (typeof resHeaders.etag === 'string' && isEtagUsable(resHeaders.etag)) { value.etag = resHeaders.etag diff --git a/deps/undici/src/lib/interceptor/cache.js b/deps/undici/src/lib/interceptor/cache.js index b0449374fd4782..81d7cb12cbbf5a 100644 --- a/deps/undici/src/lib/interceptor/cache.js +++ b/deps/undici/src/lib/interceptor/cache.js @@ -292,7 +292,7 @@ function handleResult ( // Start background revalidation (fire-and-forget) queueMicrotask(() => { - let headers = { + const headers = { ...opts.headers, 'if-modified-since': new Date(result.cachedAt).toUTCString() } @@ -302,9 +302,10 @@ function handleResult ( } if (result.vary) { - headers = { - ...headers, - ...result.vary + for (const key in result.vary) { + if (result.vary[key] != null) { + headers[key] = result.vary[key] + } } } @@ -335,7 +336,7 @@ function handleResult ( withinStaleIfErrorThreshold = now < (result.staleAt + (staleIfErrorExpiry * 1000)) } - let headers = { + const headers = { ...opts.headers, 'if-modified-since': new Date(result.cachedAt).toUTCString() } @@ -345,9 +346,10 @@ function handleResult ( } if (result.vary) { - headers = { - ...headers, - ...result.vary + for (const key in result.vary) { + if (result.vary[key] != null) { + headers[key] = result.vary[key] + } } } diff --git a/deps/undici/src/lib/interceptor/deduplicate.js b/deps/undici/src/lib/interceptor/deduplicate.js index 9244088fa48bb5..11c4f3701af512 100644 --- a/deps/undici/src/lib/interceptor/deduplicate.js +++ b/deps/undici/src/lib/interceptor/deduplicate.js @@ -46,8 +46,6 @@ module.exports = (opts = {}) => { // Convert to lowercase Set for case-insensitive header exclusion from deduplication key const excludeHeaderNamesSet = new Set(excludeHeaderNames.map(name => name.toLowerCase())) - const safeMethodsToNotDeduplicate = util.safeHTTPMethods.filter(method => methods.includes(method) === false) - /** * Map of pending requests for deduplication * @type {Map<string, DeduplicationHandler>} @@ -56,7 +54,7 @@ module.exports = (opts = {}) => { return dispatch => { return (opts, handler) => { - if (!opts.origin || safeMethodsToNotDeduplicate.includes(opts.method)) { + if (!opts.origin || methods.includes(opts.method) === false) { return dispatch(opts, handler) } diff --git a/deps/undici/src/lib/llhttp/wasm_build_env.txt b/deps/undici/src/lib/llhttp/wasm_build_env.txt index 79a11c8ce14fae..92a6a1ee9a2b4e 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@7.21.0 build:wasm +> undici@7.22.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/web/fetch/index.js b/deps/undici/src/lib/web/fetch/index.js index 47d0a1fde0945b..f35003538bb560 100644 --- a/deps/undici/src/lib/web/fetch/index.js +++ b/deps/undici/src/lib/web/fetch/index.js @@ -2302,6 +2302,41 @@ async function httpNetworkFetch ( reject(error) }, + onRequestUpgrade (_controller, status, headers, socket) { + // We need to support 200 for websocket over h2 as per RFC-8441 + // Absence of session means H1 + if ((socket.session != null && status !== 200) || (socket.session == null && status !== 101)) { + return false + } + + const headersList = new HeadersList() + + for (const [name, value] of Object.entries(headers)) { + if (value == null) { + continue + } + + const headerName = name.toLowerCase() + + if (Array.isArray(value)) { + for (const entry of value) { + headersList.append(headerName, String(entry), true) + } + } else { + headersList.append(headerName, String(value), true) + } + } + + resolve({ + status, + statusText: STATUS_CODES[status], + headersList, + socket + }) + + return true + }, + onUpgrade (status, rawHeaders, socket) { // We need to support 200 for websocket over h2 as per RFC-8441 // Absence of session means H1 diff --git a/deps/undici/src/lib/web/fetch/util.js b/deps/undici/src/lib/web/fetch/util.js index 5e51bdd35aa954..fe63cb3a9b07e5 100644 --- a/deps/undici/src/lib/web/fetch/util.js +++ b/deps/undici/src/lib/web/fetch/util.js @@ -1439,7 +1439,7 @@ function hasAuthenticationEntry (request) { */ function includesCredentials (url) { // A URL includes credentials if its username or password is not the empty string. - return !!(url.username && url.password) + return !!(url.username || url.password) } /** diff --git a/deps/undici/src/package.json b/deps/undici/src/package.json index 71267e0c075a0f..f9449d70f37b8e 100644 --- a/deps/undici/src/package.json +++ b/deps/undici/src/package.json @@ -1,6 +1,6 @@ { "name": "undici", - "version": "7.21.0", + "version": "7.22.0", "description": "An HTTP/1.1 client, written from scratch for Node.js", "homepage": "https://undici.nodejs.org", "bugs": { @@ -119,7 +119,7 @@ "c8": "^10.0.0", "cross-env": "^10.0.0", "dns-packet": "^5.4.0", - "esbuild": "^0.25.2", + "esbuild": "^0.27.3", "eslint": "^9.9.0", "fast-check": "^4.1.1", "husky": "^9.0.7", diff --git a/deps/undici/undici.js b/deps/undici/undici.js index 52148e215f336f..3e3469437308e4 100644 --- a/deps/undici/undici.js +++ b/deps/undici/undici.js @@ -10,7 +10,7 @@ var __commonJS = (cb, mod) => function __require() { var require_errors = __commonJS({ "lib/core/errors.js"(exports2, module2) { "use strict"; - var kUndiciError = Symbol.for("undici.error.UND_ERR"); + var kUndiciError = /* @__PURE__ */ Symbol.for("undici.error.UND_ERR"); var UndiciError = class extends Error { static { __name(this, "UndiciError"); @@ -27,7 +27,7 @@ var require_errors = __commonJS({ return true; } }; - var kConnectTimeoutError = Symbol.for("undici.error.UND_ERR_CONNECT_TIMEOUT"); + var kConnectTimeoutError = /* @__PURE__ */ Symbol.for("undici.error.UND_ERR_CONNECT_TIMEOUT"); var ConnectTimeoutError = class extends UndiciError { static { __name(this, "ConnectTimeoutError"); @@ -45,7 +45,7 @@ var require_errors = __commonJS({ return true; } }; - var kHeadersTimeoutError = Symbol.for("undici.error.UND_ERR_HEADERS_TIMEOUT"); + var kHeadersTimeoutError = /* @__PURE__ */ Symbol.for("undici.error.UND_ERR_HEADERS_TIMEOUT"); var HeadersTimeoutError = class extends UndiciError { static { __name(this, "HeadersTimeoutError"); @@ -63,7 +63,7 @@ var require_errors = __commonJS({ return true; } }; - var kHeadersOverflowError = Symbol.for("undici.error.UND_ERR_HEADERS_OVERFLOW"); + var kHeadersOverflowError = /* @__PURE__ */ Symbol.for("undici.error.UND_ERR_HEADERS_OVERFLOW"); var HeadersOverflowError = class extends UndiciError { static { __name(this, "HeadersOverflowError"); @@ -81,7 +81,7 @@ var require_errors = __commonJS({ return true; } }; - var kBodyTimeoutError = Symbol.for("undici.error.UND_ERR_BODY_TIMEOUT"); + var kBodyTimeoutError = /* @__PURE__ */ S ... [truncated]
← Back to Alerts View on GitHub →