Skip to content

std.brotli: a decoder, capped, that refuses an unpatched libbrotlidec (CVE-2026-111497) - #3133

Merged
paul-hammant merged 2 commits into
mainfrom
feat/brotli-decoder
Oct 24, 2026
Merged

paul-hammant merged 2 commits into
mainfrom
feat/brotli-decoder

Conversation

@paul-hammant

Copy link
Copy Markdown
Collaborator

std.brotli was encode-only. This adds a decoder over the system libbrotlidec: one-shot (decompress, decompress_max) and streaming (decompress_stream_new / _write / _finish / _free), shaped like the encoder.

Two commits:

  1. Jules's decoder, squashed from four commits with the same title. It brings capped output (max_bytes, 0 = a 1 GiB ceiling), the link wiring (-lbrotlidec in codegen, ae, the Makefile and the optional-linking test) and a co-located spec.
  2. Review fixes, covering the brotli CVEs other implementations have shipped.

The CVE classes

Issue Where it hit Here
CVE-2026-111497 ("broccoli2shell" / Node2Shell, disclosed 2026-10-21): out-of-bounds write on a malformed block switch, RCE libbrotlidec 1.0.3–1.2.0; Node.js, Bun, Python brotli The decoder refuses to run on an unfixed libbrotlidec. See below.
CVE-2025-6176: decompression bomb with no output limit Python brotli < 1.2.0, Scrapy max_bytes on every decode. Tested with 8 MiB packed into a few hundred bytes, on both paths.
CVE-2025-66471: one small streaming read decodes a whole chunk urllib3 < 2.6.0 The stream cap counts every byte a write produces.
CVE-2025-66418: unbounded chain of Content-Encodings urllib3 < 2.6.0 Doesn't arise: std.http's client doesn't auto-decode.
CVE-2020-8927: overflow in a one-shot decode > 2 GiB libbrotli < 1.0.8 The one-shot path uses the streaming decoder, lengths are int, and the fix check rules out pre-1.0.8 libraries.
Large-window streams: the header can request a 1 GiB window, outside any cap non-RFC 7932 extension Left off. A test pins that such a stream is refused.

The fix check. The CVE-2026-111497 fix gave the reserved error code -17 a name (BLOCK_SWITCH) in the same commit. brotli_decoder_patched() asks the linked library to name -17. A version comparison would refuse distro backports that keep the old number; the probe accepts them:

libbrotlidec Version reported Probe Result
upstream v1.2.0 1.2.0 INVALID refused
upstream v1.2.1 1.2.1 _ERROR_FORMAT_BLOCK_SWITCH decodes
Debian 1.0.9-2+deb12u1 (this box) 1.0.9 BLOCK_SWITCH decodes

The v1.2.0 and v1.2.1 rows come from a C harness linking aether_brotli.c against static builds of those tags. On an unfixed system, every decode returns an error naming the CVE, decoder_available() returns 0, and encoding is unaffected. The spec has a branch for that case, so a CI runner with an unfixed library tests the refusal instead.

Fixed in Jules's code

  • Truncated streams were accepted. decompress_stream_finish returned success on a truncated stream, so a cut connection read as a shorter body. It now returns "truncated stream".
  • Trailing bytes on a stream were ignored. Bytes after the end of the stream are now an error, as they already were on the one-shot path.
  • Wrong static link order. The Makefile's dedupe kept the first of each repeated library: -lbrotlienc -lbrotlicommon -lbrotlidec. A static link (the MinGW case the comment is about) can't resolve that. It now keeps the last occurrence, giving -lbrotlienc -lbrotlidec -lbrotlicommon.
  • The README misdescribed broccoli2shell. It called it a decompression bomb. It's a memory-safety bug that the output cap can't stop.
  • Vague errors. Error messages now name their cause: output limit, truncated, corrupt or refused.

Tests

std/brotli/test_brotli.ae, 20 specs. The new ones:

  • a thousandfold bomb on both paths;
  • a 300 KB binary payload with NULs, decoded at its exact limit and refused one byte under it;
  • a stream fed one byte at a time;
  • truncation and trailing bytes on a stream;
  • an empty body;
  • a large-window stream (a fixture from the brotli 1.2.1 CLI).

Negative controls:

  • the truncation and trailing-byte specs fail against Jules's commit;
  • the large-window spec fails with BROTLI_DECODER_PARAM_LARGE_WINDOW turned on;
  • the fix check refuses upstream v1.2.0.

Valgrind is clean. fmt, check-docs, check-changelog and check-tests pass. brotli_streaming and optional_library_linking pass.

Build requirement: building with brotli now needs libbrotlidec beside libbrotlienc. Both ship in the same distro package (libbrotli-dev, brew brotli, mingw-w64-*-brotli).

🤖 Generated with Claude Code

google-labs-jules Bot and others added 2 commits October 24, 2026 17:46
Co-authored-by: paul-hammant <82182+paul-hammant@users.noreply.github.com>
…on, pin the CVE classes

The decoder from the previous commit, reviewed against the brotli CVEs other
implementations have shipped:

- CVE-2026-111497 (broccoli2shell): libbrotlidec 1.0.3-1.2.0 writes out of
  bounds on a malformed block switch. Decoding now refuses to run on an
  unfixed library. brotli_decoder_patched() asks the library to name error
  code -17, which the fix added in the same commit, so distro backports that
  keep the old version number (Debian 1.0.9-2+deb12u1) pass. Checked against
  upstream v1.2.0 (refused) and v1.2.1 (decodes). decoder_available() exports it.
- decompress_stream_finish accepted a truncated stream as success; it now
  returns "truncated stream". The one-shot path names truncation too.
- The streaming decoder ignored bytes after the end of the stream; they are
  now an error, as on the one-shot path.
- Errors name their cause (output limit, truncated, corrupt, refused).
- BROTLI_LDFLAGS kept the FIRST of each repeated library, putting
  -lbrotlicommon before -lbrotlidec, which a static link cannot resolve; it
  keeps the last now.
- The README called broccoli2shell a decompression bomb; it is a memory-safety
  bug the output cap cannot stop. The README now has a table of which CVE
  class each defence answers.

Tests added: a thousandfold bomb (8 MiB in a few hundred bytes) on both paths,
a 300 KB binary payload with NULs at and one under its exact limit, a stream
fed one byte at a time, truncation and trailing bytes on the stream, an empty
body, and a large-window stream (fixture from the brotli 1.2.1 CLI). The
truncation and trailing-byte tests failed against the previous commit; the
large-window test fails with large windows enabled.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@paul-hammant
paul-hammant merged commit 52f27e9 into main Oct 24, 2026
43 checks passed
@paul-hammant
paul-hammant deleted the feat/brotli-decoder branch October 24, 2026 17:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant