The Patch Was Eight Months Old: Node’s Brotli RCE and the Runtimes That Vendored the Bug

On 21 October 2026 the Node.js project released 22.23.4, 24.22.1 and 26.12.1, closing ten security issues across all three supported lines. Four are rated high by the project, and the one that got the headlines is CVE-2026-111497 — described in the advisory as “Node.js Brotli decoder out-of-bounds write permits remote code execution,” affecting 26.x, 24.x and 22.x, reported by mcollina and fixed by aduh95. That much was widely covered, and it landed the same day as the PHP2Shell emergency release, which absorbed most of the oxygen.

The more interesting fact is one nobody put in a headline: the fix is not Node’s. It is upstream commit google/brotli@0ed4268a, titled “Detect and report invalid stream earlier,” authored by Evgenii Kliuchnikov and committed on 27 February 2026 — nearly eight months before the advisory. It touches c/dec/decode.c (+58/−29) and c/include/brotli/decode.h, and it carries a PiperOrigin-RevId trailer, meaning it arrived through Google’s internal source pipeline as ordinary correctness work rather than as a security response. For most of 2026 that commit sat in brotli’s main branch, in no tagged release, while every project that vendors the C library kept compiling the version without it.

Three backport releases, cut the same evening

The upstream project did respond, and quickly, once the CVE existed. We read the release metadata directly from the GitHub API: google/brotli published v1.0.10, v1.1.1 and v1.2.1 within 36 seconds of each other at roughly 19:27 UTC on 21 October 2026 — three backport releases across three maintenance lines, all by the maintainer eustas. The release notes on all three are a single line under a ### SECURITY heading: “CVE-2026-111497: out-of-bounds write permits remote code execution.”

We verified the content rather than trusting the label. The error constant the fix introduces, BROTLI_DECODER_ERROR_FORMAT_BLOCK_SWITCH (−17), is present at line 88 of c/include/brotli/decode.h on tag v1.2.1 and absent entirely from v1.2.0. The v1.2.1 tag resolves to commit db24a98a, “prepare for release,” dated 21 October. Commit comparison confirms the shape: 0ed4268a is a descendant of v1.2.0 and v1.1.0 — so neither of those tags contains it — while v1.2.1, v1.1.1 and v1.0.10 each carry two commits off that line, the signature of a cherry-pick onto a release branch. The releases are real and they are the fix.

The part that should worry you: nothing will tell you

Here is the gap. CVE-2026-111497 is, in the vulnerability databases, a Node.js vulnerability. The underlying library has no corresponding record of its own. As of this writing:

  • The google/brotli repository’s GitHub security advisories list returns an empty array — no GHSA.
  • OSV returns {"code":5,"message":"Vulnerability not found"} for CVE-2026-111497.
  • NVD returns zero results for the CVE ID.

So the only machine-readable statement that brotli < v1.2.1 is dangerous is a free-text line in a GitHub release body. If you vendor the C sources — as runtimes, browsers, proxies, language bindings and embedded HTTP stacks routinely do — no scanner in your pipeline has anything to match against. Your SCA tool sees a pinned commit hash or a copied c/dec/ directory, looks for an advisory keyed to brotli, finds none, and reports clean. This is the same failure mode we documented when four fast-uri parser CVEs returned zero hits in OSV against 91 million weekly downloads: the advisory exists for the consumer that happened to get a CVE assigned, not for the component that actually contains the bug.

Bun ran the experiment in public

You do not have to theorise about the downstream consequences, because one runtime’s maintainers and community worked through the whole sequence in a public issue thread on the day, and the timestamps are all retrievable.

At 14:02 UTC on 21 October — before google/brotli had cut any release — a contributor (fredluz) opened oven-sh/bun#46352, “Brotli decoder is missing the CVE-2026-111497 fix.” The report is a model of the genre. It states that every Bun release is affected: builds up to 1.4.2 compile brotli v1.1.0, while 1.4.3 and main compile v1.2.0, and Bun applies no brotli patches. It establishes identity rather than inference — the c/dec/decode.c Bun compiles is byte-identical to Node’s pre-fix copy, v1.1.0 matching Node 22 and v1.2.0 matching Node 24 and 26. It names the reachable paths: the decoder is hit by default from fetch() on any Content-Encoding: br response, and also from node:zlib and DecompressionStream("brotli"). And it supplies a differential test anyone can run, drawn from Node’s own test/parallel/test-zlib-invalid-input.js: unpatched builds fail with BLOCK_LENGTH_1 (−9), patched builds reject the stream earlier with BLOCK_SWITCH (−17). That is the behavioural fingerprint of the February commit, and it is the cheapest way to ask any runtime whether it is fixed without reading its build config.

The contributor also opened a fix — PR #46351, backporting 0ed4268a as a patch file — and then hit the ordinary friction of outside contribution: the PR came from a fork, so CI was blocked pending a maintainer unblocking the build. At the time of writing that PR is still open and unmerged.

What actually shipped took a different and, in fairness, better route, and it was only available because upstream had moved. At 23:12 UTC the Bun maintainer replied in-thread, “We are going to do a release today with the fix for this.” At 02:13:26 UTC on 22 October, commit 2a5cbbcb08 (PR #46392) landed on main and closed the issue. Its brotli content is two lines, and we read the diff: scripts/build/deps/brotli.ts moves BROTLI_COMMIT from 028fb5a2 (“v1.2.0”) to db24a98a (“v1.2.1”), and src/brotli_sys/brotli_c.rs adds ERROR_FORMAT_BLOCK_SWITCH = -17 to the BrotliDecoderErrorCode2 enum — exactly the enum addition the original reporter had warned was mandatory, since a patched decoder returning an unmapped discriminant into Rust is its own problem. Bun did not carry a local patch; it consumed the upstream release that had existed for about seven hours.

One more timestamp matters for anyone running Bun today: the newest tagged Bun release is still bun-v1.4.3, published 10 October 2026. The fix is on main. It is not yet in a build you can install. The window between “merged” and “released” is exactly the window in which a project looks patched to anyone reading its commit log and is not patched on anyone’s machine.

The rest of the Node release deserves reading too

CVE-2026-111497 was not the only thing in the box, and two of the other high-severity entries are classic validation failures worth naming. CVE-2026-105483 is a TLS certificate hostname bypass when the IP is written in fullwidth digits — a Unicode-normalisation mismatch between the component that decides “this is an IP address” and the component that compares it, affecting 26.x and 24.x. CVE-2026-106069 is a hostname bypass when the host “loosely resolves” to an IP address, across all three lines, and CVE-2026-106072 (medium) lets DNS SANs authenticate IP endpoints via non-canonical IPv4 hostnames. Three independent certificate-identity bugs in one release is a pattern, not a coincidence: hostname verification keeps breaking at the boundary where a string is reinterpreted as an address. CVE-2026-105482, the fourth high, is dns.resolve truncating names containing a NUL character.

Among the mediums, CVE-2026-106074 is the one that will bite quietly: crypto.randomUUID() and randomInt() entropy caches are captured into user-land startup snapshots and single-executable applications, so every process repeats the same values. If you build SEAs or use startup snapshots, treat any identifier or token minted from those APIs at process start as non-unique and potentially predictable across deployments, and regenerate anything long-lived that came from them. Two low-severity entries (CVE-2026-106071, CVE-2026-106073) are Permission Model bypasses — a wildcard radix over-grant and an fs.realpathSync escape — which is a useful reminder that Node’s Permission Model is a hardening feature, not a sandbox boundary to stake an agent’s filesystem isolation on.

The release also pulls in dependency updates: c-ares, OpenSSL, SQLite, Undici, and a ca-cert update the advisory labels “eMudhra Intermediate CA Compromise” across all three lines — the same distrust event we covered when Go hard-coded eight SPKI hashes into crypto/x509. That is worth sitting with. In a single advisory, Node is both the consumer of a trust decision made elsewhere and, via brotli, the only party with a CVE for a bug that lives elsewhere. Vendored and embedded dependencies invert the usual direction of security information: the advisory flows to whoever has the biggest release process, not to whoever owns the code.

What to do

Upgrade Node to 22.23.4, 24.22.1 or 26.12.1. End-of-life lines are affected and are not getting a fix, which for brotli specifically means a 20.x or earlier deployment stays vulnerable regardless of what you backport around it.

Then do the part your scanner will not do for you: inventory what else in your stack compiles brotli. Search your build configuration and lockfiles for pinned brotli commits or vendored c/dec/ trees — not for a package named brotli in a registry, which is how you will miss it. Anything built against v1.2.0, v1.1.0 or earlier is unfixed; the fixed tags are v1.2.1, v1.1.1 and v1.0.10. Where you cannot read the build, run the behavioural check: feed the invalid-stream vector from Node’s test-zlib-invalid-input.js and see whether you get −9 (unfixed) or −17 (fixed). That test is a two-minute answer for a question your SCA tooling is structurally unable to ask.

For Bun specifically: the fix is on main, there is no release carrying it as of this writing, and the issue being closed does not mean your installed binary is patched. Until a tagged release ships, the practical mitigations are the reachability paths from the report — avoid decompressing untrusted Content-Encoding: br responses, and treat node:zlib and DecompressionStream("brotli") input from the network as hostile. Watch for the next Bun release rather than the issue state.

And for defenders generally, take the structural lesson rather than the CVE. A fix can exist, in public, in the upstream main branch, for eight months, while every downstream consumer ships the bug — because releases, not commits, are what the ecosystem consumes, and because the component with the bug never got an advisory record at all. The thing that caught this was not a scanner. It was a person reading a Node advisory, tracing it to an upstream commit, and checking by hand whether another runtime compiled the same bytes. There is no automation in that chain, which is precisely the problem.

Our verification was read-only. The Node.js release contents — all ten CVE identifiers, severity ratings, affected lines, and the reporter/fixer credits, plus the c-ares, ca-cert (eMudhra), OpenSSL, SQLite and Undici dependency updates — were read first-hand from the Node.js project’s October 2026 security release page. The upstream commit 0ed4268a (author, 27 February 2026 date, commit message, and the two changed files with line counts), the publication timestamps and release bodies of google/brotli v1.0.10, v1.1.1 and v1.2.1, the v1.2.1 tag resolution to db24a98a, the empty GitHub security-advisories response for google/brotli, and the commit-comparison results establishing which tags contain the fix were read first-hand from the GitHub API. The presence of BLOCK_SWITCH (−17) in v1.2.1’s decode.h and its absence from v1.2.0 were confirmed by fetching both files directly. The OSV “Vulnerability not found” response and the NVD zero-result response for CVE-2026-111497 were retrieved directly from those APIs. The Bun issue #46352 body, its creation and closure timestamps, the maintainer comment at 23:12 UTC, the open/unmerged state of PR #46351, the diff of commit 2a5cbbcb08 showing the BROTLI_COMMIT bump and the enum addition, and the current newest Bun release (bun-v1.4.3, 10 October 2026) were read first-hand from the GitHub API. Claims about Bun’s vendored brotli versions, the byte-identity of its decode.c with Node’s pre-fix copy, and the −9/−17 differential are the reporter’s findings as stated in the issue; we did not build Bun or execute the test vector ourselves, and we report them as attributed rather than reproduced. No exploit was written, no proof-of-concept was run, no third-party system was tested, and no malformed input was sent anywhere. Brave Search was used for discovery only.

Sources: