Skip to content

fix(brotli): resume a part-received meta-block instead of re-decoding it - #154

Merged
MagicalTux merged 1 commit into
masterfrom
fix/brotli-resume-meta-block
Oct 24, 2026
Merged

MagicalTux merged 1 commit into
masterfrom
fix/brotli-resume-meta-block

Conversation

@MagicalTux

Copy link
Copy Markdown
Member

Hardening that came out of checking our exposure to CVE-2026-111497 ("Node2Shell", libbrotli). We are not affected by that CVE: our decoder is native Rust, has no unsafe, and already handles a block switch with a single block type. This PR fixes a separate denial-of-service problem found during that review.

Problem

If a compressed meta-block wasn't fully buffered, the decoder decoded it from its first command, hit the end of input, rolled back, and started over on every later decode call. Total work grew with the square of the meta-block size. Zero-length dictionary references let a hostile meta-block be any size compressed, so a crafted stream could keep the decoder busy almost indefinitely.

Measured on a 16 MB base64 file compressed with brotli -q11 (12 MB):

Feed size Before After
one call 0.07 s 0.09 s
64 KiB ~0.9 s 0.08 s
8 KiB 6.3 s 0.08 s
512 B — 0.08 s

Output is byte-identical to the original in every case.

Change

  • The meta-block's tables and command-loop position now live in the decoder between calls (MetaBlock, BlockCursor). The loop advances in units: an insert-and-copy symbol with its extra bits, one literal, or one distance. A unit that runs out of input is undone and retried once more bytes arrive.
  • Output from finished commands is handed out right away instead of waiting for the end of the meta-block.
  • A back-reference running past the meta-block is rejected before the copy, not after (it can be ~16 MiB).
  • One-shot decode throughput (examples/micro) is unchanged within noise.

Tests

  • New decoder_resumes_inside_a_meta_block: qualities 1/6/11, input fed in 1-, 7- and 1000-byte chunks, output must appear from half a stream, and truncation must still be reported. It fails on master.
  • cargo fmt --check, clippy --all-targets --all-features -D warnings and cargo test --all-features (1983 passed) all clean.

🤖 Generated with Claude Code

When a compressed meta-block was not fully buffered, the decoder parsed
and decoded it from its first command, hit the end of input, rolled
everything back and started over on the next call. Total work grew with
the square of the meta-block size: a 12 MB `brotli -q11` stream fed in
8 KiB pieces took 6.3 s instead of 0.07 s. Zero-length dictionary
references let a hostile meta-block take any number of bytes, so a
crafted stream could keep the decoder busy almost indefinitely.

The meta-block's tables and command-loop position now live in the
decoder between calls. The loop advances in units (an insert-and-copy
symbol with its extra bits, one literal, one distance), and a unit that
runs out of input is undone and retried once more bytes arrive. Output
from finished commands is handed out right away. Decode time is now
linear in the input whatever the chunk size, and one-shot decode speed
is unchanged.

Also reject a back-reference that runs past the meta-block before
copying it, not after (it can be ~16 MiB).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@MagicalTux
MagicalTux merged commit 9f28a86 into master Oct 24, 2026
50 checks passed
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