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.
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]