Skip to content

Bun.serve(http3): lift the 64 KB QPACK prepare_decode cap so a large request header does not abort the connection - #35763

Open
robobun wants to merge 5 commits into
mainfrom
farm/e4717ad0/serve-http3-large-header
Open

robobun wants to merge 5 commits into
mainfrom
farm/e4717ad0/serve-http3-large-header

Conversation

@robobun

@robobun robobun commented Jul 25, 2026 •

Copy link
Copy Markdown
Collaborator

What

A single request header whose decoded value is about 59 KB (about 44 KB of encoded QPACK) aborts the entire Bun.serve({http3: true}) connection with H3_QPACK_DECOMPRESSION_FAILED (app error 0x200), taking every other in-flight stream on the session down with it.

error: HTTP3StreamReset fetching "https://127.0.0.1:.../headers"
  code: "HTTP3StreamReset"

Cause

us_quic_hsi_prepare (packages/bun-usockets/src/quic.c, the server-side QPACK prepare_decode hook) rejected any per-header decode request above 64 * 1024 bytes by returning NULL. lsquic maps that to LQRHS_ERROR and calls ci_abort_error(HEC_QPACK_DECOMPRESSION_FAILED), which is connection-fatal.

With a Huffman-encoded value lsqpack reserves about 1.5x the encoded length, so the practical ceiling was about 65536 / 1.5 ≈ 43.7 KB of encoded bytes. A 60 KB value of 'A' (6 bits/char Huffman) encodes to about 45 KB and requests about 67.5 KB of decode buffer, tripping the cap.

es_max_header_list_size = 16 * 1024 (set on the server engine) is advisory only: lsquic writes it into the SETTINGS frame but never enforces it on decode, so it was not the guard the surrounding comment implied.

Fix

  • Build lshpack/lsqpack with -DLSXPACK_MAX_STRLEN=UINT32_MAX so lsxpack_strlen_t is 32-bit in every TU that includes lsxpack_header.h (lshpack, lsqpack, lsquic, quic.c, node_quic_shim.c, c-bindings.cpp, and the h3blast dev harness). The flags.ts and packages/h3blast/Makefile hunks are byte-identical to node:quic(h3): build lshpack/lsqpack with 32-bit header lengths so large request headers don't abort the connection #35741 (the node:quic side of the same root cause), so whichever lands first the other resolves cleanly.
  • Replace the per-request 64 KB space cap in us_quic_hsi_prepare with a 128 KB cap on the whole decoded header buffer (h->len + space). 128 KB admits a single header value up to about 85 KB (the 60 KB repro fits with room to spare) and bounds the per-stream buffer at 128 KB regardless of how many headers the peer sends. The LSXPACK_MAX_STRLEN check alongside it keeps the cast to lsxpack_strlen_t sound.
  • Apply the same bound to the sibling nq_hsi_prepare_decode in node_quic_shim.c, since the build-flag change would otherwise turn its sole guard (space > LSXPACK_MAX_STRLEN) into a 4 GB no-op. The cap there is max(2 × maxHeaderLength, 128 KB): users who opt into a larger maxHeaderLength get proportionally more decode headroom, and the 128 KB floor means a small or default limit never lowers the abort threshold below where it was (a header over maxHeaderLength is still silently dropped in process_header, which keeps the session alive).
  • Correct the comment above es_max_header_list_size to say it is advisory and point at the actual guard.

Why this cap, not LSXPACK_MAX_STRLEN

lsqpack requests the decode buffer from the peer-declared value-length varint before a single value byte has arrived (DATA_STATE_READ_VAL_LEN → guarantee_out_bytes), and that varint is 24-bit. Without a hard cap a dozen wire bytes reserve about 32 MB per stream, and with es_init_max_streams_bidi = 100 one connection can pin about 3 GB of server heap for about 1 KB on the wire. The 128 KB cap keeps the Bun.serve worst case at about 12.8 MB per connection.

Exceeding the cap is still connection-fatal (same as before, at a higher threshold). Making it a per-stream reset per RFC 9114 §4.2.2 needs an lsquic patch: NULL from hsi_prepare_decode and any nonzero from hsi_process_header both end up in ci_abort_error via qdh_header_read_results / qdh_hsi_process_wrapper / lsquic_stream.c's HQFI_FLAG_ERROR path. That and wiring a per-server maxHeaderSize through to the Bun.serve hook are tracked alongside the node:quic follow-ups in #35741.

Verification

New case in test/js/bun/http/serve-http3.test.ts sends a 60 KB x-big header, asserts the handler receives the full value, and asserts a second request on the pooled session still succeeds.

Before: fetch rejects with HTTP3StreamReset (server never invokes the handler).
After: status 200, x-big.length === 60000, follow-up request served.

test/js/bun/http/serve-http3.test.ts, test/js/node/quic/*, and test/js/node/http2/h2-conformance.test.ts all pass with the new struct layout. #35741's new quic-stream.test.ts large-header case (maxHeaderLength: 1 << 20, 60 KB value) also passes under the nq_hsi_prepare_decode cap.


[review] gate passed · iteration 2 · 5 files touched

fails on main (without fix)
ASAN without fix: 1 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" "test/js/bun/http/serve-http3.test.ts"
bun test v1.4.0 (a17a87587)

test/js/bun/http/serve-http3.test.ts:
(pass) Bun.serve HTTP/3 > basic GET [3241.99ms]
(pass) Bun.serve HTTP/3 > POST echoes body, status, request headers [3149.54ms]
(pass) Bun.serve HTTP/3 > 204 with no body [3025.98ms]
(pass) Bun.serve HTTP/3 > query string is preserved [2732.22ms]
(pass) Bun.serve HTTP/3 > large response body crosses multiple QUIC packets [2638.93ms]
(pass) Bun.serve HTTP/3 > concurrent requests across separate connections [2692.87ms]
(pass) Bun.serve HTTP/3 > client abort mid-response does not crash the server [2447.74ms]
(pass) Bun.serve HTTP/3 > http1: false rejects HTTP/1.1 but accepts HTTP/3 [2326.26ms]
(pass) Bun.serve HTTP/3 > http1: false — url/address/stop see the QUIC listener [3533.80ms]
(pass) Bun.serve HTTP/3 > maxRequestBodySize is enforced for H3 bodies without Content-Length [2192.95ms]
(pass) Bun.serve HTTP/3 > unknown route returns 404 [2646.96ms]
(pass) Bun.serve HTTP/3 > routes: handler with :params [2347.71ms]
(pass) Bun.serve
... (truncated)

release without fix: 1 skipped
bun test v1.4.0-canary.1 (907cc02b9)

test/js/bun/http/serve-http3.test.ts:
(pass) Bun.serve HTTP/3 > basic GET [496.01ms]
(pass) Bun.serve HTTP/3 > POST echoes body, status, request headers [294.95ms]
(pass) Bun.serve HTTP/3 > 204 with no body [258.72ms]
(pass) Bun.serve HTTP/3 > query string is preserved [185.69ms]
(pass) Bun.serve HTTP/3 > large response body crosses multiple QUIC packets [259.13ms]
(pass) Bun.serve HTTP/3 > concurrent requests across separate connections [174.52ms]
(pass) Bun.serve HTTP/3 > client abort mid-response does not crash the server [214.30ms]
(pass) Bun.serve HTTP/3 > http1: false rejects HTTP/1.1 but accepts HTTP/3 [195.43ms]
(pass) Bun.serve HTTP/3 > http1: false — url/address/stop see the QUIC listener [1101.45ms]
(pass) Bun.serve HTTP/3 > maxRequestBodySize is enforced for H3 bodies without Content-Length [288.56ms]
(pass) Bun.serve HTTP/3 > unknown route returns 404 [213.95ms]
(pass) Bun.serve HTTP/3 > routes: handler with :params [255.99ms]
(pass) Bun.serve HTTP/3 > routes: per-method handler [204.26ms]
(pass) Bun.serve HTTP/3 > routes: method-specific '/*' falls through to fetch() on other methods [268.88ms]
(pass) Bun.serve H
... (truncated)
passes on PR (with fix)
ASAN with fix: all passed
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" "test/js/bun/http/serve-http3.test.ts"
bun test v1.4.0 (a17a87587)

test/js/bun/http/serve-http3.test.ts:
(pass) Bun.serve HTTP/3 > basic GET [3001.30ms]
(pass) Bun.serve HTTP/3 > POST echoes body, status, request headers [3175.47ms]
(pass) Bun.serve HTTP/3 > 204 with no body [1970.31ms]
(pass) Bun.serve HTTP/3 > query string is preserved [2040.21ms]
(pass) Bun.serve HTTP/3 > large response body crosses multiple QUIC packets [1994.48ms]
(pass) Bun.serve HTTP/3 > concurrent requests across separate connections [2029.77ms]
(pass) Bun.serve HTTP/3 > client abort mid-response does not crash the server [2215.62ms]
(pass) Bun.serve HTTP/3 > http1: false rejects HTTP/1.1 but accepts HTTP/3 [2469.19ms]
(pass) Bun.serve HTTP/3 > http1: false — url/address/stop see the QUIC listener [3272.20ms]
(pass) Bun.serve HTTP/3 > maxRequestBodySize is enforced for H3 bodies without Content-Length [2150.82ms]
(pass) Bun.serve HTTP/3 > unknown route returns 404 [2239.88ms]
(pass) Bun.serve HTTP/3 > routes: handler with :params [2057.89ms]
(pass) Bun.serve
... (truncated)

release with fix: 1 skipped
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 2484ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/5] cc obj/packages/bun-usockets/src/node_quic_shim.c.o
[2/5] cc obj/packages/bun-usockets/src/quic.c.o
[2/5] link bun-profile
[3/5] bun-profile --revision
1.4.0-canary.1+a17a87587
[5/5] strip bun
[build] done
bun test v1.4.0-canary.1 (a17a87587)

test/js/bun/http/serve-http3.test.ts:
(pass) Bun.serve HTTP/3 > basic GET [355.43ms]
(pass) Bun.serve HTTP/3 > POST echoes body, status, request headers [204.45ms]
(pass) Bun.serve HTTP/3 > 204 with no body [200.99ms]
(pass) Bun.serve HTTP/3 > query string is preserved [167.65ms]
(pass) Bun.serve HTTP/3 > large response body crosses multiple QUIC packets [169.20ms]
(pass) Bun.serve HTTP/3 > concurrent requests across separate connections [186.31ms]
(pass) Bun.serve HTTP/3 > client abort mid-response does not crash the server [186.01ms]
(pass) Bun.serve HTTP/3 > http1: false rejects HTTP/1.1 but accepts HTTP/3 [206.47ms]
(pass) Bun.serve HTTP/3 > http1: false — url/address/stop see the QUIC listener [1093.05ms]
(pass) Bun.serve HTTP/3 > maxRequestBodySize is 
... (truncated)
diff hotspot
packages/bun-usockets/src/node_quic_shim.c | 14 +++++++++++++-
 packages/bun-usockets/src/quic.c           | 24 ++++++++++++++++++++----
 packages/h3blast/Makefile                  |  4 ++++
 scripts/build/flags.ts                     | 13 +++++++++++++
 test/js/bun/http/serve-http3.test.ts       | 17 +++++++++++++++++
 5 files changed, 67 insertions(+), 5 deletions(-)

gate history · 3 passed · 0 rejected · iteration 2

evidence per changed file
file                                        reads  edits  tests
packages/bun-usockets/src/node_quic_shim.c      4      3      0
packages/bun-usockets/src/quic.c                6      6      0
packages/h3blast/Makefile                       2      3      0
scripts/build/flags.ts                          1      2      0
test/js/bun/http/serve-http3.test.ts            4      1      0

…request header does not abort the connection

us_quic_hsi_prepare (the server-side QPACK prepare_decode hook for
Bun.serve http3) rejected any per-header decode request above 64 KB by
returning NULL, which lsquic treats as a QPACK decompression failure and
aborts the whole connection (HEC_QPACK_DECOMPRESSION_FAILED, app error
0x200). With Huffman the decoder reserves ~1.5x the encoded length, so a
single header value of ~44 KB encoded (~59 KB decoded) was enough to take
down every in-flight stream on the session.

Two changes:

* Build lshpack/lsqpack with -DLSXPACK_MAX_STRLEN=UINT32_MAX so
  lsxpack_strlen_t is 32-bit. This is struct-layout ABI shared by every
  consumer of lsxpack_header.h (lshpack, lsqpack, lsquic, quic.c,
  node_quic_shim.c, c-bindings.cpp), so it lives in globalFlags. Same
  change as #35741 for node:quic.
* Cap us_quic_hsi_prepare at LSXPACK_MAX_STRLEN instead of 64 KB, matching
  nq_hsi_prepare_decode. lsqpack itself bounds prepare_decode requests at
  LSXPACK_MAX_STRLEN, and NULL from this hook is connection-fatal, so
  the only rejection we keep is the type's structural limit.

es_max_header_list_size (16 KB on the server) is advisory only in lsquic:
it is written into the SETTINGS frame but never enforced on decode, so it
was not the guard the surrounding comment implied.

Test added to serve-http3.test.ts sends a 60 KB x-big header, asserts the
handler receives it intact and the pooled session carries a second
request.
@coderabbitai

coderabbitai Bot commented Jul 25, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

Changes

QUIC header decode protection

Layer / File(s) Summary
Align LSXPACK build configuration
scripts/build/flags.ts, packages/h3blast/Makefile
Defines LSXPACK_MAX_STRLEN=UINT32_MAX consistently across build consumers and documents the required vendor object layout.
Enforce decoded header allocation limits
packages/bun-usockets/src/quic.c
Adds a 128 KB decoded-header buffer ceiling and rejects expansion exceeding either this cap or LSXPACK_MAX_STRLEN.
Validate large-header connection reuse
test/js/bun/http/serve-http3.test.ts
Tests decoding a large request header and successfully sending a follow-up request on the same HTTP/3 session.

Possibly related PRs

  • oven-sh/bun#34194: Both changes modify us_quic_hsi_prepare and its header-buffer allocation behavior.

Suggested reviewers: jarred-sumner, cirospaciari

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly summarizes the main HTTP/3 QPACK cap change and connection-abort fix.
Description check ✅ Passed Covers the change and verification, though it uses custom headings instead of the template's exact prompts.

Comment @coderabbitai help to get the list of available commands.

@robobun

robobun commented Jul 25, 2026 •

Copy link
Copy Markdown
Collaborator Author

Reproduced with:

bun test test/js/bun/http/serve-http3.test.ts -t "single large request header value"

Before: HTTP3StreamReset (server-side HEC_QPACK_DECOMPRESSION_FAILED, handler never invoked).
After: 200, handler sees the full 60000-byte value, pooled session carries a second request.

The flags.ts and packages/h3blast/Makefile hunks are byte-identical to #35741; the overlap merges cleanly in either order. Both QPACK prepare_decode hooks are bounded (128 KB for Bun.serve, max(2 × maxHeaderLength, 128 KB) for node:quic) so the widened lsxpack_strlen_t does not open a peer-declared-varint amplification; see the PR body for the numbers.

CI status: the diff is green on every lane that actually ran. The gate passed (test fails without the fix and passes with it on ASAN and release). All 40 debian/ubuntu test shards on build 81475 passed, and every build-cpp job that got an agent passed (darwin-aarch64, darwin-x64, linux-aarch64, linux-x64, linux-aarch64-musl). The red lanes are build-bun siblings of build-cpp jobs that expired with no agent assigned (same on build 81349, where three build-cpp lanes stayed scheduled for an hour). Ready for a maintainer; all review threads are resolved.

@robobun

robobun commented Jul 25, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 9:14 PM PT - Jul 25th, 2026

❌ @robobun, your commit a17a875 has 1 failures in Build #81475 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 35763

That installs a local version of the PR into your bun-35763 executable, so you can run:

bun-35763 --bun

@github-actions

Copy link
Copy Markdown
Contributor

This PR may be a duplicate of:

  1. node:quic(h3): build lshpack/lsqpack with 32-bit header lengths so large request headers don't abort the connection #35741 - Same -DLSXPACK_MAX_STRLEN=UINT32_MAX build flag change in flags.ts fixing the identical 16-bit lsxpack_strlen_t root cause for large HTTP/3 request headers

🤖 Generated with Claude Code

@robobun

robobun commented Jul 25, 2026

Copy link
Copy Markdown
Collaborator Author

Not a duplicate of #35741. Same root cause, different code path:

The flags.ts hunk is identical between the two (struct-layout ABI, must be set once globally); whichever lands first, the other merges cleanly.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Beyond the inline findings, I also checked us_quic_hsi_prepare for unsigned overflow in need = h->len + space and an infinite loop in the ncap *= 2 doubling now that the cap is UINT32_MAX — ls-qpack's 24-bit varint bound keeps space ≤ ~25 MB in practice, so ncap tops out at 32 MB well before wrapping. The HTTP/2 (lshpack) decode path was also checked for a matching cap that this ABI change would need lifted — there isn't one.

Extended reasoning...

The bug-hunting system's DoS finding is the load-bearing concern here and warrants human review; the h3blast ABI mismatch is a real but dev-tool-only nit. Separately, three finder agents raised potential unsigned-overflow / infinite-loop issues in the same us_quic_hsi_prepare function after the cap moved to UINT32_MAX: h->len + (unsigned int) space wrapping, and while (ncap < need) ncap *= 2 never terminating if need were near UINT32_MAX. Verifiers traced ls-qpack's lsqpack_dec_int24 (caps value length at 1u << 24) and guarantee_out_bytes (requests ~1.5× that), so space is bounded around 25 MB in practice and the doubling loop reaches 32 MB without overflow — recording that here so it isn't re-derived.

Comment thread packages/bun-usockets/src/quic.c
Comment thread scripts/build/flags.ts
…K hunks with #35741

Review found that lifting the prepare_decode cap to LSXPACK_MAX_STRLEN
(UINT32_MAX after the build-flag change) removed the only server-side bound
on peer-declared pre-allocation: lsqpack requests ~1.5x the declared value
length before any value bytes arrive, so a handful of wire bytes could
reserve ~32 MB per stream. Keep a hard 128 KB ceiling on the total hset
buffer instead; that admits the 60 KB repro (and any single header up to
~85 KB) while bounding the worst case at 128 KB x es_init_max_streams_bidi
per connection.

Also:
* Fix the stale comment above es_max_header_list_size (it is advisory only;
  the allocation guard lives in us_quic_hsi_prepare).
* Add -DLSXPACK_MAX_STRLEN=UINT32_MAX to packages/h3blast/Makefile so its
  hand-rolled compile agrees with the dep objects it links.
* Make the flags.ts and Makefile hunks byte-identical to #35741 so a
  3-way merge resolves to one copy.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@test/js/bun/http/serve-http3.test.ts`:
- Around line 531-542: The HTTP/3 tests only cover successful large headers and
must also verify the rejection boundary. Extend the test coverage around fetchH3
to send a header value just over US_QUIC_HSET_MAX_BUF (128 KiB) and assert the
expected protocol failure, preserving the existing successful 60,000-byte and
follow-up request checks.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 431a5883-7e1c-4135-b2b9-caee661f26b6

📥 Commits

Reviewing files that changed from the base of the PR and between df6c7ee and d7ab1ec.

📒 Files selected for processing (4)
  • packages/bun-usockets/src/quic.c
  • packages/h3blast/Makefile
  • scripts/build/flags.ts
  • test/js/bun/http/serve-http3.test.ts

Comment thread test/js/bun/http/serve-http3.test.ts

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

d7ab1ec addresses both earlier findings — us_quic_hsi_prepare now bounds the hset buffer at US_QUIC_HSET_MAX_BUF (128 KB) via h->len + space, and packages/h3blast/Makefile sets -DLSXPACK_MAX_STRLEN=UINT32_MAX so its layout matches the linked dep objects. The remaining inline note is the sibling node:quic decode hook, which the same flags.ts hunk also unbounds.

Extended reasoning...

The author pushed d7ab1ec after my previous review. I re-checked packages/bun-usockets/src/quic.c: the new US_QUIC_HSET_MAX_BUF guard ((size_t) h->len + space > 128*1024) sits before the realloc, so the earlier ~32 MB-per-stream amplification on Bun.serve({http3:true}) is closed. packages/h3blast/Makefile now carries the matching -DLSXPACK_MAX_STRLEN define. Both prior comments are resolved. The one open item (posted inline) is that the global build flag also flows to node_quic_shim.c's nq_hsi_prepare_decode, whose only pre-realloc guard is still space > LSXPACK_MAX_STRLEN; d7ab1ec did not touch that file. Not approving given this is a network-facing allocation bound.

Comment thread scripts/build/flags.ts
The -DLSXPACK_MAX_STRLEN=UINT32_MAX build flag turns the sole guard in
nq_hsi_prepare_decode (space > LSXPACK_MAX_STRLEN) into a 4 GB no-op,
opening the same peer-declared-varint amplification on node:quic that the
previous commit bounded for Bun.serve. Cap the decode buffer at twice the
configured maxHeaderLength (Huffman headroom for a single header that is
itself the whole budget), falling back to 128 KB when maxHeaderLength is
unlimited. Verified against #35741's large-header test (maxHeaderLength
1 MB, 60 KB value).
Comment thread packages/bun-usockets/src/node_quic_shim.c
With the default maxHeaderLength of 16384, 2x that is 32 KB, which is below
the pre-PR threshold (space > UINT16_MAX). A header in the 32-64 KB
decode-window range used to decode and get silently dropped by
process_header (session stays alive); it would now be connection-fatal.
Floor the cap at US_NQ_HSET_MAX_BUF so no configuration regresses; users
who opt into a larger maxHeaderLength still get proportionally more.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All three rounds of feedback are addressed in 907cc02 — the quic.c hset buffer is capped at 128 KB, nq_hsi_prepare_decode is capped at max(2 × maxHeaderLength, 128 KB) so the default config no longer regresses, and the h3blast Makefile carries the matching -DLSXPACK_MAX_STRLEN. I didn't find anything further, but this changes peer-controlled allocation bounds on a network-facing path and flips a global struct-layout ABI flag, so it's worth a human look — particularly the 128 KB cap value and the connection-fatal-vs-stream-reset deferral.

What was reviewed:

  • Traced every TU that includes lsxpack_header.h (lshpack/lsqpack/lsquic via computeDepFlags, quic.c, node_quic_shim.c, c-bindings.cpp, h3blast) — all get the flag; c-bindings.cpp's HTTP/2 path keeps its own local 64 KB LSHPACK_MAX_HEADER_SIZE cap and its Rust FFI struct uses size_t, so no ABI break there.
  • Re-checked the amplification bound after 907cc02: quic.c worst case ≈ 128 KB × 100 streams ≈ 12.8 MB/conn; node:quic default ≈ 128 KB/stream (was effectively 64 KB before, so no regression).
  • Confirmed the flags.ts entry has no when predicate and flows through computeDepFlags, so vendored deps and bun's own TUs agree on the 32-bit layout on every platform/profile.
Extended reasoning...

Overview

The PR lifts the 64 KB per-header QPACK decode-buffer cap in Bun.serve({http3: true}) so a ~60 KB request header no longer aborts the whole connection with H3_QPACK_DECOMPRESSION_FAILED. It does so by (1) building every lsxpack_header.h consumer with -DLSXPACK_MAX_STRLEN=UINT32_MAX (widening lsxpack_strlen_t from u16 → u32, a struct-layout ABI change), (2) replacing quic.c's hardcoded 64 KB per-request cap with a 128 KB whole-buffer cap, (3) adding a matching max(2 × maxHeaderLength, 128 KB) cap to the sibling node:quic hook, and (4) adding a regression test that sends a 60 KB header and asserts the pooled session survives.

Over three review rounds I flagged: the initial uncapped variant opened a ~25 MB/stream × 100-stream amplification DoS (fixed in d7ab1ec with the 128 KB cap); the h3blast dev tool's Makefile didn't set the flag and would link mismatched-layout objects (fixed in d7ab1ec); the same DoS on the node:quic sibling (fixed in de97474); and de97474's 2 × maxHeaderLength cap regressed the default config's abort threshold from 64 KB to 32 KB (fixed in 907cc02 with a 128 KB floor). All resolved.

Security risks

The change directly governs how much memory a peer-declared QPACK length varint can force the server to allocate before any value bytes arrive. The final state bounds this at 128 KB/stream for Bun.serve (≈12.8 MB/connection at the default 100 bidi streams) and max(2 × maxHeaderLength, 128 KB)/stream for node:quic. Exceeding the cap is still connection-fatal (NULL → LQRHS_ERROR → HEC_QPACK_DECOMPRESSION_FAILED), which is a documented follow-up (needs an lsquic patch to make it per-stream per RFC 9114 §4.2.2). The struct-ABI change is the other risk surface: any TU that includes lsxpack_header.h without the flag would read/write misaligned fields. I traced every consumer — vendored lshpack/lsqpack/lsquic get it via computeDepFlags → globalFlags (no when predicate), bun's own quic.c/node_quic_shim.c/c-bindings.cpp via computeFlags, and h3blast via its patched Makefile. The HTTP/2 HPACK path in c-bindings.cpp keeps its own local LSHPACK_MAX_HEADER_SIZE = 65536 shared-buffer cap and its Rust FFI struct (lshpack.rs) uses usize for lengths, so it's unaffected by the width change.

Level of scrutiny

High. This is production network-facing code that bounds peer-controlled allocation, and the fix required three iterations to get the DoS bound and the default-config behavior right. The 128 KB constant, the 2 × maxHeaderLength formula, the decision to keep over-cap connection-fatal for now, and the coordination with #35741 (byte-identical flags.ts/h3blast hunks) are all judgment calls a maintainer should sign off on.

Other factors

The regression test is well-constructed (asserts full value received + pooled-session survival, uses Buffer.alloc per harness convention, comment explains the Huffman/1.5× arithmetic). The PR body's evidence shows the test failing on main (ASAN lane) and passing on the PR, and reports serve-http3.test.ts, node/quic/*, and h2-conformance.test.ts all passing under the new layout. The CodeRabbit suggestion to test the rejection boundary was reasonably declined (would lock in a transient internal threshold and connection-fatal behavior that's slated to change).

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

907cc02 addresses the last of my four findings (the nq_hsi_prepare_decode cap is now floored at 128 KB, so the default maxHeaderLength = 16384 no longer regresses below the pre-PR 64 KB abort threshold). I found nothing further in this revision, but this changes a DoS bound on network-facing header decode and flips a struct-layout ABI flag across lshpack/lsqpack/lsquic, so I'd like a human to sign off.

What was reviewed:

  • us_quic_hsi_prepare: h->len + space bounded at 128 KB; (unsigned int) cast and ncap doubling stay well under overflow.
  • nq_hsi_prepare_decode: (size_t) h->max_bytes * 2 widens before multiply; max_bytes == 0 and the 16 KB default both floor to 128 KB.
  • ABI reach of -DLSXPACK_MAX_STRLEN=UINT32_MAX: c-bindings.cpp (HTTP/2) and src/http/lshpack.rs go through the lshpack_header wrapper with size_t fields, not lsxpack_header_t directly, so the Rust↔C++ boundary is unaffected; h3blast now matches.
Extended reasoning...

Overview

This PR lifts the 64 KB per-header QPACK decode-buffer cap in us_quic_hsi_prepare (Bun.serve HTTP/3) so a ~60 KB request header value no longer aborts the whole connection with H3_QPACK_DECOMPRESSION_FAILED. To do that it flips LSXPACK_MAX_STRLEN from the upstream UINT16_MAX default to UINT32_MAX globally (via scripts/build/flags.ts), which changes lsxpack_strlen_t from uint16_t to uint32_t in every TU that includes lsxpack_header.h — lshpack, lsqpack, lsquic, quic.c, node_quic_shim.c, c-bindings.cpp, and the h3blast dev harness. It then re-bounds the two prepare_decode hooks (128 KB total in quic.c; max(2 × maxHeaderLength, 128 KB) in node_quic_shim.c) so the widened type doesn't open a peer-controlled pre-allocation DoS. A new test in serve-http3.test.ts sends a 60 KB x-big header and asserts the handler receives it and the pooled session survives a follow-up request.

Security risks

The change sits directly on a DoS boundary: lsqpack requests the decode buffer from the peer's value-length varint before any value bytes arrive, so the cap is the only thing bounding how much heap ~13 wire bytes can reserve per stream. My first three review rounds on this PR each found a way the intermediate revisions widened that surface (unbounded quic.c cap → 32 MB/stream; unbounded node_quic_shim.c sibling; then a regression where the default maxHeaderLength halved the abort threshold). All are addressed in 907cc02: quic.c bounds the whole hset at 128 KB (~12.8 MB/conn worst case at 100 bidi streams), and node_quic_shim.c floors at 128 KB so no configuration aborts sooner than before. Exceeding the cap remains connection-fatal (an lsquic-level limitation the PR body tracks as follow-up). I also checked the reach of the struct-ABI change into HTTP/2: src/http/lshpack.rs mirrors the C++ wrapper struct lshpack_header (whose length fields are size_t), not lsxpack_header_t itself, so the Rust FFI layout is unaffected; and c-bindings.cpp's LSHPACK_MAX_HEADER_SIZE = 65536 passed to lsxpack_header_prepare_decode now fits without truncation.

Level of scrutiny

High. This is production network-facing server code (Bun.serve({http3: true}) and node:quic), it changes an allocation bound on adversarial input, and it flips a build flag that alters struct layout across three vendored libraries plus Bun's own HTTP/2 path. The PR body explicitly calls the shared flags.ts/h3blast hunks byte-identical to #35741, so merge order matters. The 128 KB cap is a judgment call (well-argued in the PR body, but still a policy choice about the connection-fatal threshold) and there's an acknowledged follow-up to make over-limit a per-stream reset instead.

Other factors

The gate evidence shows the new test fails on main (ASAN lane) and passes with the fix; test/js/node/http2/h2-conformance.test.ts and test/js/node/quic/* were run against the new layout. The bug hunting system found nothing in the current revision. Given the security-sensitive surface and the cross-library ABI change, I'm deferring rather than approving — a maintainer should confirm the 128 KB bound and the interim connection-fatal semantics are acceptable, and that landing order vs #35741 is understood.

@Jarred-Sumner

Copy link
Copy Markdown
Collaborator

Rebase

robobun added a commit that referenced this pull request Sep 13, 2026
Same shape as before (twice maxHeaderLength), with a 128 KB floor in
place of 64 KB. The hunk is now identical to the one in #35763, so the
second of the two to land rebases without a conflict here.
@robobun

robobun commented Sep 13, 2026

Copy link
Copy Markdown
Collaborator Author

Heads-up from #35741, which is now rebased on f04caca.

Main has a new consumer of lsxpack_header.h since this branch was cut: packages/bun-uws/src/Http2Context.h (the Bun.serve HTTP/2 server). It uses LSXPACK_MAX_STRLEN as ls-hpack's per-string limit in four places (writeHeaderBlock and handleHeaderBlock). This PR carries the same -DLSXPACK_MAX_STRLEN=UINT32_MAX define, so after a rebase those checks become no-ops:

  • On encode, a response header of 2 MB or more reaches lshpack_enc_encode. Its length prefix is four bytes (size_buf[4] in lshpack_enc_enc_str), so it fails, and writeHeaderBlock doubles its buffer in a loop.
  • On decode, a name or value past 64 KB now fails with COMPRESSION_ERROR (ls-hpack returns LSHPACK_ERR_TOO_LARGE past UINT16_MAX at either width) where it was ENHANCE_YOUR_CALM.

#35741 gives that file its own HPACK_MAX_STRLEN = UINT16_MAX so HTTP/2 does not change. If this PR lands first it needs the same change. The node_quic_shim.c hunk in #35741 is now byte-identical to the one here. The flags.ts comment differs a little (it lists Http2Context.h as a consumer).

robobun added a commit that referenced this pull request Sep 22, 2026
Same shape as before (twice maxHeaderLength), with a 128 KB floor in
place of 64 KB. The hunk is now identical to the one in #35763, so the
second of the two to land rebases without a conflict here.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants