Node.js CVE-2026-111497: The Brotli Decoder Keeps Going After an Invalid Block Switch
Brotli splits compressed data into meta-blocks, and within a block the stream can switch between several block types. According to RFC 7932, such a switch is only allowed if a category declares at least two block types. The Brotli decoder bundled with Node.js did check this condition, but it reported a violation with the same return value it used for missing input, and the fast decoding path simply ignored that value. CVE-2026-111497 turns this misunderstanding into an out-of-bounds write, which Node.js classifies as a possible path to code execution. The fix comes from the Brotli project itself and was public there long before it shipped as a security update in Node.js on 21 October.
TL;DR — 90 seconds
- What: The Brotli decoder bundled with Node.js keeps processing an invalid block switch instead of rejecting the stream. According to Node.js this leads to an out-of-bounds write and can enable code execution.
- Severity: Node.js rates the flaw as “high”. A CVSS score and a published CVE record are still missing, and it is not in the CISA KEV catalog.
- Who is affected: Applications on Node.js 22, 24 or 26 and on all EOL lines that decompress Brotli data from untrusted sources. That includes every
fetch()call to third-party servers, because undici decodesContent-Encoding: brautomatically. - What to do: Update to 22.23.4, 24.22.1 or 26.12.1, rebuild images and restart processes. For builds linked against the system library, also bring libbrotli to 1.2.1, 1.1.1 or 1.0.10.
- What would have protected you: rejecting compressed request bodies at the proxy, limiting outbound requests to known destinations and moving decompression of untrusted content into a process without secrets. The Node.js permission model does not help against this mechanism.
One return value with two meanings
Brotli is the compression format from RFC 7932 that browsers and servers negotiate as Content-Encoding: br. A stream consists of meta-blocks, each of which decompresses to up to 16 MiB. Within a meta-block the format distinguishes three categories, literals, commands and distances, and each category may have several block types with their own prefix codes. The stream switches between them using block-switch commands. The RFC ties this to a clear condition: the codes for block type and block count only appear if the category declares at least two block types, expressed in the format as NBLTYPES >= 2. If a category has only one type, there is nothing to switch to, and a switch is a format error.
The decoder in c/dec/decode.c has two variants for many steps. The safe variant expects input to end in the middle of a symbol and saves its state so it can resume later. The fast variant runs in ProcessCommandsInternal when enough input is available and skips this bookkeeping. The block-switch functions, including DecodeBlockTypeAndLength and the switch functions for literals, commands and distances, used to return only a boolean, and BROTLI_FALSE was meant to signal nothing but missing input. The variants for the fast path returned nothing at all and discarded the result, since enough input was guaranteed there.
This is where the bug lay. The same functions also returned BROTLI_FALSE when a block switch appeared in a category with only one block type. In the safe path, the format error thus became a harmless “please send more data”, in the fast path it went unnoticed altogether, and the decoder carried on with a state the format rules out. How exactly this produces the out-of-bounds write, meaning which buffer is hit at which index, is not publicly documented. My reading of the diff suggests that the decoder works with lengths or tables of a block type that does not exist in that category. That is my own inference and not a statement by the vendor.
The RFC anticipated this risk. In its security considerations it advises that decoders handle any byte sequence, reject non-conforming data and bounds-check every memory access that results from values decoded from the stream. It treats decompression bombs separately and suggests sanity checks on the meta-block size and the sliding window. CVE-2026-111497 falls into the first category, and that matters for hardening, because defences against the second miss it entirely.
What the fix commit changes
The fix comes from the Brotli project. Commit 0ed4268a by Evgenii Kliuchnikov carries the plain description “Detect and report invalid stream earlier.” and changes the signature of the affected functions: instead of a boolean they now return a BrotliDecoderErrorCode. Missing input reports BROTLI_DECODER_NEEDS_MORE_INPUT, while a block switch with max_block_type <= 1 reports the new error BROTLI_DECODER_ERROR_FORMAT_BLOCK_SWITCH with the previously reserved code -17. A new macro BROTLI_SAFE_WITH_STATUS makes the fast path abort on this error and save its state as well. A debug assertion for the remaining meta-block length was also added, but it does not apply in release builds.
Node.js cherry-picks the commit into deps/brotli, on the 24.x line as 49dff6f, on 22.x as 67d2947 and on 26.x as a6040b5. A second commit by Antoine du Hamel, b5f4152 on the 24.x line, exposes the new constant to JavaScript in src/node_zlib.cc and adds a regression test that feeds a stream with only one block type and a block switch through both the synchronous and the stream API. lib/zlib.js was not changed.
What the fix reveals about the cause is more instructive than the flaw itself. The code knew the invariant from the RFC and checked it, but it reported the result through a channel that could only carry one meaning. A return value that can mean both “wait” and “abort” is a classic in C, and it becomes dangerous as soon as a caller ignores it for performance reasons. The history is notable too, because the same function ProcessCommandsInternal was the site of CVE-2016-1624, an integer underflow, back in 2016. The fast path of a decoder seems to be where assumptions about the input most easily take on a life of their own.
The timeline, finally, is uncomfortable. Commit 0ed4268a is dated 27 February 2026 and had been public in the upstream repository ever since, with no hint at its security relevance and no tagged release. Brotli releases 1.2.1, 1.1.1 and 1.0.10 only appeared on 21 October together with the CVE. Anyone who read the diff early could have recognised the weakness while distributions and bundled copies were still on the old code.
From a third-party server into the decoder
Node.js does not decompress Brotli on its own. node:http delivers bodies as they arrive over the wire, and an application only becomes vulnerable when it hands attacker-controlled Brotli data to the decoder. That sounds like a strong restriction, but in practice it is a small one. Directly involved are zlib.brotliDecompress, zlib.brotliDecompressSync and zlib.createBrotliDecompress, and many libraries build on these APIs with decompression switched on as a convenience feature.
The most important case is the built-in fetch(). undici, the HTTP implementation behind it, reads the Content-Encoding header and decodes br via createBrotliDecompress. The only exceptions are HEAD and CONNECT requests, responses without a body and redirects that are followed. There is no switch to turn this off on that path. undici only offers br in Accept-Encoding over HTTPS, but the decoding code does not check the scheme. From this I conclude that a server can deliver compressed data even when the client did not ask for it, and tests by Lilting show that Accept-Encoding: identity does not protect you. Every service that fetches URLs from user input, for link previews, webhooks, feed imports or image optimisation, thus pulls the attacker's stream straight into the decoder. Vercel therefore points out that self-hosted Next.js needs the update, because image optimisation does not block Brotli responses.
On the inbound side the best-known candidate is body-parser, which Express 5 uses. According to its README, all parsers inflate gzip, br, deflate and zstd automatically, and the inflate option is enabled by default. An attacker only has to send a request with Content-Encoding: br, and the rest happens before any application logic. Atlasum also lists decompression enabled by default for axios, got and node-fetch, which I have not verified in the code myself.
Node.js sums up the impact in the advisory title as remote code execution, while a CVSS vector is still missing. Vercel reports that researchers demonstrated process-memory disclosure and command execution with the privileges of the Node process on unpatched 22.x, 24.x and 26.x. How much effort the path to code execution takes is not described in the sources. Even reading process memory can expose tokens and credentials from the heap, though, and that is serious damage without any code execution.
The services that are really affected are those that fetch content from third-party servers or accept compressed request bodies. If you only query your own upstreams over HTTPS and accept no inbound compression, your risk is much lower, although a compromised upstream reopens the path. Many operators, on the other hand, only believe they are affected because they use Brotli at the reverse proxy for responses to browsers, yet compression towards the client never touches the decoder.
| CVE | Affected | Fix | Precondition | CVSS |
|---|---|---|---|---|
| CVE-2026-111497 (Brotli decoder) | Node.js 22, 24, 26 before the fixed versions and all EOL lines; libbrotli before 1.2.1, 1.1.1 and 1.0.10 | Node.js 22.23.4 / 24.22.1 / 26.12.1; Brotli 1.2.1 / 1.1.1 / 1.0.10 | Application decompresses Brotli data from an untrusted source, e.g. via fetch() or body-parser | not yet published (Node.js: high) |
| CVE-2026-106069 and CVE-2026-106072 (TLS hostname check) | Node.js 22, 24, 26 | 22.23.4 / 24.22.1 / 26.12.1 | per title: target in non-canonical IP notation | not yet published (high / medium) |
| CVE-2026-105483 (TLS hostname check, fullwidth digits) | Node.js 24, 26 | 24.22.1 / 26.12.1 | per title: IP address written in fullwidth digits | not yet published (high) |
CVE-2026-105482 (NUL byte in dns.resolve) | Node.js 22, 24, 26 | 22.23.4 / 24.22.1 / 26.12.1 | hostname from user input | not yet published (high) |
Node.js rates the remaining five CVEs in the release as medium or low. Most relevant for operators are CVE-2026-106074, where entropy caches of crypto.randomUUID() and randomInt() end up in startup snapshots and single executable applications, and the two permission model bypasses CVE-2026-106071 and CVE-2026-106073, the first of which affects 26.x only. The release also ships a compiled-in block list for the public keys of the compromised eMudhra intermediate certificates, whose background I cover in my post on the eMudhra CA compromise. None of these flaws is in the CISA KEV catalog (as of 21 October), and EPSS has no scores yet because the CVE record is missing.
Node.js 22.23.4, 24.22.1, 26.12.1 and their pitfalls
The complete fix is in Node.js 22.23.4, 24.22.1 as the current LTS, and 26.12.1. There is no update for Node.js 20 and 25, which have reached end of life. According to the advisory, EOL versions are always affected, and the way out is moving to a supported line. According to Atlasum, the official Docker images were rebuilt on 21 October and are available under tags such as node:22.23.4, node:24.22.1 and node:26.12.1. A base image pinned to node:24.22.0 stays vulnerable until you bump the tag, and a floating tag such as node:24-alpine only helps once the pipeline actually pulls a fresh image and rebuilds. Running processes and pods keep the old runtime until they restart.
One pitfall concerns distribution builds of Node. Debian and Alpine build their Node.js packages with --shared-brotli, in which case the runtime uses the system library libbrotlidec instead of the bundled copy. There, the distribution's Brotli package decides whether you are protected, and on 22 October that was not yet the case everywhere. That day the Debian security tracker still listed the nodejs packages in Bookworm, Trixie, Forky and Sid as vulnerable. The Alpine tracker only listed nodejs-current 26.12.1-r0 in edge-community as fixed. Wolfi was quicker: the package definitions in wolfi-dev/os are at nodejs-24 24.22.1 and nodejs-26 26.12.1, and brotli 1.2.0 received a patch for exactly this CVE with epoch 7. The Amazon Linux Security Center had no entry for the CVE yet on October 22. Bun also bundles Brotli without the fix, and the pull request with the backport was still waiting for a response from the maintainers on 22 October.
The update brings some behaviour changes you should know before rolling it out. Invalid Brotli streams that used to be rejected with, for example, ERR__ERROR_FORMAT_BLOCK_LENGTH_1 can now return ERR__ERROR_FORMAT_BLOCK_SWITCH, which affects code that checks error codes. TLS rejects hostnames in non-canonical IP notation, and dns.resolve* throws ERR_INVALID_ARG_VALUE on NUL bytes. Certificate chains through the blocked eMudhra keys fail with CERT_REVOKED. 26.12.1 also reverts the change that loaded most ES modules synchronously. undici moves to 8.12.2 in 26.12.1, 7.30.1 in 24.22.1 and 6.29.1 in 22.23.4, and if you also use undici as an npm package, you update it separately. You need to rebuild startup snapshots and single executable applications with the patched runtime because of CVE-2026-106074, as the old state would otherwise remain in the artefact.
Hardening beyond the patch
To put the measures in context, it helps to look at where the bug sits. It lives in native code running in the address space of the Node process, and for the asynchronous zlib APIs, according to the Node docs, in a thread of the internal thread pool. So there is no boundary between decoder and application that a memory error would have to respect. What works is anything that keeps untrusted Brotli data away from the decoder, or that separates the process doing the decompression from secrets and the network. Settings inside Node.js that limit resources or check permissions are aimed at the wrong place.
Reject compressed request bodies at the edge
Browsers usually send request bodies uncompressed, and many web applications have no legitimate reason to accept Content-Encoding: br inbound. A reverse proxy or ingress that answers such requests with 415 takes the inbound path away from the attacker. In the application you achieve the same with inflate: false in body-parser, which makes the parser reject compressed bodies. Check beforehand whether API clients deliberately send compressed bodies. The measure is cheap, and if you extend the rule to every Content-Encoding except identity, it also keeps future bugs in gzip or deflate decoders away. Its limit is clear, because it does nothing against responses your application fetches itself. A proxy such as nginx cannot decompress and inspect Brotli anyway, since ngx_http_gunzip_module only handles gzip responses.
Limit outbound requests
The outbound path is the harder one. Where libraries offer decompression as an option, such as decompress: false in axios and got, it can be turned off, and the application then has to handle compressed responses deliberately. The built-in fetch() has no such switch. What remains is the network layer. Services that do not need to fetch arbitrary URLs get an egress restriction that only allows their known upstreams, in Kubernetes as a NetworkPolicy with policyTypes: [Egress] and, for hostnames, via your CNI's FQDN rules. This reduces the servers that can deliver a stream to those you trust anyway. Services that must fetch arbitrary URLs go through a forward proxy that only allows HTTPS, because plain HTTP opens the path to anyone sitting between you and the target. For such services this is not a complete block, since an attacker can simply run their server over HTTPS. After a successful exploit, however, the egress restriction makes downloading tools and exfiltrating data harder.
Isolate decompression of untrusted content
For services that must process content from untrusted sources, isolation is the answer that holds up best. Link previews, feed imports or image optimisation can be moved into a separate process or pod that holds no database credentials or API tokens, runs without a ServiceAccount token via automountServiceAccountToken: false, and whose egress leads outwards but not into the internal network. A memory error in the decoder then hits a process with little to steal. That is my inference from the mechanism and not a Node.js recommendation, but the technical reasoning is simple: only a process boundary contains a bug in your own address space. Worker threads are not enough, because they share the same process. The effort lies in the architecture, since the separated service needs its own interface and monitoring.
Container hardening as damage limitation
The well-known securityContext settings do not prevent the memory error, nor code execution inside the Node process. They do decide what is possible afterwards. With runAsNonRoot: true, allowPrivilegeEscalation: false, capabilities.drop: ["ALL"] and seccomp in RuntimeDefault mode, an attacker remains an unprivileged process with no route to higher privileges, and readOnlyRootFilesystem: true makes it harder to drop tools or establish persistence. A minimal base image without a shell or package manager removes convenient helpers as well. The Pod Security Standard “restricted” bundles most of these values. When it comes to the actual damage, all of this has limits, because secrets the process legitimately holds in memory or environment variables can still be read by an attacker with code execution. How to enforce these values for whole namespaces is covered in my series TYPO3 on Kubernetes under IT-Grundschutz.
What does not help here
The Node.js permission model with --permission does not protect against this flaw. The docs state plainly that the feature does not protect against malicious code and that malicious code can bypass the model and execute arbitrary code. A memory error in native code lies outside the checks the model performs in the JavaScript APIs, and the same release closes two bypasses of the model anyway.
The node:zlib options do not help either. maxOutputLength limits the output size of the convenience methods and is useful against decompression bombs, but it is useless against a bug in the decoder state, because the faulty access does not depend on the output size. chunkSize only sets the size of the output buffer. WAF rules are a similar story: Vercel rolled out rules for inbound attacks but itself stresses that only the runtime update protects against fetched responses. A CVE gate in the pipeline also falls short as long as the CVE has no published record, because many scanners do not know it yet. And if you check your runtime via process.versions.brotli, you will see the same version number on patched and unpatched builds, because Node.js only took the single commit and did not bump the version.
Detection and verification
The first indicator of whether a runtime is patched is process.version, which should read 22.23.4, 24.22.1, 26.12.1 or later. For builds of unclear origin there is a more reliable test: the patched runtime knows the constant zlib.constants.BROTLI_DECODER_ERROR_FORMAT_BLOCK_SWITCH with the value -17, the unpatched one does not. For distribution builds with --shared-brotli, however, the constant says little about the library actually loaded. There, ldd on the Node binary shows whether libbrotlidec comes from the system, and then the version of the Brotli package is what counts. In Kubernetes, looking at image tags is not enough if pods have been running for a while; what matters is the runtime in the running container.
Whether someone has already attacked the flaw is harder to answer, because Node.js and Vercel publish no indicators. A plausible signal is Node processes crashing with memory errors, meaning segmentation faults or pod restarts with exit code 139, that coincide with fetches of third-party URLs or inbound requests with Content-Encoding: br. An error with the code ERR__ERROR_FORMAT_BLOCK_SWITCH after the update shows that someone sent such a stream, which the patched runtime now rejects. Both search patterns are my own inference and not vendor guidance.
If you suspect an attack, treat the affected process as compromised. Redeploy pods from a patched image and rotate all secrets the process held in memory or environment variables. After that, check the service's egress logs for connections to destinations it normally does not talk to.
Recommendation for operators
Act now if a Node service fetches URLs from user input, processes content from third-party servers with fetch() or a library with automatic decompression, or accepts compressed bodies via Express 5 and body-parser. Update to the fixed versions immediately, rebuild the images and restart the pods. Until the update is everywhere, reject inbound Content-Encoding: br at the proxy.
Monitoring is enough if your services only query their own upstreams over HTTPS and accept no compressed requests. The update still belongs in the next few days, if only because of the TLS flaws in the same release, and if you use distribution builds, keep an eye on the Brotli package as well.
Whatever your individual case, the advice I give for every vulnerability applies here too: patches and updates should be automated. With Node.js the runtime usually lives in the base image, and that is exactly where versions go stale unnoticed, because a pinned tag never moves on its own. If Renovate or Dependabot propose new image tags and package versions as merge requests and the pipeline tests them against staging, a security release like this one takes hours instead of weeks. A cooldown for routine updates protects you from compromised releases, and you exempt security updates from it. The changed error codes and the stricter TLS check in this release are exactly the kind of change that should show up in testing rather than in production. If you update by hand, you can easily spend weeks on a vulnerable runtime during the holiday season and then apply a bulk update where nobody knows which change triggers which error.
Frequently asked questions about CVE-2026-111497
I only use Brotli for responses to browsers. Am I affected?+
Not through that use, because compression towards the client does not touch the decoder. You are affected if you decompress Brotli data, meaning responses from third-party servers via fetch() or compressed request bodies via body-parser.
How do I check whether my runtime is patched?+
Check process.version and whether zlib.constants.BROTLI_DECODER_ERROR_FORMAT_BLOCK_SWITCH has the value -17. You cannot rely on process.versions.brotli, because the number stays the same. For distribution builds, ldd shows whether the system library is loaded.
Does the Node.js permission model protect me?+
No. The Node docs themselves say the permission model does not protect against malicious code and can be bypassed. A memory error in the native decoder lies outside its checks.
Is updating Node.js enough on its own?+
For official builds and Docker images, yes, once processes and pods run on the new runtime. If your distribution uses --shared-brotli, you also need a Brotli package with the fix. Rebuild startup snapshots and single executable applications with the patched runtime. Bun is a separate case and needs its own update.
Conclusion
CVE-2026-111497 is a classic bug: a decoder knew the rule of its format but reported a breach of it through a return value that a fast path ignored. For operators the lesson reaches beyond Brotli. Every parser for compressed or binary formats runs in your process with all the privileges and secrets that process has, and a convenience feature like automatic decompression in fetch() turns every third-party server into a supplier for that parser. It is worth reviewing your own services for where they decompress data from untrusted sources and placing exactly those spots in processes without secrets and with limited network access. The silent fix, public long before the CVE, also shows that comparing bundled dependencies with their upstream reveals more than a scanner waiting for CVE numbers.
Sources
- Node.js: October 2026 Security Releases
- google/brotli fix commit 0ed4268a
- google/brotli release v1.2.1
- nodejs/node commit 49dff6f (Brotli cherry-pick)
- nodejs/node commit b5f4152 (constant and regression test)
- Node.js release notes v24.22.1
- Node.js release notes v22.23.4
- Node.js release notes v26.12.1
- RFC 7932: Brotli Compressed Data Format
- Node.js docs: zlib
- Node.js docs: Permissions
- undici: fetch implementation
- expressjs/body-parser README
- Vercel: Node.js security advisory
- Atlasum: CVE-2026-111497
- Lilting: tests of the Node.js security release
- oven-sh/bun issue #46352
- Debian Security Tracker: brotli
- nginx docs: ngx_http_gunzip_module
- Kubernetes docs: Security Context
I review your Node services for where they decompress untrusted data, isolate those paths and automate the rollout of patched runtimes and images.
Audits of Node.js services that fetch third-party content, egress rules and isolation for image optimisation and link previews, Renovate setups for base images and runtimes.
Platform operations instead of advice on paper: I continuously review, patch and harden your infrastructure.
