From b2a693cefc6dde2fd09377a976005c562f0e745a Mon Sep 17 00:00:00 2001 From: Matteo Collina Date: Wed, 2 Sep 2026 17:17:37 +0000 Subject: [PATCH] fix(proxy-agent): guard Proxy-Authorization in iterable header containers buildHeaders() only materialized arrays, so a Map or Headers carrying proxy-authorization passed through untouched and bypassed throwIfProxyAuthIsSent(), whose Object.keys/for...in scan sees no entries on those containers. The header was then forwarded through the CONNECT tunnel to the origin, disclosing proxy credentials (GHSA-6cv7-626c-qhqw). Materialize any iterable header container into a record in buildHeaders() before the guard runs, and add regression tests for Map and Headers inputs. --- lib/dispatcher/proxy-agent.js | 16 ++++++++++++++++ test/proxy-agent.js | 27 +++++++++++++++++++++++++-- 2 files changed, 41 insertions(+), 2 deletions(-) diff --git a/lib/dispatcher/proxy-agent.js b/lib/dispatcher/proxy-agent.js index fd4577c1119..2b118581eec 100644 --- a/lib/dispatcher/proxy-agent.js +++ b/lib/dispatcher/proxy-agent.js @@ -9,6 +9,7 @@ const buildConnector = require('../core/connect') const Client = require('./client') const { channels } = require('../core/diagnostics') const Socks5ProxyAgent = require('./socks5-proxy-agent') +const { hasSafeIterator } = require('../core/util') const kAgent = Symbol('proxy agent') const kClient = Symbol('proxy client') @@ -345,6 +346,21 @@ function buildHeaders (headers) { return headersPair } + // Materialize iterable header containers (e.g. Map, Headers) into a record so + // that throwIfProxyAuthIsSent() can inspect their entries. Object.keys and + // for...in see nothing on a Map/Headers instance, so without this the + // Proxy-Authorization guard is bypassed and proxy credentials can reach the + // origin server (GHSA-6cv7-626c-qhqw). + if (headers && typeof headers === 'object' && hasSafeIterator(headers)) { + const headersPair = {} + + for (const [key, value] of headers) { + headersPair[key] = value + } + + return headersPair + } + return headers } diff --git a/test/proxy-agent.js b/test/proxy-agent.js index c4d04b08d09..4d7e1d686b2 100644 --- a/test/proxy-agent.js +++ b/test/proxy-agent.js @@ -3,7 +3,7 @@ const { tspl } = require('@matteo.collina/tspl') const { test, after } = require('node:test') const diagnosticsChannel = require('node:diagnostics_channel') -const { request, fetch, setGlobalDispatcher, getGlobalDispatcher } = require('..') +const { request, fetch, Headers, setGlobalDispatcher, getGlobalDispatcher } = require('..') const { InvalidArgumentError, ConnectTimeoutError, SecureProxyConnectionError } = require('../lib/core/errors') const ProxyAgent = require('../lib/dispatcher/proxy-agent') const Pool = require('../lib/dispatcher/pool') @@ -837,7 +837,7 @@ test('use proxy-agent with custom headers with tunneling enabled', async (t) => }) test('sending proxy-authorization in request headers should throw', async (t) => { - t = tspl(t, { plan: 3 }) + t = tspl(t, { plan: 5 }) const server = await buildServer() const proxy = await buildProxy() @@ -888,6 +888,29 @@ test('sending proxy-authorization in request headers should throw', async (t) => 'Proxy-Authorization should be sent in ProxyAgent' ) + // Iterable containers (Map/Headers) must not bypass the guard (GHSA-6cv7-626c-qhqw) + await t.rejects( + request( + serverUrl + '/hello?foo=bar', + { + dispatcher: proxyAgent, + headers: new Map([['proxy-authorization', Buffer.from('user:pass').toString('base64')]]) + } + ), + 'Proxy-Authorization should be sent in ProxyAgent' + ) + + await t.rejects( + request( + serverUrl + '/hello?foo=bar', + { + dispatcher: proxyAgent, + headers: new Headers({ 'proxy-authorization': Buffer.from('user:pass').toString('base64') }) + } + ), + 'Proxy-Authorization should be sent in ProxyAgent' + ) + server.close() proxy.close() proxyAgent.close()