The 2019 commit added the limit to the counter that was already there
CVE-2026-42036 names the streaming branch. Open lib/adapters/http.js at v1.15.0 and read the response handler at line 788. The same responseStream is the input to two branches:
if (responseType === 'stream') {
response.data = responseStream;
settle(resolve, reject, response);
} else {
const responseBuffer = [];
let totalResponseBytes = 0;
responseStream.on('data', function handleStreamData(chunk) {
responseBuffer.push(chunk);
totalResponseBytes += chunk.length;
// make sure the content length is not over the maxContentLength if specified
if (config.maxContentLength > -1 && totalResponseBytes > config.maxContentLength) {
rejected = true;
responseStream.destroy();
abort(
new AxiosError(
'maxContentLength size of ' + config.maxContentLength + ' exceeded',
AxiosError.ERR_BAD_RESPONSE,
config,
lastRequest
)
);
}
});
// ... aborted, error, end handlers ...
}
The else branch buffers. It needs to assemble the full body before resolving the promise, so it pushes each chunk into responseBuffer and tracks totalResponseBytes. The maxContentLength check rides inside that same data handler, four lines below the buffer-append. The accountant the branch needed in order to build response.data is the accountant the maintainer attached the limit to.
The if branch does not buffer. It hands responseStream to the caller and settles immediately. There is no data handler. There is no totalResponseBytes. There is no place inside this branch for the maintainer to have attached the check, because the work the check rode on top of is not work this branch performs.
The commit that added maxContentLength enforcement was acabfbd, authored by Gadzhi Gadzhiev on May 7, 2019, with the message "Destroy stream on exceeding maxContentLength (fixes #1098)." The diff lives entirely inside the buffering chunk handler. The streaming branch four lines above was already present, having been added by Nick Uraltsev in commit d23f9d5 on April 14, 2016. The 2019 author wrote the limit. The 2016 branch did not get the limit. Nothing in the 2019 commit message acknowledges that there was a second branch.
For seven years and eleven months, every Axios user who set responseType: 'stream' plus a maxContentLength and read the docs got one of those things.
The CVE-2026-42036 patch is a Readable that exists only to count
Commit e8904af, authored by Jay Saayman on April 18, 2026, titled "fix: stream response bypassed max content length (#10754)", inserts an async generator immediately above the streaming-branch settle call:
if (responseType === 'stream') {
// Enforce maxContentLength on streamed responses; previously this
// was applied only to buffered responses. See GHSA-vf2m-468p-8v99.
if (config.maxContentLength > -1) {
const limit = config.maxContentLength;
const source = responseStream;
async function* enforceMaxContentLength() {
let totalResponseBytes = 0;
for await (const chunk of source) {
totalResponseBytes += chunk.length;
if (totalResponseBytes > limit) {
throw new AxiosError(
'maxContentLength size of ' + limit + ' exceeded',
AxiosError.ERR_BAD_RESPONSE,
config,
lastRequest
);
}
yield chunk;
}
}
responseStream = stream.Readable.from(enforceMaxContentLength(), {
objectMode: false,
});
}
response.data = responseStream;
settle(resolve, reject, response);
}
enforceMaxContentLength is a Readable whose entire job is to count bytes and throw. It does not transform the data; the yield chunk line passes each chunk through unchanged. The streaming branch had no buffer counter, so the patch synthesizes one and pipes the response through it before handing the stream to the caller. The new totalResponseBytes is the same identifier name the 2019 commit used in the else branch. The patch is the missing buffer counter, written specifically not to buffer.
The comment, "previously this was applied only to buffered responses," is in the patch itself. The maintainer is naming what the original 2019 commit did not say.
The same release ships a twin
Read further down in the same v1.15.1 diff. The same author folds in commit 1c7f6d7 immediately before the upload pipe:
// Enforce maxBodyLength for streamed uploads on the native http/https
// transport (maxRedirects === 0); follow-redirects enforces it on the
// other path. See GHSA-5c9x-8gcm-mpgx.
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.pipe(req);
This is CVE-2026-42034, reported by the same researcher (asadeddin) who reported CVE-2026-42036, fixed in the same release. The patch comment says it flat: follow-redirects enforces it on the other path.
When maxRedirects > 0, Axios pipes the request body through the follow-redirects package, which has its own maxBodyLength check. The Axios native-transport branch does not. When a caller sets maxRedirects: 0, the configuration Axios's own threat-model docs recommend for streamed uploads, follow-redirects is no longer in the pipeline, and the limit goes with it. The gate existed somewhere else. Somewhere else was no longer in scope.
The patch builds a Transform stream whose only job is to count bytes and error. It is the same shape as enforceMaxContentLength thirty lines above: a synthetic counter, written to live in the branch that never had one.
This is the third CVE on this contract in eight months
September 10, 2025: commit 945435f, "fix(node): enforce maxContentLength for data: URLs (#7011)", patches CVE-2025-58754. Axios accepts data: URLs as a request target, and the data-URL handler dispatched inline at lib/adapters/http.js:433 before any byte counter ran. The fix adds an estimateDataURLDecodedBytes helper and an explicit pre-decode check at line 435. The reasoning is the same: the data: URL code path did not go through the buffered chunk handler, so it was not gated by the buffered chunk handler's check.
April 18, 2026: v1.15.1 ships CVE-2026-42036 and CVE-2026-42034.
Three CVEs. One advertised contract. Three separate code paths. Each is patched in its own commit, in the location-specific shape required by that path: an estimateDataURLDecodedBytes helper for data URLs, an async generator for streamed responses, a Transform for streamed uploads. The pattern is parallel-implementation-gap, a capability implemented in multiple parallel paths where the security check ships in one and every other path ships unguarded until a researcher walks through the file.
What's distinct about this exhibit is the within-function variant. Astro's Cloudflare adapter forgot the allowlist because it lived in a different file. mcp-atlassian's upload handlers forgot path validation because they were in a different function. MLflow's FastAPI routers forgot auth because they were in a different routing table. Axios's streaming branch forgot maxContentLength because the limit had been attached to an on('data', ...) handler in the else branch four lines below it. There is no separate file or separate module to point at. The two paths are inside the same function, in adjacent branches of the same control-flow fork. The 2019 author looked at the buffered branch and never moved their gaze up.
What the docs claim
README.md line 673:
By default maxContentLength and maxBodyLength are -1 (unlimited). A malicious or compromised server can return a tiny gzip/deflate/brotli body that expands to gigabytes and exhaust the Node.js process.
docs/pages/misc/security.md line 7:
If you make requests to servers you do not fully trust, you MUST set a maxContentLength (and maxBodyLength) suitable for your workload. The limit is enforced chunk-by-chunk during streaming decompression, so setting it is sufficient to neutralize decompression-bomb attacks.
A Node service author reads "MUST set a maxContentLength" and "sufficient to neutralize decompression-bomb attacks" and concludes the contract is the library's. It is not. The contract is the buffered-response handler's. A caller using responseType: 'stream' to avoid loading the body into memory, the exact case where a decompression bomb is most catastrophic because the application is no longer in a position to bound it itself, gets every byte the server is willing to send regardless of what maxContentLength is set to. The phrase "during streaming decompression" in the docs refers to Node's gzip and brotli Transform streams that the buffering branch wraps the response in. It does not refer to responseType: 'stream'. A reader of those docs is given no way to know that those are different paths.
The v1.15.1 patch closes CVE-2026-42036. The premise that a single configuration knob is sufficient to neutralize decompression bombs in Axios was never implemented at the library boundary. It was implemented at the boundary of one chunk handler that already needed a byte counter for unrelated reasons. Every code path Axios adds that does not happen to need a buffer counter for its own work will ship without the limit, and at some point a researcher will walk through the file and report it as a CVE, and the patch will be a synthetic counter written into that branch.
PoC: GHSA-vf2m-468p-8v99
The contract was advertised across paths the implementation never knew existed.