2
0
mirror of https://github.com/tenrok/axios.git synced 2026-06-17 19:21:29 +03:00

fix: custom auth headers not stripped on cross-origin redirects (#10892)

Co-authored-by: Jason Saayman <jasonsaayman@gmail.com>
Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
SapirBaruch
2026-06-03 11:05:51 +03:00
committed by GitHub
parent 550f0d80a8
commit 6bb12c191f
8 changed files with 291 additions and 5 deletions
+20
View File
@@ -2,6 +2,26 @@
## Unreleased
## Security Fixes
- **HTTP Adapter Redirects:** Added a Node.js `sensitiveHeaders` request config option that strips caller-selected custom secret headers from cross-origin redirects. (**#10892**)
## Docs updates
- docs/pages/misc/security.md LN29 : | [`sensitiveHeaders`](/pages/advanced/request-config#sensitiveheaders) | Custom authentication headers such as `X-API-Key` can be forwarded to a different origin when a trusted server redirects there. | List custom secret-bearing headers in `sensitiveHeaders` so the Node.js adapter strips them on cross-origin redirects. |
- docs/pages/advanced/request-config.md LN269 : | ### `sensitiveHeaders` <Badge type="warning" text="Node.js only" />
The `sensitiveHeaders` property is an optional array of custom secret-bearing header names to remove when axios follows a redirect to a different origin. Matching is case-insensitive. Same-origin redirects keep these headers.
This only applies to redirects followed by the Node.js HTTP adapter. If `maxRedirects` is set to 0, `sensitiveHeaders` is not used.
```js
axios.get('https://api.example.com/users', {
headers: { 'X-API-Key': 'secret' },
sensitiveHeaders: ['X-API-Key']
});
```
## New Features
- **HTTP Adapter - Zstandard:** Added automatic zstd decompression on Node.js versions that support it. `zstd` is only advertised in the default `Accept-Encoding` header when `transitional.advertiseZstdAcceptEncoding: true` is set. (**#6792**)
+6
View File
@@ -898,6 +898,12 @@ These config options are available for requests. Only `url` is required. Request
// If set to 0, Axios follows no redirects.
maxRedirects: 21, // default
// `sensitiveHeaders` (Node only option) lists custom secret-bearing headers
// to remove from cross-origin redirects. Matching is case-insensitive.
// Same-origin redirects keep these headers. If `maxRedirects` is 0, this
// option is not used.
sensitiveHeaders: ['X-API-Key'],
// `beforeRedirect` defines a function that Axios calls before redirect.
// Use this to adjust the request options upon redirecting,
// to inspect the latest response headers,
+2 -2
View File
@@ -104,8 +104,8 @@ The runtime model is general by design. axios is a transport library and cannot
| **Description** | Caller sets `Authorization: Bearer …` and requests `https://api.trusted.com/x`. Server responds `302 Location: https://evil.com/`. Does the bearer token go to evil.com? |
| **Likelihood** | Medium |
| **Impact** | High (full credential theft) |
| **Mitigations** | • Node adapter delegates to `follow-redirects@^1.15.11`, which strips `Authorization`, `Cookie`, and `Proxy-Authorization` on cross-host redirects and on HTTPS to HTTP downgrades. <br>• `maxRedirects` defaults to 5; set to `0` to handle redirects manually. <br>• `beforeRedirect` callback allows custom inspection. <br>• Browser adapters (XHR/fetch) delegate to the browser, which applies its own cross-origin credential rules. |
| **Residual risk** | Low. We inherit `follow-redirects`' security posture. It is a critical transitive dependency, and its CVEs are our CVEs. |
| **Mitigations** | • Node adapter delegates to `follow-redirects@^1.16.0`, which strips `Authorization`, `Cookie`, and `Proxy-Authorization` on cross-host redirects and on HTTPSHTTP downgrades. <br>• `sensitiveHeaders` lets callers list custom secret-bearing headers (for example `X-API-Key`) that axios strips on cross-origin redirects. <br>• `maxRedirects` defaults to 5; set to `0` to handle redirects manually. <br>• `beforeRedirect` callback allows custom inspection. <br>• Browser adapters (XHR/fetch) delegate to the browser, which applies its own cross-origin credential rules. |
| **Residual risk** | Low for standard credential headers and configured custom secret headers. We inherit `follow-redirects`' security posture - it is a critical transitive dependency and its CVEs are our CVEs. Callers must list any custom secret headers they want stripped. |
---
+1
View File
@@ -559,6 +559,7 @@ declare namespace axios {
};
formDataHeaderPolicy?: 'legacy' | 'content-only';
redact?: string[];
sensitiveHeaders?: string[];
}
// Alias
Vendored
+1
View File
@@ -452,6 +452,7 @@ export interface AxiosRequestConfig<D = any> {
};
formDataHeaderPolicy?: 'legacy' | 'content-only';
redact?: string[];
sensitiveHeaders?: string[];
}
// Alias
+71 -2
View File
@@ -154,8 +154,8 @@ const flushOnFinish = (stream, [throttled, flush]) => {
const http2Sessions = new Http2Sessions();
/**
* If the proxy, auth, or config beforeRedirects functions are defined, call them
* with the options object.
* If the proxy, auth, sensitive header, or config beforeRedirects functions are defined,
* call them with the options object.
*
* @param {Object<string, any>} options - The options object that was passed to the request.
*
@@ -168,11 +168,39 @@ function dispatchBeforeRedirect(options, responseDetails, requestDetails) {
if (options.beforeRedirects.auth) {
options.beforeRedirects.auth(options);
}
if (options.beforeRedirects.sensitiveHeaders) {
options.beforeRedirects.sensitiveHeaders(options, requestDetails);
}
if (options.beforeRedirects.config) {
options.beforeRedirects.config(options, responseDetails, requestDetails);
}
}
function stripMatchingHeaders(headers, sensitiveSet) {
if (!headers) {
return;
}
Object.keys(headers).forEach((header) => {
if (sensitiveSet.has(header.toLowerCase())) {
delete headers[header];
}
});
}
function isSameOriginRedirect(redirectOptions, requestDetails) {
if (!requestDetails) {
return false;
}
try {
return new URL(requestDetails.url).origin === new URL(redirectOptions.href).origin;
} catch (e) {
// If origin comparison fails, treat the redirect as unsafe.
return false;
}
}
/**
* If the proxy or config afterRedirects functions are defined, call them with the options
*
@@ -864,6 +892,7 @@ export default isHttpAdapterSupported &&
transport = isHttpsRequest ? https : http;
isNativeTransport = true;
} else {
options.sensitiveHeaders = [];
if (config.maxRedirects) {
options.maxRedirects = config.maxRedirects;
}
@@ -888,6 +917,45 @@ export default isHttpAdapterSupported &&
}
};
}
const sensitiveHeaders = own('sensitiveHeaders');
if (sensitiveHeaders != null) {
if (!utils.isArray(sensitiveHeaders)) {
return reject(
new AxiosError(
'sensitiveHeaders must be an array of strings',
AxiosError.ERR_BAD_OPTION_VALUE,
config
)
);
}
const sensitiveSet = new Set();
for (const header of sensitiveHeaders) {
if (!utils.isString(header)) {
return reject(
new AxiosError(
'sensitiveHeaders must be an array of strings',
AxiosError.ERR_BAD_OPTION_VALUE,
config
)
);
}
sensitiveSet.add(header.toLowerCase());
}
if (sensitiveSet.size) {
options.sensitiveHeaders = Array.from(sensitiveSet);
options.beforeRedirects.sensitiveHeaders = function beforeRedirectSensitiveHeaders(
redirectOptions,
requestDetails
) {
if (!isSameOriginRedirect(redirectOptions, requestDetails)) {
stripMatchingHeaders(redirectOptions.headers, sensitiveSet);
}
};
}
}
transport = isHttpsRequest ? httpsFollow : httpFollow;
}
}
@@ -1254,3 +1322,4 @@ export default isHttpAdapterSupported &&
};
export const __setProxy = setProxy;
export const __isSameOriginRedirect = isSameOriginRedirect;
+161 -1
View File
@@ -10,7 +10,7 @@ import {
} from '../../setup/server.js';
import axios from '../../../index.js';
import AxiosError from '../../../lib/core/AxiosError.js';
import { __setProxy } from '../../../lib/adapters/http.js';
import { __isSameOriginRedirect, __setProxy } from '../../../lib/adapters/http.js';
import HttpsProxyAgent from 'https-proxy-agent';
import http from 'http';
import https from 'https';
@@ -632,6 +632,166 @@ describe('supports http with nodejs', () => {
await stopHTTPServer(proxy);
});
it('should strip sensitiveHeaders on cross-origin redirect', async () => {
let capturedHeaders;
// destination server — different port means different origin
const destination = await startHTTPServer((req, res) => {
capturedHeaders = req.headers;
res.statusCode = 200;
res.end('ok');
});
// origin server — redirects to destination (cross-origin)
const origin = await startHTTPServer((req, res) => {
res.setHeader('Location', `http://localhost:${destination.address().port}/dest`);
res.statusCode = 302;
res.end();
});
try {
await axios.get(`http://localhost:${origin.address().port}/src`, {
maxRedirects: 5,
headers: { 'X-API-Key': 'secret', 'X-Other': 'keep' },
sensitiveHeaders: ['X-API-Key'],
});
assert.strictEqual(capturedHeaders['x-api-key'], undefined, 'X-API-Key should be stripped');
assert.strictEqual(capturedHeaders['x-other'], 'keep', 'X-Other should be preserved');
} finally {
await stopHTTPServer(origin);
await stopHTTPServer(destination);
}
});
it('should preserve sensitiveHeaders on same-origin redirect', async () => {
let capturedHeaders;
let requestCount = 0;
const server = await startHTTPServer((req, res) => {
requestCount++;
if (requestCount === 1) {
res.setHeader('Location', '/dest');
res.statusCode = 302;
res.end();
} else {
capturedHeaders = req.headers;
res.statusCode = 200;
res.end('ok');
}
});
try {
await axios.get(`http://localhost:${server.address().port}/src`, {
maxRedirects: 5,
headers: { 'X-API-Key': 'secret' },
sensitiveHeaders: ['X-API-Key'],
});
assert.strictEqual(capturedHeaders['x-api-key'], 'secret', 'X-API-Key should be preserved on same-origin redirect');
} finally {
await stopHTTPServer(server);
}
});
it('should strip sensitiveHeaders case-insensitively on cross-origin redirect', async () => {
let capturedHeaders;
const destination = await startHTTPServer((req, res) => {
capturedHeaders = req.headers;
res.statusCode = 200;
res.end('ok');
});
const origin = await startHTTPServer((req, res) => {
res.setHeader('Location', `http://localhost:${destination.address().port}/dest`);
res.statusCode = 302;
res.end();
});
try {
await axios.get(`http://localhost:${origin.address().port}/src`, {
maxRedirects: 5,
// Header sent with mixed casing; sensitiveHeaders list uses different casing
headers: { 'X-Api-Key': 'secret' },
sensitiveHeaders: ['x-api-key'],
});
assert.strictEqual(capturedHeaders['x-api-key'], undefined, 'X-Api-Key should be stripped case-insensitively');
} finally {
await stopHTTPServer(origin);
await stopHTTPServer(destination);
}
});
it('should strip sensitiveHeaders configured on an instance', async () => {
let capturedHeaders;
const destination = await startHTTPServer((req, res) => {
capturedHeaders = req.headers;
res.statusCode = 200;
res.end('ok');
});
const origin = await startHTTPServer((req, res) => {
res.setHeader('Location', `http://localhost:${destination.address().port}/dest`);
res.statusCode = 302;
res.end();
});
const client = axios.create({
headers: { 'X-API-Key': 'secret', 'X-Other': 'keep' },
sensitiveHeaders: ['X-API-Key'],
});
try {
await client.get(`http://localhost:${origin.address().port}/src`, {
maxRedirects: 5,
});
assert.strictEqual(capturedHeaders['x-api-key'], undefined, 'X-API-Key should be stripped');
assert.strictEqual(capturedHeaders['x-other'], 'keep', 'X-Other should be preserved');
} finally {
await stopHTTPServer(origin);
await stopHTTPServer(destination);
}
});
it('should reject invalid sensitiveHeaders config', async () => {
await assert.rejects(
axios.get('http://localhost:1/', { sensitiveHeaders: 'X-API-Key' }),
(error) => {
assert.strictEqual(error.code, AxiosError.ERR_BAD_OPTION_VALUE);
assert.strictEqual(error.message, 'sensitiveHeaders must be an array of strings');
return true;
}
);
await assert.rejects(
axios.get('http://localhost:1/', { sensitiveHeaders: [null] }),
(error) => {
assert.strictEqual(error.code, AxiosError.ERR_BAD_OPTION_VALUE);
assert.strictEqual(error.message, 'sensitiveHeaders must be an array of strings');
return true;
}
);
});
it('should fail closed when sensitiveHeaders redirect origin cannot be parsed', () => {
assert.strictEqual(
__isSameOriginRedirect(
{ href: 'http://localhost/final' },
{ url: 'http://localhost/start' }
),
true
);
assert.strictEqual(
__isSameOriginRedirect({ href: 'http://[::1' }, { url: 'http://localhost/start' }),
false
);
assert.strictEqual(__isSameOriginRedirect({ href: 'http://localhost/final' }), false);
});
it('should wrap HTTP errors and keep stack', async () => {
const server = await startHTTPServer(
(req, res) => {
+29
View File
@@ -28,6 +28,7 @@ describe('Prototype Pollution Protection', () => {
delete Object.prototype.baseURL;
delete Object.prototype.socketPath;
delete Object.prototype.beforeRedirect;
delete Object.prototype.sensitiveHeaders;
delete Object.prototype.insecureHTTPParser;
delete Object.prototype.adapter;
delete Object.prototype.httpAgent;
@@ -498,6 +499,34 @@ describe('Prototype Pollution Protection', () => {
}
}, 10000);
it('should not pick up Object.prototype.sensitiveHeaders during redirects', async () => {
Object.prototype.sensitiveHeaders = ['X-Secret'];
let capturedHeaders;
const target = await startServer((req, res) => {
capturedHeaders = req.headers;
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end('{"ok":true}');
});
const { port: targetPort } = target.address();
const redirector = await startServer((req, res) => {
res.writeHead(302, { Location: `http://127.0.0.1:${targetPort}/final` });
res.end();
});
const { port: redirectorPort } = redirector.address();
try {
await axios.get(`http://127.0.0.1:${redirectorPort}/start`, {
headers: { 'X-Secret': 'keep' },
});
assert.strictEqual(capturedHeaders['x-secret'], 'keep');
} finally {
await stopServer(redirector);
await stopServer(target);
}
}, 10000);
it('should not enable insecureHTTPParser via Object.prototype', async () => {
// A raw TCP server emits a response that uses LF-only line terminators
// instead of CRLF. Node's strict HTTP parser rejects this payload with