Skip to content

Bun.serve(http3): send a response whose header block is larger than 64 KB - #42905

Open
robobun wants to merge 4 commits into
mainfrom
robobun/dc5f7931/http3-large-header-block
Open

robobun wants to merge 4 commits into
mainfrom
robobun/dc5f7931/http3-large-header-block

Conversation

@robobun

@robobun robobun commented Sep 16, 2026 •

Copy link
Copy Markdown
Collaborator

Fixes #42887.

Problem

  • Bun.serve({ http3: true }) cannot send a response whose QPACK-encoded header block is larger than 64 KB. The same Response goes out over HTTP/1.1 and HTTP/2. On the canary the client times out. Debug builds abort first: lsxpack_header_set_offset2: Assertion 'val_offset <= LSXPACK_MAX_STRLEN' failed.
  • The cap is in lsquic. send_headers_ietf (vendor/lsquic/src/liblsquic/lsquic_stream.c:4120) encodes into a stack buffer of MAX_HEADERS_SIZE (64 KB) and returns -1 with QWH_ENOBUF for a larger block. Nothing is written. Upstream master has the same buffer.
  • The assertion comes from packages/bun-usockets/src/quic.c. It addressed every field from the start of one shared buffer, and lsxpack offsets are 16-bit.

Fix

  • New patches/lsquic/large-header-block.patch: send_headers_ietf computes an upper bound of the encoded block from the header list (name, value, and two QPACK integers per field). A block under 64 KB keeps the stack buffer. A larger one uses a heap buffer that is freed at clean:.
  • quic.c: each lsxpack_header points at its own slice of the shared buffer with offsets from 0, on the send path (us_quic_stream_send_headers) and the decode path (us_quic_hsi_prepare, us_quic_hsi_process). The send-side hunks in quic.c and node_quic_shim.c are the same as in Bun.serve(http3): reset the stream when lsquic refuses the response headers or a body write #42895, so either PR merges cleanly after the other. This relayout becomes unnecessary once node:quic(h3): build lshpack/lsqpack with 32-bit header lengths so large request headers don't abort the connection #35741 builds lsxpack with 32-bit lengths.
  • Correct because QPACK picks Huffman only when it is shorter than the literal, so the bound never undercounts. lsquic's frame size and stash fields are size_t and unsigned, so a block over 64 KB goes through unchanged.
  • Verified: test/js/bun/http/serve-http3.test.ts and test/js/node/quic/quic-stream.test.ts (one new case each, 100 headers of 700 bytes, both time out on main). Also the full serve-http3 file, test/js/node/quic/, and the three fetch-http3-* client files.

Background

Notes

…4 KB

lsquic's send_headers_ietf encoded the header block into a fixed 64 KB
stack buffer and failed with QWH_ENOBUF for a larger block, so the
response never went out. A new lsquic patch sizes the buffer from the
header list and uses the heap above 64 KB.

The lsxpack offsets are 16-bit. The send and decode paths in quic.c and
the send path in node_quic_shim.c addressed every field from the start
of one shared buffer, so more than 64 KB of fields tripped the offset
assertion in debug builds. Each field now points at its own slice of
the buffer. The send-side hunks are the same as in #42895.
@coderabbitai

coderabbitai Bot commented Sep 16, 2026 •

Copy link
Copy Markdown
Contributor

Review Change StackReview Change Stack

Warning

Review limit reached

  • Run on-demand review

On-demand reviews are free for the next 4 days. After that, they cost $0.25 per reviewed file.

Or wait 1 minute for your next included review.

Check out review usage here.

View limit details

Limit details: You’ve used all 10 included reviews currently available.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Essentials

Run ID: 329e3f18-3cda-40ba-8715-ecd8c5e8c8d5

📥 Commits

Reviewing files that changed from the base of the PR and between 16a15ea and 438ac1a.

📒 Files selected for processing (2)
  • test/js/bun/http/serve-http3.test.ts
  • test/js/node/quic/quic-stream.test.ts

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Essentials

Run ID: 0f8e36d2-f0f5-4014-aef2-bebfdff37da5

📥 Commits

Reviewing files that changed from the base of the PR and between c6b7fcb and 6702342.

📒 Files selected for processing (5)
  • packages/bun-usockets/src/node_quic_shim.c
  • packages/bun-usockets/src/quic.c
  • patches/lsquic/large-header-block.patch
  • scripts/build/deps/lsquic.ts
  • test/js/bun/http/serve-http3.test.ts

Included review availability: Your plan provides up to 10 included reviews per hour; 0 remain after this review.


Walkthrough

HTTP/3 header offsets now use per-entry storage. lsquic sizes encoded header blocks from the header list and allocates larger buffers when required. Tests cover a response with approximately 70 KiB of headers.

Changes

HTTP/3 large-header support

Layer / File(s) Summary
Correct header offsets
packages/bun-usockets/src/node_quic_shim.c, packages/bun-usockets/src/quic.c
Header encoding and decoding now use offsets relative to each header entry or storage slice. Name and value length checks reject unsupported lengths.
Large header-block allocation
patches/lsquic/large-header-block.patch, scripts/build/deps/lsquic.ts
lsquic calculates an encoded header-size bound and uses heap storage for blocks larger than 64 KiB on non-Windows systems. The build applies the patch.
HTTP/3 large-header validation
test/js/bun/http/serve-http3.test.ts
The test fixture generates large response headers. The test verifies 100 headers of 700 bytes each, a 200 status, and an "ok" body.

Suggested reviewers: jarred-sumner, cirospaciari

Priority: ➖ Normal

Severity of issue fixed: Medium

Merge Risk: ⚪ Minimal · up to 16a15

The large HTTP/3 response-header fix does not require increasing the native client’s HTTP/2 header-list setting. No actionable merge-blocking risk remains.

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The PR implements #42887. patches/lsquic/large-header-block.patch sizes the QPACK buffer from the header list, keeps the 64 KiB stack buffer for smaller blocks, and allocates heap storage for larger…
Out of Scope Changes check ✅ Passed The changed files support #42887. The quic.c and node_quic_shim.c offset changes prevent 16-bit offset overflow when headers use shared storage. The build registration and HTTP/3 regression test a…
Title check ✅ Passed The title clearly and concisely identifies the main change: enabling Bun.serve HTTP/3 responses with header blocks larger than 64 KB.
Description check ✅ Passed The description provides a detailed problem statement, implementation summary, verification results, background, limitations, and related issues. It does not use the exact template headings, but it in…

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

@robobun

robobun commented Sep 16, 2026

Copy link
Copy Markdown
Collaborator Author

Noted, the review raised no actionable items. CI is in progress on Buildkite build 116456. The failed claude-find-issues action exited before it ran (is_error on init) and does not relate to this diff.

@robobun

robobun commented Sep 16, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 4:46 AM PT - Sep 16th, 2026

✅ @robobun, your commit 438ac1ab946ce66af49a02401c3aa5fa934afebc passed in Build #116487! 🎉


🧪   To try this PR locally:

bunx bun-pr 42905

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

bun-42905 --bun

@robobun

robobun commented Sep 16, 2026

Copy link
Copy Markdown
Collaborator Author

The updated review still has no actionable items. I merged main into the branch (16a15ea) so the binary-size check compares against the same toolchain: main moved to LLVM 23 after the branch point, and the earlier run reported size deltas in both directions across targets, up to +1.78 MB on freebsd-aarch64, for a diff that adds about 50 lines of C. CI runs again on Buildkite build 116465.

@robobun

robobun commented Sep 16, 2026

Copy link
Copy Markdown
Collaborator Author

The diff is complete and ready for review. Local verification ran on the branch before the merge from main (LLVM 21 toolchain): the new test fails on the released bun with a timeout and passes on the debug build, and the full serve-http3, node/quic, and fetch-http3 client files pass. After the merge of main, the branch needs clang 23, which this environment does not have, so the local check cannot build either side. CI on Buildkite build 116465 is the verification for the merged state.

@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 the lsquic patch's headers_max_encoded_size bound (a QPACK literal line is one prefix integer plus one length integer per string, and Huffman is only chosen when shorter, so 2 * LSQPACK_UINT64_ENC_SZ + name_len + val_len per field does not undercount), that every exit from send_headers_ietf after the heap allocation reaches clean: (the early return -1 sits before malloc), and the decode-side relayout in us_quic_hsi_prepare/us_quic_hsi_process (offsets stay relative to h->buf + h->len across a resize, and h->len += is equivalent to the old absolute assignment) — none of those turned up a defect.

Extended reasoning...

Findings are already posted inline (swallowed -1 from the new >65535 guard in Http3Response.h, the pre-existing unbounded total header size on the decode path, and test-coverage nits). This note only records what else was examined and ruled out: the lsquic patch's size bound and heap-buffer ownership on all exit paths, and the per-field base-pointer arithmetic on both the send and decode sides. It is informational, not a correctness guarantee; the vendored-lsquic patch and the QUIC header relayout still warrant a human look alongside the inline comments.

Findings marked 🟡 are optional suggestions and need no follow-up push.

Additional findings (outside the current diff — GitHub can't attach inline comments there):

  • 🔴 packages/bun-usockets/src/quic.c — pre-existing, security: a hostile HTTP/3 client can make a Bun.serve server allocate about 2 GB and then spin forever in its event loop with one request whose decoded header set passes 2 GB. us_quic_hsi_prepare at quic.c:522 caps a single field at 64 KB but nothing caps the total, because lsquic only advertises es_max_header_list_size (quic.c:829) and does not enforce it on receipt. Once need exceeds 2^31, ncap *= 2 at quic.c:526 wraps to 0 and the while loop never exits. Fix: bound the total decoded size in us_quic_hsi_prepare (fail the block once h->len + space exceeds the context's es_max_header_list_size) for both the server and client contexts. [also at: packages/bun-usockets/src/quic.c:568 - pre-existing, security-relevant: a Bun.serve HTTP/3 peer can make the server buffer a decoded request header set of any size, and after this change that buffer grows without wrapping.]

    Extended reasoning...

    Condition: lsquic's IETF path treats es_max_header_list_size as advertise-only. In this checkout node-quic-accessors.patch:1072 passes it only to lsquic_hcso_write_settings, and patch:1492 logs the peer's value with "we ignore it". The PR description states a 100 x 700 B request header set reaches the handler although the server advertises 16 KB at quic.c:829, which shows the limit is not enforced on receipt. The server does not change es_qpack_dec_max_size, so the decoder's dynamic table stays at lsquic's 4096-byte default. The attacker inserts one ~4 KB entry and then sends indexed field lines of one byte each. For each one lsqpack calls us_quic_hsi_prepare with space of about 4 KB, which passes the per-field check at quic.c:522. h->len grows by about 4 KB per byte received (quic.c:568). lsquic re-extends the stream flow-control window as hq_read consumes the HEADERS frame, so the frame can be arbitrarily long. After roughly 512 KB of compressed input h->len approaches 2^31. At quic.c:523…

    Verification: normal (not pre-existing as the candidate labels it: this PR is what makes the growth path reachable). Trigger: a single HTTP/3 client sends one request whose QPACK-decoded header block exceeds 2 GB (feasible: with the default 4 KB decoder dynamic table one 1-byte indexed field line expands to ~4 KB, so ~540 KB of HEADERS payload suffices; static-table-only still gives ~50x). Mechanism, verified…

Comment thread packages/bun-usockets/src/quic.c
Comment thread test/js/bun/http/serve-http3.test.ts Outdated
Comment thread packages/bun-usockets/src/node_quic_shim.c
@robobun

robobun commented Sep 16, 2026

Copy link
Copy Markdown
Collaborator Author

Pushed 92e86de for the review findings: the Bun.serve test compares the received header values byte for byte, and a node:quic case in quic-stream.test.ts covers the send path in node_quic_shim.c (times out on the released bun, passes with the fix). The swallowed -1 in Http3Response.h and the missing total cap on decoded request headers are pre-existing. #42895 handles the first and #35763 the second, so this PR leaves both to them. The rate-limit notice needs no on-demand review. CI runs on the new commit.

@robobun

robobun commented Sep 16, 2026

Copy link
Copy Markdown
Collaborator Author

The refreshed summary has no actionable items. Nothing changed on my side since 92e86de. CI for that commit is Buildkite build 116480.

@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.

Code review completed

Nothing new to post: everything this review found is already covered by existing comments on this pull request or didn't merit a separate one.

Still open from earlier reviews (1):

  • Unresolved: 1 minor or pre-existing.

This review covers commit 92e86de, which is no longer the latest commit on this pull request; later commits are not covered by it.

@robobun

robobun commented Sep 16, 2026

Copy link
Copy Markdown
Collaborator Author

Both bot notes need no code change. The one open thread (the -1 that Http3Response.h drops) is answered and resolved: the caller change belongs to #42895, and this PR keeps the guard hunk identical to it. 438ac1a is an empty commit to run the checks again. CI is Buildkite build 116487.

@robobun

robobun commented Sep 16, 2026

Copy link
Copy Markdown
Collaborator Author

A note for the merge, from the #42895 side. I merged this branch with #42895 and #42901 locally and ran the tests of all three on a debug build. serve-http3.test.ts, quic-stream.test.ts and fetch-http3-client.test.ts pass together (154 tests).

git reports one conflict between this PR and #42895: both add a test at the same place in test/js/node/quic/quic-stream.test.ts. Keep both tests. This PR and #42901 also both append a patch to the list in scripts/build/deps/lsquic.ts, so the second one to land needs a rebase.

The first form of the #42895 tests expected RESET_STREAM for a block over 64 KB, which this PR sends. Two of them failed on the merge. They now use a value over 65535 bytes for the refused case, so they pass with and without the lsquic patch here, in either merge order.

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.

Bun.serve(http3): a response with more than 64 KB of encoded headers cannot be sent

1 participant