-----BEGIN PGP SIGNED MESSAGE----- Hash: SHA256 NEFARIOUSPLAN-CANONICAL-V1 {"body_md":"## The 2019 commit added the limit to the counter that was already there\n\nCVE-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:\n\n```js\nif (responseType === 'stream') {\n response.data = responseStream;\n settle(resolve, reject, response);\n} else {\n const responseBuffer = [];\n let totalResponseBytes = 0;\n\n responseStream.on('data', function handleStreamData(chunk) {\n responseBuffer.push(chunk);\n totalResponseBytes += chunk.length;\n\n // make sure the content length is not over the maxContentLength if specified\n if (config.maxContentLength > -1 && totalResponseBytes > config.maxContentLength) {\n rejected = true;\n responseStream.destroy();\n abort(\n new AxiosError(\n 'maxContentLength size of ' + config.maxContentLength + ' exceeded',\n AxiosError.ERR_BAD_RESPONSE,\n config,\n lastRequest\n )\n );\n }\n });\n // ... aborted, error, end handlers ...\n}\n```\n\nThe 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.\n\nThe 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.\n\nThe commit that added `maxContentLength` enforcement was [`acabfbd`](https://github.com/axios/axios/commit/acabfbdf00a58bb866c9d070e8a10d1d0dbeb572), 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`](https://github.com/axios/axios/commit/d23f9d5d4782e5849362895f8b648ed587999706) 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.\n\nFor seven years and eleven months, every Axios user who set `responseType: 'stream'` plus a `maxContentLength` and read the docs got one of those things.\n\n## The CVE-2026-42036 patch is a Readable that exists only to count\n\nCommit [`e8904af`](https://github.com/axios/axios/commit/e8904af03385b040e53f1263a444e825db4335d9), 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:\n\n```js\nif (responseType === 'stream') {\n // Enforce maxContentLength on streamed responses; previously this\n // was applied only to buffered responses. See GHSA-vf2m-468p-8v99.\n if (config.maxContentLength > -1) {\n const limit = config.maxContentLength;\n const source = responseStream;\n async function* enforceMaxContentLength() {\n let totalResponseBytes = 0;\n for await (const chunk of source) {\n totalResponseBytes += chunk.length;\n if (totalResponseBytes > limit) {\n throw new AxiosError(\n 'maxContentLength size of ' + limit + ' exceeded',\n AxiosError.ERR_BAD_RESPONSE,\n config,\n lastRequest\n );\n }\n yield chunk;\n }\n }\n responseStream = stream.Readable.from(enforceMaxContentLength(), {\n objectMode: false,\n });\n }\n response.data = responseStream;\n settle(resolve, reject, response);\n}\n```\n\n`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.\n\nThe 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.\n\n## The same release ships a twin\n\nRead further down in the same v1.15.1 diff. The same author folds in commit `1c7f6d7` immediately before the upload pipe:\n\n```js\n// Enforce maxBodyLength for streamed uploads on the native http/https\n// transport (maxRedirects === 0); follow-redirects enforces it on the\n// other path. See GHSA-5c9x-8gcm-mpgx.\nlet uploadStream = data;\nif (config.maxBodyLength > -1 && 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 > limit) {\n return cb(\n new AxiosError(\n 'Request body larger than maxBodyLength limit',\n AxiosError.ERR_BAD_REQUEST,\n config,\n req\n )\n );\n }\n cb(null, chunk);\n },\n }),\n ],\n utils.noop\n );\n}\nuploadStream.pipe(req);\n```\n\nThis is [CVE-2026-42034](https://github.com/advisories/GHSA-5c9x-8gcm-mpgx), 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.`\n\nWhen `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.\n\nThe 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.\n\n## This is the third CVE on this contract in eight months\n\nSeptember 10, 2025: commit [`945435f`](https://github.com/axios/axios/commit/945435fc51467303768202250debb8d4ae892593), \"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.\n\nApril 18, 2026: v1.15.1 ships CVE-2026-42036 and CVE-2026-42034.\n\nThree 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](/patterns/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.\n\nWhat'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.\n\n## What the docs claim\n\n`README.md` line 673:\n\n> 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.\n\n`docs/pages/misc/security.md` line 7:\n\n> **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.\n\nA 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.\n\nThe 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.\n\nPoC: [GHSA-vf2m-468p-8v99](https://github.com/advisories/GHSA-vf2m-468p-8v99)","closing_line":"The contract was advertised across paths the implementation never knew existed.","hook_md":"The Axios README tells you to set `maxContentLength` and promises the library will enforce it chunk-by-chunk during streaming decompression. That sentence has been in the docs for years. It has never been true for callers using `responseType: 'stream'`.\n\nCVE-2026-42036 is the part where the streaming branch of Axios's Node HTTP adapter never enforced the limit. The same release that fixes it, v1.15.1, ships a second CVE from the same researcher, CVE-2026-42034, where streamed uploads bypass `maxBodyLength` whenever the caller sets `maxRedirects: 0`. Both CVEs are the same shape. Both have always been there.\n\nThe 2019 commit that added `maxContentLength` enforcement attached the check to a byte counter the buffered branch needed for an unrelated reason. Where the counter was, the limit was. Where the counter wasn't, the limit wasn't.","post_id":260,"slug":"axios-maxcontentlength-was-inside-the-buffer-loop","title":"CVE-2026-42036: Axios's maxContentLength Lived Inside the Buffered Branch's Counter. The Streaming Branch Did Not Buffer.","type":"initial","unreadable_sentence":"Where the counter was, the limit was. Where the counter wasn't, the limit wasn't."} -----BEGIN PGP SIGNATURE----- iHUEARYIAB0WIQRf0htP5+SjynlxywneZjl4jgkQJgUCaq1vMgAKCRDeZjl4jgkQ JtbuAQDOO83lLcEgZ+8wl9tYm3+qHk8NQBXMuas1nlK/3brt/AEA7d/u5Wdq7t4s I6GidFFQajHH1It0U0G+ffn6yMsIhgI= =JsZ/ -----END PGP SIGNATURE-----