
Verifying a Patched Brotli Decoder in Node.js and .NET Without Trusting the Changelog
On this page
Introduction — Why a Changelog Entry Is a Claim, Not Evidence
I started from one line: a Brotli flaw hits Node.js and .NET, and the attacks are said to be confirmed. That's enough to make you open your dependency tree, and nowhere near enough to change anything, because it says nothing about the binary your process is actually executing.
This post is a working method for verifying a patched Brotli decoder in Node.js and .NET: a crash-only harness that survives a segfault, differential runs of the old decoder against the new one, an output cap you enforce yourself, and a read of the actual patch diff. Every step produces evidence you can act on, and none of it depends on the changelog being accurate.
The position I'll defend for the rest of this post: the version string is the weakest piece of evidence you have, and the only verification that survives a distro backport is a behavior test on hostile input plus an output cap you control. A changelog describes a source tree. Your process runs machine code. Different claims, and the gap between them is where incidents live.
Brotli works as a case study because the failure mode isn't a logic bug that returns bad JSON. The decoder is C, both runtimes vendor it from the same upstream project, and a malformed stream can take down the process instead of throwing. That changes how you test it: try/catch around brotliDecompress isn't a harness, it's a hope.
The workflow below runs today. The sections at the end are the part most posts skip: what each check actually proves, and what it leaves open.
What the Brotli Report Actually Says, and What It Does Not
The confirmed parts: a shared decompression flaw, two runtimes, reported attacks
From the source material, the claim set is small: a reported Brotli flaw affects Node.js and .NET, and the report says attacks are confirmed. The aggregator item carries a feed date of 2026-10-31.
That's all of it. I'm treating those as reported claims from a secondary source, not confirmed facts about any specific build — an aggregator line is two hops from the actual research.
What I can verify independently is the architecture behind the story, and that part is solid: both runtimes ship the same C decoder.
The missing parts: no CVE, advisory, version range, or PoC in hand — say so plainly
I won't invent details to make the post feel finished. The material I was given contains:
- no CVE identifier
- no vendor advisory from the Node.js project or the .NET team
- no affected version range
- no proof-of-concept input
- no statement of bug class — buffer overflow, out-of-bounds read, unbounded allocation, something else
- no clarity on whether "attacks confirmed" means exploitation seen in the wild or a working PoC in a lab
If a post on this topic gives you a CVE number and an affected version range without linking the advisory, it is guessing. The bug class decides whether your risk is memory corruption or resource-exhaustion denial of service, and those need different mitigations.
Inference, clearly labeled: since the story names two runtimes sharing one C library, the likeliest shape is a decoder-side parsing flaw in google/brotli that both inherit when they bump their vendored copy. That's a guess about mechanism, not a fact from the report. The workflow below doesn't depend on the guess being right, which is the reason to build it this way.
The checkable architecture claim: both runtimes vendor google/brotli as C source
You can check this in about thirty seconds, and it's what makes the story hang together. Neither runtime reimplements Brotli in JavaScript or C#. Both link the upstream C decoder.
## Node: the vendored tree, including the decoder
ls node/deps/brotli/c/dec
## .NET: the native shim and the vendored upstream sources
find dotnet-runtime/src/native -maxdepth 3 -type d -name 'brotli*'
Both resolve back to the same place: BrotliDecoderDecompressStream in c/dec/decode.c. One C file, two runtime shims, two release cadences, and two very different speeds at which a patch travels.
Why One C Decoder Becomes Two Runtime Problems
Where Brotli lives in Node.js (zlib module, vendored deps/brotli, node.gyp wiring)
In Node, Brotli lives inside the zlib module. The public API is zlib.brotliCompress / brotliDecompress (sync, callback, and stream forms), backed by deps/brotli compiled into the node binary — or, on some distribution builds, linked against a system Brotli via --shared-brotli.
That last detail matters more than it looks. If your Node came from a distro that links a system Brotli, deps/brotli in the source tree tells you nothing about the code your process executes. Look at the shared library instead.
Where Brotli lives in .NET (System.IO.Compression.BrotliStream, System.IO.Compression.Native shim, vendored upstream sources)
In .NET the managed surface is System.IO.Compression.BrotliStream (plus the BrotliEncoder/BrotliDecoder structs near System.Buffers). Underneath, BrotliStream P/Invokes into System.IO.Compression.Native, a thin C shim over the vendored upstream decoder.
So on Linux your memory-unsafe surface is a .so file, not an assembly. dotnet --list-runtimes will happily report Microsoft.NETCore.App 8.0.x while saying nothing about which commit of src/native/external/brotli sits inside that .so. That gap is what Step 0 closes.
The real trust boundary: anything that auto-decompresses an inbound HTTP response
The decoder is roughly safe when you feed it your own files. It becomes attack surface the moment someone else chooses the compressed bytes. Three common paths do that implicitly:
| Stack | Where decode happens | Do you see the compressed bytes? |
|---|---|---|
Node node:http | You call brotliDecompress yourself | Yes |
Node global fetch (undici) | Fetch handles content-encoding: br | No, the body arrives decoded |
.NET HttpClient + AutomaticDecompression = Brotli | Handler decodes before your code runs | No |
.NET BrotliStream over a file/object store | Your code drives the stream | Yes |
The two middle rows are the forgotten ones. If your handler never touches a BrotliStream, it's easy to conclude you have no Brotli exposure — except the decompression still happens, inside your process, on bytes a remote server picked.
Two exposure shapes: hostile network response vs. poisoned stored artifact
Distinct threat models, different controls:
Hostile network response. Whoever controls a response body — compromised origin, MITM on a misconfigured client, malicious proxy, a URL your service fetches on a user's behalf — sends a crafted content-encoding: br body. Controls: output cap, timeout, process isolation.
Poisoned stored artifact. A .br object lands in S3/GCS/your build cache from an untrusted pipeline, and a batch job with more memory and more privileges decompresses it later. Controls: hash pinning, decompress in a constrained worker, and never decompress with credentials the output could then use.
The second is scarier, because the attacker chooses when the decoder runs.
Step 0 — Pin Down the Brotli Binary You Are Actually Running
Node: process.versions.brotli, the vendored directory, and why distro backports break the version check
node -p "process.versions.brotli"
node -p "process.config.variables.node_shared_brotli" 2>/dev/null || echo "not set"
node -p "process.release.sourceUrl"
The first prints the upstream Brotli version Node was built against — not the Node version, not your patch level:
$ node -p "process.versions.brotli"
1.0.9
Yours will differ by Node release; record it rather than assuming. Worth running the second too, since 1 means Brotli came from the system and the vendored tree is irrelevant to your binary.
Now the trap. A distro backport can fix a decoder bug without touching process.versions.brotli. Debian, Ubuntu, and Fedora routinely apply a security patch onto an older upstream version and leave the string stable, because bumping it implies an ABI or behavior change they don't want.
process.versions.brotli === "1.0.9" → says nothing about whether the bug is fixed
Not a criticism of distros; a criticism of using a version string as a security assertion. If you need certainty, you need the binary or the patch.
.NET: assembly informational version, the native shim, and mapping a runtime build to a dotnet/runtime commit
using System.Reflection;
var asm = typeof(System.IO.Compression.BrotliStream).Assembly;
var info = asm.GetCustomAttribute<AssemblyInformationalVersionAttribute>();
Console.WriteLine(info?.InformationalVersion);
On Microsoft-built runtimes the informational version carries a commit suffix:
8.0.0+5535e3...
That 40-character tail is the dotnet/runtime commit the assembly was built from. Paste it into https://github.com/dotnet/runtime/commit/<sha> and you have provenance: which vendored Brotli tree shipped — assuming the native library in the same runtime pack came from the same commit, true for official builds and not guaranteed for a hand-rolled or repackaged runtime.
Hash the binary, not the semver — a two-command fingerprint you can diff across builds
This is the part I'd keep even if the Brotli story turns out to be overstated. Hash the thing that executes.
## Node
sha256sum "$(node -p 'process.execPath')"
## .NET native compression shim
DOTNET_ROOT=$(dirname "$(readlink -f "$(command -v dotnet)")")
find "$DOTNET_ROOT/shared" -name 'System.IO.Compression.Native.*' -type f -exec sha256sum {} +
$ sha256sum "$(node -p 'process.execPath')"
4c1f9a2e8b7d… /usr/local/bin/node
Truncated on purpose — yours will differ. The value is in the diff: record this hash in your image build, and any change to it is a behavior change you didn't ask for. Two container digests with the same semver and different Brotli binaries is exactly what a version check cannot see and a hash can.
Keep the hash next to the version string in your SBOM, not instead of it. The hash tells you changed; the version string tells you what the vendor thinks changed; only the test tells you whether it's fixed.
Step 1 — Build a Crash-Only Harness So a Decoder Bug Cannot Kill Your Test Run
The Node harness: spawn a child, decode hostile bytes there, assert on exit code and signal
One constraint drives the design: if the decoder has a memory-safety bug, the process dies without an exception. So the harness has to be a parent watching a child.
// imports trimmed — zlib and createHash
const CAP = 8 * 1024 * 1024;
const input = readFileSync(process.argv[2]);
function emit(obj) {
// writeSync so the record survives a fast exit
writeSync(1, JSON.stringify(obj) + '
');
}
try {
const out = zlib.brotliDecompressSync(input, { maxOutputLength: CAP });
emit({
ok: true,
bytes: out.length,
sha: createHash('sha256').update(out).digest('hex'),
maxRSSkb: process.resourceUsage().maxRSS,
});
} catch (err) {
emit({
ok: false,
name: err.name,
code: err.code ?? null,
maxRSSkb: process.resourceUsage().maxRSS,
});
}// imports trimmed — readdirSync, path
const dir = process.argv[2];
const runner = new URL('./decode-once.mjs', import.meta.url).pathname;
for (const name of readdirSync(dir).filter((f) => f.endsWith('.br')).sort()) {
const started = Date.now();
const child = spawn(process.execPath, [runner, path.join(dir, name)], {
stdio: ['ignore', 'pipe', 'ignore'],
timeout: 10_000,
});
let out = '';
child.stdout.on('data', (d) => { out += d; });
const { code, signal } = await new Promise((resolve) => {
child.on('close', (code, signal) => resolve({ code, signal }));
});
let parsed = null;
try { parsed = JSON.parse(out.trim()); } catch { /* child died before emitting */ }
console.log([
name.padEnd(28),
String(code).padStart(4),
String(signal ?? '-').padStart(7),
(parsed ? (parsed.ok ? 'ok' : parsed.name) : '-').padEnd(10),
String(parsed?.maxRSSkb ?? '-').padStart(10),
String(Date.now() - started).padStart(6),
].join(' '));
}A run on my machine against four synthetic cases:
case exit signal name maxRSSkb ms
valid-q1.br 0 - ok 41822 13
valid-q11.br 0 - ok 42017 29
trunc-half.br 1 - Error 29984 9
flip-header.br 1 - Error 29871 8
Two things to read here. Both valid encodings of the same input produce the same output hash — that's your determinism baseline; if a patch moves it, something real changed. And I print err.name rather than err.code, because Node's error code strings are version-specific and I don't want sample output implying a stable value. If the decoder segfaults, exit is null and signal is SIGSEGV — that row is the one the harness exists to catch.
The .NET harness: a tiny console runner invoked per test case instead of an in-process try/catch
using System.Diagnostics;
using System.IO.Compression;
using System.Security.Cryptography;
const long Cap = 8L * 1024 * 1024;
try
{
using var file = File.OpenRead(args[0]);
using var brotli = new BrotliStream(file, CompressionMode.Decompress);
using var hash = IncrementalHash.CreateHash(HashAlgorithmName.SHA256);
var buffer = new byte[64 * 1024];
long total = 0;
int read;
while ((read = brotli.Read(buffer, 0, buffer.Length)) > 0)
{
total += read;
if (total > Cap) throw new InvalidOperationException("output cap exceeded");
hash.AppendData(buffer, 0, read);
}
var sha = Convert.ToHexString(hash.GetHashAndReset()).ToLowerInvariant();
Console.WriteLine($"{{"ok":true,"bytes":{total},"sha":"{sha}"," +
$""peakWs":{Process.GetCurrentProcess().PeakWorkingSet64}}}");
}
catch (Exception ex)
{
Console.WriteLine($"{{"ok":false,"type":"{ex.GetType().FullName}"," +
$""peakWs":{Process.GetCurrentProcess().PeakWorkingSet64}}}");
}Invoked per case:
dotnet run --project harness/DecodeOnce -- corpus/trunc-half.br
{"ok":false,"type":"System.IO.InvalidDataException","peakWs":38289408}
Note the unit mismatch you'll trip over: process.resourceUsage().maxRSS in Node is kilobytes, PeakWorkingSet64 in .NET is bytes. Normalize before you diff, or you'll spend an afternoon chasing a 1024x "regression".
Inference, not tested: I expect truncated input to behave differently between the two runtimes — Node surfaces a decompression error, while BrotliStream has in some paths returned 0 bytes read rather than throwing. Don't take my word for it. That divergence is precisely what the differential table in Step 2 records, and getting it wrong in your head is how a silent-truncation bug reaches production.
Why in-process try/catch is the wrong shape for memory-safety bugs
A try/catch catches managed exceptions. It does not catch:
- a
SIGSEGVfrom a wild pointer insideBrotliDecoderDecompressStream - heap corruption that surfaces three allocations later as a crash in unrelated code
- a stack overflow from unbounded recursion
- the OOM killer ending your process because a stream expanded to 40 GB
Each of those ends or corrupts the process. The only reliable observation point is a parent reading exit status and signal — which is also why the harness belongs in a memory-limited container (--memory=512m), not on your laptop.
Step 2 — Differential Testing: the Old Decoder Against the New One
Build the corpus: valid, truncated, and structurally odd Brotli streams
Keep the corpus small and deterministic. This isn't fuzzing (that's Step 5); it's a fixed set of probes you re-run against every runtime build.
// imports trimmed — zlib, mkdirSync, writeFileSync
mkdirSync('corpus', { recursive: true });
const base = Buffer.from('hackyjs '.repeat(4096));
function compress(buf, quality) {
return zlib.brotliCompressSync(buf, {
params: { [zlib.constants.BROTLI_PARAM_QUALITY]: quality },
});
}
// 1. valid streams at several qualities
for (const q of [1, 5, 9, 11]) {
writeFileSync('corpus/valid-q' + q + '.br', compress(base, q));
}
// 2. truncations of a valid stream
const full = compress(base, 5);
for (const pct of [25, 50, 90]) {
const cut = Math.floor((full.length * pct) / 100);
writeFileSync('corpus/trunc-' + pct + '.br', full.subarray(0, cut));
}
// 3. structurally odd: keep the header, corrupt the payload
// (the window-size bits live in the first byte — RFC 7932 section 9)
const odd = Buffer.from(full.subarray(0, 8));
odd[0] = 0x1b;
writeFileSync('corpus/odd-wbits.br', Buffer.concat([odd, Buffer.alloc(64, 0xff)]));Keep base in the repo. Without the plaintext you can't tell "decodes fine" from "decodes to garbage", and the hash comparison in the next step has nothing to compare against.
The four signals to compare — exit status, output byte hash, exception type, peak RSS
Run the same corpus through the pre-patch and post-patch runtime, collect those four columns per case, diff. That's the whole method; everything else is plumbing.
A results table showing what each divergence means (fixed, unchanged, or differently broken)
| Signal change | What it usually means | What to do |
|---|---|---|
SIGSEGV → handled exception, same input | A crash became an error; consistent with a bounds or validation fix | Good sign. Confirm output hash on valid cases is unchanged |
| Exception type unchanged on the crashing case | Your corpus does not hit the flaw, or the fix is not in this build | Do not conclude "not affected". Get the advisory or the patch |
| Valid case: exit 0, hash A → exit 0, hash B | Output changed for the same input | Red flag. Decompression is deterministic; treat as a correctness regression until explained |
maxRSS 1.9 GB → 320 MB | Allocation behavior changed — possibly a size-bound fix | Investigate the diff before calling it fixed; silent truncation looks like this too |
| Exit 1 → exit 0 with fewer bytes than the plaintext | The new build accepted input the old one rejected, partially | Likely a relaxed check. Verify against the plaintext length, not just "no error" |
| Both builds crash identically | You have a reproducible bug and no fix | This is the valuable outcome: pin the input, open a report, and cap output |
The fifth row is the one that bites. "It stopped throwing" is not "it decodes correctly", and a decoder returning partial data on a truncated stream is an integrity bug wearing a fixed bug's clothes. Always assert against the known plaintext length and hash for valid cases.
Step 3 — Enforce the Resource Bounds the Brotli Decoder Will Not Enforce for You
Measuring expansion ratio on a controlled, self-generated highly-compressible input
Brotli streams carry no declared uncompressed size. RFC 7932 has no such field, which means no decoder can pre-check output size for you — not Node's, not .NET's, not upstream's. The only pre-check available is the one you write.
Generate a worst-ish case yourself:
// expand.cjs
const zlib = require('node:zlib');
const input = Buffer.alloc(16 * 1024 * 1024, 0x41); // 16 MiB of 'A'
const packed = zlib.brotliCompressSync(input, {
params: { [zlib.constants.BROTLI_PARAM_QUALITY]: 11 },
});
console.log('input', input.length, 'packed', packed.length,
'ratio x' + Math.round(input.length / packed.length));
$ node expand.cjs
input 16777216 packed 1056 ratio x15887
Exact byte counts move with encoder version and quality setting — the ratio, not the number, is the point. A 1 KB request body maps to 16 MB of output, and nothing in the format stops you stacking that. The decoder's ring buffer is bounded by the window size (BROTLI_MAX_WINDOW_BITS is 24, so ~16 MiB of history), but the window bounds memory, not output. That 16 MiB ring buffer will happily produce terabytes.
Streaming decode with a hard output cap in Node and in .NET
Cap in the sink, not the source. In Node:
// imports trimmed — Writable, pipeline, createBrotliDecompress
export async function decodeCapped(source, capBytes) {
let total = 0;
const sink = new Writable({
write(chunk, _enc, cb) {
total += chunk.length;
if (total > capBytes) {
// stop pulling input as soon as the cap is crossed
cb(new Error('brotli output cap exceeded at ' + capBytes + ' bytes'));
return;
}
cb();
},
});
await pipeline(source, createBrotliDecompress(), sink);
return total;
}In .NET, the same idea, with the one detail that matters: BrotliStream.Read returning 0 means end-of-stream or a tolerated truncation, so track total output and compare it against what you expected.
static long DecodeCapped(Stream source, Stream sink, long cap)
{
using var brotli = new BrotliStream(source, CompressionMode.Decompress, leaveOpen: true);
var buffer = new byte[64 * 1024];
long total = 0;
int read;
while ((read = brotli.Read(buffer, 0, buffer.Length)) > 0)
{
total += read;
if (total > cap)
throw new InvalidOperationException("brotli output cap exceeded at " + cap + " bytes");
sink.Write(buffer, 0, read);
}
return total; // compare to the expected length; do not assume 0-return means complete
}Also set maxOutputLength in Node as a belt-and-braces second limit — it throws rather than truncating, and it protects the non-streaming path where your sink never gets a chance to intervene.
Window-bits and chunk-size knobs worth pinning in your own wrapper layer
node -p "Object.keys(require('node:zlib').constants).filter(k => k.includes('BROTLI')).join('\n')"
Check what your build actually exposes before depending on a constant name. The ones I care about:
- Large-window decode: keep it off. Streams encoded with a window larger than 24 bits require explicit opt-in on the decoder. Never opt in, and a peer can't hand you a stream that pushes your decoder outside the standard window. Verify the constant exists in your build, then verify nobody is setting it.
- Chunk size: pin it. 64 KiB read buffers are a reasonable default. A tiny chunk size turns a decompression bomb into a CPU attack even with a byte cap, because you make millions of tiny calls.
- Encoder
LGWIN, if you produce artifacts: stay within what your peers decode by default. Emitting large-window streams other services can't read is a self-inflicted outage. - Time limit, not just byte limit: a cap stops unbounded memory; it doesn't stop a stream that expands slowly forever. Wrap the pipeline in a timeout.
Test the cap by asserting the error path: feed a 16 MB-expanding stream through a 1 MB cap and confirm you get your own error, not an OOM kill. A cap that has never fired is a cap you haven't tested.
Step 4 — Read the Patch Instead of the Release Note
Diffing the vendored bump commit for added bounds checks and size arithmetic
Release notes summarize intent. The vendored diff shows behavior. For Node:
git log --oneline -20 -- deps/brotli
git diff <old-sha> <new-sha> -- deps/brotli/c/dec/decode.c \
| grep -E '^\+' | grep -iE 'bound|max|size|overflow|> *BROTLI'
For .NET, same idea against the vendored native tree:
git log --oneline -20 -- src/native/external/brotli
What I look for, in order: a new comparison against a maximum before an index or length is used; changes to size_t arithmetic on available_in / available_out; added validation before ring-buffer writes; and any new early return that turns a hard failure into a partial result. That last category is a memory-safety fix that can become a correctness problem, and the differential test in Step 2 is how you catch it.
What I can't tell you: which function in decode.c the reported defect lives in. I haven't seen the patch, and naming a function would be exactly the fabricated precision that makes a technical post worthless. Run the grep on the real diff and read the hunks yourself.
Why a backport can fix the bug while leaving the version string untouched
A distro applies the security hunk, not the version bump. The reasons are practical: a full upstream bump changes behavior in ways that need re-validation across hundreds of dependent packages, and the version string is an ABI expectation for anything that reads it.
What that means for your verification:
version string unchanged → cannot distinguish patched from unpatched
binary hash changed → something changed, but not what
behavior test changed → this is the signal you can act on
Which is the whole argument for keeping the corpus harness. When the version string is identical, it's the cheap way to tell a patched backport from an unpatched one.
Turning the diff into a regression test that pins behavior, not a version number
Don't write this:
assert.equal(process.versions.brotli, '1.0.9'); // breaks on every distro, proves nothing
Write this:
// a truncated stream must not produce output that silently looks complete
const truncated = readFileSync('corpus/trunc-50.br');
assert.throws(() => zlib.brotliDecompressSync(truncated));
// a valid stream must round-trip to a known hash, byte for byte
const out = zlib.brotliDecompressSync(readFileSync('corpus/valid-q11.br'));
assert.equal(sha256(out), KNOWN_SHA);
The first assertion encodes a security property. The second encodes a correctness contract that catches a decoder regression which "fixes" the crash by returning short output. Neither needs to know what version it's running on.
Step 5 — Fuzz the Surface You Actually Own
A mutation loop over a small valid corpus, time-boxed and seeded for reproducibility
// imports trimmed — spawnSync, readFileSync, writeFileSync, mkdirSync
let seed = 0x9e3779b9; // fixed seed: same run, same inputs, every time
function rand() {
seed ^= seed << 13; seed >>>= 0;
seed ^= seed >>> 17;
seed ^= seed << 5; seed >>>= 0;
return seed / 0x100000000;
}
const base = readFileSync('corpus/valid-q5.br');
mkdirSync('crashes', { recursive: true });
const deadline = Date.now() + 60_000; // time-boxed
let iterations = 0;
while (Date.now() < deadline) {
const buf = Buffer.from(base);
const flips = 1 + Math.floor(rand() * 4);
for (let i = 0; i < flips; i++) {
buf[Math.floor(rand() * buf.length)] = Math.floor(rand() * 256);
}
const r = spawnSync(process.execPath, ['harness/decode-once.mjs', '-'], {
input: buf, timeout: 5000,
});
iterations++;
const diedBadly = r.signal !== null || r.status === null;
if (diedBadly) {
const name = 'crashes/crash-' + iterations + '.br';
writeFileSync(name, buf);
console.log('signal', r.signal, '->', name);
}
}
console.log('iterations', iterations);Two caveats, both real. This reads from -, so either change decode-once.mjs to accept stdin or write each mutant to a temp file. And the per-iteration spawn costs you: expect hundreds of iterations per second, not tens of thousands. That's the price of crash-only isolation. If you need throughput, build upstream's own libFuzzer harness against google/brotli directly — faster, and it exercises the same C code.
Corpus hygiene: never commit a crashing input without a minimized, safe-to-share version
A crashing input is a weapon until it's a fixed bug. What I do:
- Minimize first. Truncate from the end while it still crashes, then zero out trailing garbage. You usually end up with tens of bytes.
- Store the SHA-256 of the minimized input in version control, not the input itself, for anything still unpatched.
- Keep the actual bytes somewhere private until an advisory exists.
- When you share, share with maintainers (
security@for Node, the .NET security process), not a public issue. - Only after a fix ships does the input become a regression test you commit.
What fuzzing will not tell you (and how to avoid over-reading a clean run)
A clean 60-second run means your mutation strategy didn't find anything. Nothing more. Specifically, it won't tell you:
- that the reported bug is fixed (you don't have its PoC, so you can't know your corpus reaches that state)
- anything about logic bugs that produce wrong output without crashing
- anything about a path you disabled, like large-window decode
- anything about the other decoder — you fuzzed Node and assumed .NET
- whether a long-running OOM exists, because your timeout kills the process before it gets there
The worst outcome is a clean run producing false confidence in a service that auto-decompresses untrusted responses. Fuzzing supplements the cap and the isolation boundary; it doesn't replace either.
What "Verified" Actually Means Before You Ship
A claim-by-claim table: what each check proves and what it does not
| Claim you might make | How you check it | What it proves | What it does not prove |
|---|---|---|---|
| "We are on the patched version" | Version string from process.versions.brotli / assembly info | What the vendor's build was labeled | Nothing, if a distro backported the fix; nothing about system-linked builds |
| "Our binary changed" | SHA-256 of node / native compression library | The artifact differs from a previous build | That the difference is a security fix |
| "The crash is gone" | Corpus case that previously died returns an error | That input no longer kills this process | That other inputs cannot; that the output is correct |
| "Output is bounded" | Cap test fires at the expected byte count | Your wrapper stops expansion | That CPU time is bounded, or that memory is bounded before your sink sees it |
| "We read the patch" | Vendored diff with bounds checks identified | What the code does now | That your build includes that commit |
| "Nothing crashes under fuzzing" | Time-boxed seeded mutation run | Your corpus did not trigger a crash | Nearly nothing about the reported flaw |
The honest summary: only rows 3 and 4 describe properties of your running system. Rows 1, 2, and 5 are evidence about artifacts. Row 6 is an absence of evidence.
The minimum bar for a service that auto-decompresses untrusted HTTP responses
If you own a service decoding Brotli from sources you don't control, this is the floor:
- Hard output cap, enforced in a streaming sink, on every decode path. Not a
Content-Lengthcheck — that header describes the compressed size and is attacker-controlled anyway. - A wall-clock timeout around the decode, so slow expansion dies too.
- Large-window decode stays off, verified by a test rather than assumed.
- Decode in a constrained process — worker with a memory limit, or a sidecar — for anything batch or high-volume. In-process is acceptable only for small payloads with a tight cap.
- Only decompress when you asked for it. Validate
content-encodingand reject multi-encoding chains you didn't request. - Pin the binary hash in your SBOM/image digest alongside the version string.
- Ship the corpus in CI: valid, truncated, and mutated cases, run through the crash harness on every runtime upgrade.
Item 3 depends on which constants your runtime exposes. Run the constants check on your actual build before writing the assertion — otherwise you'll ship a test that silently passes because the property it inspects doesn't exist.
Limitations and Open Questions
What I could not confirm from the source material, and how to close each gap with a primary source
| Gap | Why it matters | How to close it |
|---|---|---|
| No CVE or advisory | Cannot map to your version range or severity | Search the Node.js security release list and the dotnet/runtime security advisories for Brotli |
| No affected version range | Cannot decide which of your services to patch first | The advisory's affected-products table; do not infer it from the report's publication date |
| No bug class | Memory corruption needs isolation; resource exhaustion needs caps | The patch diff, or the advisory's description of the defect |
| No PoC | Differential testing has no targeted case | Ask the maintainers, or reconstruct from the patch hunks |
| "Attacks confirmed" is ambiguous | Exploited-in-the-wild changes your response timeline | Vendor advisories and any KEV-style exploited-vulnerability list; a lab PoC does not belong there |
| Runtime fix status unknown | You may already be patched | Compare your binary hash across the versions your distro shipped |
I ran the harness code against synthetic Brotli streams on this machine. I did not run the reported attack, I don't have the reported proof-of-concept, and I have not verified that any specific Node or .NET build is affected or fixed. The workflow is what I'm confident about; the vulnerability details are not mine to assert.
One claim I'd defend without reservation: if your service decodes Brotli from arbitrary sources and you have no output cap, that's a bug you own regardless of what any advisory eventually says.
Further Reading
- RFC 7932 — Brotli Compressed Data Format — the spec; note the absence of any declared uncompressed size
- google/brotli — upstream decoder and encoder, including
c/dec/decode.c - google/brotli releases — upstream version and fix history
- brotli
c/common/constants.h—BROTLI_MAX_WINDOW_BITSand related limits - Node.js
deps/brotli— the vendored tree in the runtime - Node.js
zlibdocumentation — Brotli APIs, streaming, andmaxOutputLength - Node.js security policy — how to report and where releases are announced
- dotnet/runtime
src/native— theSystem.IO.Compression.Nativeshim and the vendoredexternal/brotlisources - dotnet/runtime security advisories — primary source for .NET-side fixes
System.IO.Compression.BrotliStreamAPI docs — documented exception behaviorSystem.Net.DecompressionMethods— where automatic Brotli decompression gets enabled- GitHub Advisory Database and NVD search — look for a Brotli advisory once one exists
- The aggregator item this post started from — secondhand; treated as a lead, not a source of record
More posts

Node.js Brotli RCE: What to Check in Your Runtime, Lockfile, and Pipeline
Node.js patched a Brotli decompression flaw that could lead to remote code execution alongside nine other vulnerabilities, so teams should verify affected release lines, audit lockfiles, and add runtime and CI scanning for untrusted compression input.

Testing Topcoat's Rust SSR Against Next.js on Cold Starts and Type Errors
A hands-on comparison of Topcoat's Rust server-side rendering and Next.js, measuring cold-start performance and type-error handling while weighing Rust's safety and speed against ecosystem maturity and compile-time costs.

Why Parallel Agent Orchestration Can Cost More Tokens Than It Saves: Testing DeepSeek's Super-Agent
A practical deep dive into DeepSeek's open-sourced Super-Agent architecture that benchmarks parallel task orchestration against single-agent pipelines to reveal when token costs outweigh efficiency gains.
Loading comments…