Axios: Excessive recursion in formDataToJSON can cause denial of service
Axios versions 0.28.0 and later contain uncontrolled recursion in formDataToJSON, the helper behind the public axios.formToJSON() / named formToJSON API and the default request transform used when FormData is sent with an application/json content type.
Applications are affected when they pass attacker-controlled FormData field names into this functionality. A field name with thousands of nested bracket segments can exhaust the JavaScript call stack and throw RangeError: Maximum call stack size exceeded, causing request failure and, in applications that do not handle the exception or rejected promise, possible process termination.
The impact is denial of service against applications that process untrusted FormData field names through axios' FormData-to-JSON conversion.
The vulnerable path is not reached by merely installing axios, by normal multipart FormData pass-through, or by ordinary axios requests that do not request JSON serialisation of FormData. In the default axios request, the error is produced before network I/O and returned as a rejected Promise. Direct use of formToJSON() throws synchronously.
Server-side applications are the primary risk when remote users can submit arbitrary form field names, and the application converts those fields with formToJSON() or sends them through axios as JSON.
Affected APIs and paths:
axios.formToJSON(formData)import { formToJSON } from "axios"lib/helpers/formDataToJSON.jstransformRequest when data is FormData and Content-Type contains application/jsonUnaffected or lower-risk paths:
FormData requests without JSON Content-TypetoFormData() object-to-FormData serialisation, which already has a maxDepth guardlib/helpers/formDataToJSON.js parses a form field name into path segments with parsePropPath(). For a key such as a[x][x][x], each bracketed segment becomes another path element.
formDataToJSON() then calls the nested buildPath(path, value, target, index) function. buildPath() recursively calls itself once for each path segment and does not enforce a maximum depth:
const result = buildPath(path, value, target[name], index);
A key containing thousands of bracket segments, therefore, creates thousands of recursive calls. At sufficient depth, V8 throws RangeError: Maximum call stack size exceeded.
Axios already applies a depth guard to the inverse serializer in lib/helpers/toFormData.js, where maxDepth defaults to 100 and exceeding it throws AxiosError with code ERR_FORM_DATA_DEPTH_EXCEEDED. formDataToJSON() does not currently have equivalent protection.
import { formToJSON } from "axios";
const fd = new FormData();
fd.append("a" + "[x]".repeat(15000), "value");
try {
formToJSON(fd);
console.log("not vulnerable");
} catch (err) {
console.log(`${err.constructor.name}: ${err.message}`);
}
Expected vulnerable result:
RangeError: Maximum call stack size exceeded
The axios request transform path can also be reached before network I/O:
import axios from "axios";
const fd = new FormData();
fd.append("a" + "[x]".repeat(15000), "value");
await axios
.post("http://127.0.0.1:1/", fd, {
headers: { "Content-Type": "application/json" }
})
.catch((err) => console.log(`${err.constructor.name}: ${err.message}`));
Expected vulnerable result:
RangeError: Maximum call stack size exceeded
Applications can avoid the vulnerable path by not converting attacker-controlled FormData to JSON with axios.
If conversion is required before a fixed axios release is available, validate FormData field names before calling formToJSON() or before sending FormData with Content-Type: application/json. Reject keys whose parsed nesting depth exceeds the application's expected schema.
For axios requests carrying untrusted FormData, avoid setting Content-Type: application/json; leaving the data as multipart FormData bypasses formDataToJSON().
Catching the resulting error can prevent process termination, but it does not remove the uncontrolled-recursion behaviour and should not be treated as the primary mitigation.
| CWE | CVSS Score | Severity | Type |
|---|---|---|---|
| CWE-918 | 7.5 (CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:L/A:L) | HIGH | Server-Side Request Forgery |
The shouldBypassProxy() function in Axios fails to recognise 0.0.0.0, ::, and ::ffff:0.0.0.0 as loopback addresses. When NO_PROXY=localhost is configured, requests to these addresses are incorrectly forwarded through the proxy instead of being sent directly, enabling an SSRF attack against internal services reachable via the proxy's loopback interface.
File: lib/helpers/shouldBypassProxy.js
isIPv4Loopback (lines 3-8): Only checks for 127.x.x.x addresses by inspecting parts[0] !== '127'. The 0.0.0.0 address has parts[0] === '0', so it falls through as non-loopback, even though on Linux 0.0.0.0 routes to the loopback interface.
isIPv6Loopback (lines 10-38): Only checks host === '::1'. The :: address (unspecified IPv6) also routes to the loopback, but is not recognised.
Attack Flow:
isIPv4Loopback (line 3) — fails for 0.0.0.0
→ isLoopback (line 44) — wraps both checks, returns false
→ shouldBypassProxy (line 127) — PUBLIC API, exported default
→ lib/adapters/http.js (line 190) — Node.js HTTP adapter
import shouldBypassProxy from 'axios/lib/helpers/shouldBypassProxy.js';
// Normal loopback — correctly returns true (bypasses proxy)
shouldBypassProxy('http://127.0.0.1:9999/'); // → true
// Vulnerable — returns false (goes through proxy!)
shouldBypassProxy('http://0.0.0.0:9999/'); // → false ← SSRF
shouldBypassProxy('http://[::]:9999/'); // → false ← SSRF
shouldBypassProxy('http://[::ffff:0.0.0.0]:9999/'); // → false ← SSRF
A full 3-container Docker reproduction was created and tested:
Reproduction steps:
cd /tmp/deep-e2e
docker compose up -d
docker compose exec attacker node test-ssrf.js
Results:
127.0.0.1 + NO_PROXY=localhost → BYPASS (correct)0.0.0.0 + NO_PROXY=localhost → VIA_PROXY (SSRF)[::] + NO_PROXY=localhost → VIA_PROXY (SSRF)[::ffff:0.0.0.0] + NO_PROXY=localhost → VIA_PROXY (SSRF)The real Axios HTTP client (v1.16.1, source tree) was tested through proxy configuration:
proxy: { host: 'proxy', port: 8888 }NO_PROXY=localhost and requesting http://0.0.0.0:9999/NO_PROXY=localhost to protect internal serviceshttp://0.0.0.0:8080/admin as the target URL0.0.0.0 → the proxy's own loopback → reaches the internal admin service on port 8080File: lib/helpers/shouldBypassProxy.js
function isIPv4Loopback(host) {
if (host === '0.0.0.0') return true; // ADD THIS LINE
const parts = host.split('.');
if (parts.length !== 4) return false;
if (parts[0] !== '127') return false;
return parts.every(p => /^\d+$/.test(p) && Number(p) >= 0 && Number(p) <= 255);
}
function isIPv6Loopback(host) {
if (host === '::1' || host === '::') return true; // ADD '::'
// ... rest of implementation
}
0.0.0.0 and :: to the NO_PROXY environment variable explicitly127.0.0.1 instead of 0.0.0.0 in all internal service URLs0.0.0.0 and :: before passing to Axios왜 이 VPI인가 (설명가능 · 실험적)
VPI 산정 기준
| 영향도(기본값(정보 없음)) | 55.00 |
| 악용 신호(추가 악용신호 없음) | ×1.00 |
| VPI | 55.00 |
VPI 공식 vpi-v1 기준