Skip to content

http(h3): reject DATA before final response HEADERS in the HTTP/3 client - #32678

Open
robobun wants to merge 6 commits into
mainfrom
farm/7a4c40fe/h3-data-before-headers
Open

http(h3): reject DATA before final response HEADERS in the HTTP/3 client#32678
robobun wants to merge 6 commits into
mainfrom
farm/7a4c40fe/h3-data-before-headers

Conversation

@robobun

@robobun robobun commented Jun 24, 2026

Copy link
Copy Markdown
Collaborator

Problem

Two HTTP/3 client conformance gaps, both in how it handles a malformed response.

1. Unbounded buffering before the final HEADERS (DoS). on_stream_data appended every incoming DATA chunk to stream.body_buffer unconditionally and then called deliver(). When stream.status_code == 0 (no final response HEADERS yet), deliver() returned early without draining the buffer. lsquic's frame filter rejects DATA before any HEADERS, but accepts it after a 1xx HEADERS because it only tracks that some HEADERS preceded DATA. A hostile server could send HEADERS(:status 100) followed by an unbounded stream of DATA frames; each chunk grew body_buffer with no consumer and no cap.

2. Malformed responses were torn down with a graceful close. RFC 9114 §4.1.2 requires a malformed message be a stream error of type H3_MESSAGE_ERROR (0x010E). The client's fail() path did abort()close(), which puts a FIN + STOP_SENDING(NO_ERROR) on the wire: to the peer that is indistinguishable from normal completion. The qs.reset() already in ClientSession::detach() never corrected this, because abort() runs first and sets lsquic's STREAM_U_WRITE_DONE, which trips lsquic_stream_maybe_reset's guard and turns the reset into a no-op.

Fix

on_stream_data rejects DATA that arrives while status_code == 0, mirroring the existing HTTP/2 guard at src/http/h2_client/dispatch.rs:430:

if len > 0 && stream.status_code == 0 {
    stream.session_mut().fail_malformed(stream);
    return;
}

ClientSession::fail_malformed puts H3_MESSAGE_ERROR on the wire before anything FINs the send half, then fails the request. It is used at all three malformed-response sites (a connection-specific response field, a missing :status, and DATA before the final HEADERS), not only the new one. It mirrors h2_client's rst_stream(PROTOCOL_ERROR):

pub fn fail_malformed(&mut self, stream: *mut Stream) {
    // Must run before abort()/detach(): their close() sets lsquic's
    // U_WRITE_DONE, which neuters lsquic_stream_maybe_reset.
    if let Some(qs) = stream_mut(stream).qstream_mut() {
        qs.reset(quic::ErrorCode::MESSAGE_ERROR);
    }
    self.fail(stream, err!(HTTP3ProtocolError));
}

The lsquic patch

lsquic's public API cannot express an HTTP/3 stream error once the send half is FIN'd (which every GET already is), and it discards the peer's incoming error code before the application can observe it. patches/lsquic/stream-error-code.patch (~15 lines) fills both gaps:

  • lsquic_stream_maybe_reset's already-closed-write branch records the caller's error code so the STOP_SENDING frame carries it instead of a hardcoded NO_ERROR. First error wins, so detach()'s later H3_REQUEST_CANCELLED cannot overwrite it through lsquic's deferred frame generation.
  • The peer's incoming RESET_STREAM / STOP_SENDING application error code, which lsquic_stream_rst_in / lsquic_stream_stop_sending_in parse and then drop, is stored and exposed via a new lsquic_stream_peer_error_code() accessor.

Every internal lsquic caller passes an error code of 0, which preserves the NO_ERROR default, so no existing behavior changes. us_quic_stream_reset gains an explicit error-code parameter (the one existing caller keeps H3_REQUEST_CANCELLED), and an RFC 9114 §8.1 ErrorCode newtype mirrors the existing H2 wire::ErrorCode.

Tests

Because a conformant HTTP/3 server cannot emit HEADERS(1xx) followed by DATA with no final HEADERS, and there is no raw QUIC framer in the test harness (unlike the raw-TCP servers the HTTP/2 equivalents use), a debug-only server hook (x-bun-test-100-then-data, compiled out of release builds) produces the sequence. The hook deliberately leaves its stream open, so the only thing that can close it is the client's stream error: that makes the error-code assertion deterministic and exercises a true RESET_STREAM(0x010E) rather than just a STOP_SENDING. Verified on the wire with BUN_DEBUG_lsquic=1:

event: generated RESET_STREAM: stream 0; offset 100; error code 270
event: RX RST_STREAM frame: error code 270, stream 0

The test asserts (a) the fetch rejects with HTTP3ProtocolError, and (b) the server observed 0x010E. With the wire reset reverted (the previous behavior) the server instead observes H3_REQUEST_CANCELLED:

error: expect(received).toBe(expected)
Expected: "10e"
Received: "10c"

A positive counterpart verifies a legitimate HEADERS(100) -> HEADERS(200) -> DATA -> FIN still delivers only the final response. Its warmup request works around a separate, pre-existing Bun.serve HTTP/3 server bug with 100-continue on a fresh QUIC connection, filed as #33082.

test/js/web/fetch/fetch-http3-adversarial.test.ts (29), fetch-http3-client.test.ts (52), and test/js/bun/http/serve-http3.test.ts (45) all pass locally.


[review] gate passed · iteration 11 · 16 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/web/fetch/fetch-http3-adversarial.test.ts"
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: checking for self-update (current version: 1.29.0)
bun test v1.4.0 (2db819fd9)

test/js/web/fetch/fetch-http3-adversarial.test.ts:
(pass) http3 adversarial body=65536 > POST /echo (Uint8Array) [93.59ms]
(pass) http3 adversarial body=65536 > POST /slow-echo (slow consumer) [49.37ms]
(pass) http3 adversarial body=65536 > POST /echo via pull ReadableStream [28.60ms]
(pass) http3 adversarial body=65536 > POST /echo via type:direct stream [34.33ms]
(pass) http3 adversarial body=65536 > POST /drop (server abandons body) [24.92ms]
(pass) http3 adversarial body=65536 > 8 concurrent POST /echo [77.16ms]
(pass) http3 adversarial body=524288 > POST /echo (Uint8Array) [41.59ms]
(pass) http3 adversarial body=524288 > POST /slow-echo (slow consumer) [54.34ms]
(pass) http3 adversarial body=524288
... (truncated)

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

test/js/web/fetch/fetch-http3-adversarial.test.ts:
(pass) http3 adversarial body=65536 > POST /echo (Uint8Array) [7.55ms]
(pass) http3 adversarial body=65536 > POST /slow-echo (slow consumer) [18.25ms]
(pass) http3 adversarial body=65536 > POST /echo via pull ReadableStream [1.34ms]
(pass) http3 adversarial body=65536 > POST /echo via type:direct stream [1.80ms]
(pass) http3 adversarial body=65536 > POST /drop (server abandons body) [1.81ms]
(pass) http3 adversarial body=65536 > 8 concurrent POST /echo [3.93ms]
(pass) http3 adversarial body=524288 > POST /echo (Uint8Array) [5.17ms]
(pass) http3 adversarial body=524288 > POST /slow-echo (slow consumer) [17.48ms]
(pass) http3 adversarial body=524288 > POST /echo via pull ReadableStream [5.54ms]
(pass) http3 adversarial body=524288 > POST /echo via type:direct stream [4.14ms]
(pass) http3 adversarial body=524288 > POST /drop (server abandons body) [0.69ms]
(pass) http3 adversarial body=524288 > 8 concurrent POST /echo [26.50ms]
(pass) http3 adversarial body=1048576 > POST /echo (Uint8Array) [7.21ms]
(pass) http3 adversarial body=1048576 > POST /slow-echo (slow consumer) [19.55ms]
(
... (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/web/fetch/fetch-http3-adversarial.test.ts"
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: checking for self-update (current version: 1.29.0)
bun test v1.4.0 (2db819fd9)

test/js/web/fetch/fetch-http3-adversarial.test.ts:
(pass) http3 adversarial body=65536 > POST /echo (Uint8Array) [78.76ms]
(pass) http3 adversarial body=65536 > POST /slow-echo (slow consumer) [54.37ms]
(pass) http3 adversarial body=65536 > POST /echo via pull ReadableStream [27.04ms]
(pass) http3 adversarial body=65536 > POST /echo via type:direct stream [28.42ms]
(pass) http3 adversarial body=65536 > POST /drop (server abandons body) [24.38ms]
(pass) http3 adversarial body=65536 > 8 concurrent POST /echo [74.59ms]
(pass) http3 adversarial body=524288 > POST /echo (Uint8Array) [30.21ms]
(pass) http3 adversarial body=524288 > POST /slow-echo (slow consumer) [59.77ms]
(pass) http3 adversarial body=524288
... (truncated)

release with fix: 1 skipped
$ bun scripts/build.ts --profile=release
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: checking for self-update (current version: 1.29.0)
[configured] bun-profile → bun (stripped) in 679ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/25] gen generated_host_exports.rs
generated_host_exports.rs: 91 exports (host=3, lazy=10, generic=78, rust=0); 243 extern-C blocks audited
[2/25] gen cpp.rs (cppbind)
[3/25] gen JS modules (bundle-modules)
Preprocess modules (10731ms)
Bundle modules (80ms)
Postprocesss modules (158ms)
Bundle Functions (861ms)
Generate Code (99ms)

[11.94s] Bundled "src/js" for production
  1913 kb
  162 internal modules
  12 native modules
  90 internal functions across 19 files
[3/15] cargo bun_bin → libbun_rust.a (--target x86_64-unknown-linux-gnu)
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: component r
... (truncated)
diff hotspot
packages/bun-usockets/src/quic.c                  |  21 ++--
 packages/bun-usockets/src/quic.h                  |   6 +-
 patches/lsquic/stream-error-code.patch            | 101 ++++++++++++++++++
 scripts/build/deps/lsquic.ts                      |   1 +
 src/http/H3Client.rs                              |   8 +-
 src/http/h3_client/ClientSession.rs               |  14 ++-
 src/http/h3_client/callbacks.rs                   |  11 +-
 src/http_jsc/headers_jsc.rs                       |  20 ++++
 src/js/internal-for-testing.ts                    |   7 ++
 src/runtime/dispatch_js2native.rs                 |   1 +
 src/runtime/server/server_body.rs                 |  25 +++++
 src/uws_sys/h3.rs                                 |  20 ++++
 src/uws_sys/libuwsockets_h3.cpp                   |  20 ++++
 src/uws_sys/quic.rs                               |   1 +
 src/uws_sys/quic/Stream.rs                        |  40 ++++++-
 test/js/web/fetch/fetch-http3-adversarial.test.ts | 123 +++++++++++++++++++++-
 16 files changed, 401 insertions(+), 18 deletions(-)

gate history · 2 passed · 0 rejected · iteration 11

evidence per changed file
file                                               reads  edits  tests
packages/bun-usockets/src/quic.c                       2      2     14
packages/bun-usockets/src/quic.h                       1      1     14
patches/lsquic/stream-error-code.patch                 1      1     14
scripts/build/deps/lsquic.ts                           1      2     14
src/http/H3Client.rs                                   2      2     14
src/http/h3_client/ClientSession.rs                    4      4     14
src/http/h3_client/callbacks.rs                        4      5     14
src/http_jsc/headers_jsc.rs                            1      1     14
src/js/internal-for-testing.ts                         3      1     14
src/runtime/dispatch_js2native.rs                      1      1     14
src/runtime/server/server_body.rs                      1      2     14
src/uws_sys/h3.rs                                      4      5     14
src/uws_sys/libuwsockets_h3.cpp                        2      3     14
src/uws_sys/quic.rs                                    1      2     14
src/uws_sys/quic/Stream.rs                             1      2     14
test/js/web/fetch/fetch-http3-adversarial.test.ts      4     11     14

@coderabbitai

coderabbitai Bot commented Jun 24, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

Adds lsquic support for peer application error codes, updates the usockets reset API to pass explicit error codes, rejects HTTP/3 DATA before response HEADERS in the client, and adds a debug-only adversarial test for that protocol failure.

Changes

HTTP/3 DATA-before-HEADERS protocol violation

Layer / File(s) Summary
lsquic patch: peer error code storage and STOP_SENDING propagation
patches/lsquic/stream-error-code.patch, scripts/build/deps/lsquic.ts
Adds sm_peer_error_code and lsquic_stream_peer_error_code, records peer error codes from RESET_STREAM and STOP_SENDING, preserves locally supplied error codes for STOP_SENDING generation, and wires the patch into the lsquic build.
usockets: parameterized reset and peer error accessor
packages/bun-usockets/src/quic.h, packages/bun-usockets/src/quic.c
us_quic_stream_reset now accepts a uint64_t code and forwards it to lsquic_stream_maybe_reset. us_quic_stream_peer_error_code returns the peer stream error code.
H3 client: reject DATA before response HEADERS
src/http/h3_client/callbacks.rs
on_stream_data now fails the stream with HTTP3ProtocolError when DATA arrives before a non-1xx response HEADERS has set status_code.
Adversarial test: HTTP3ProtocolError on DATA-before-HEADERS
test/js/web/fetch/fetch-http3-adversarial.test.ts
Adds isDebug gating and a test that triggers the invalid HTTP/3 frame sequence, expects HTTP3ProtocolError, and confirms normal requests still succeed.

Suggested reviewers

  • 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 names the main behavior change: rejecting HTTP/3 DATA before final response HEADERS.
Description check ✅ Passed It covers the PR's purpose and verification, though it uses custom headings instead of the template's exact section labels.

Warning

Review ran into problems

🔥 Problems

Git: Failed to clone repository. Please run the @coderabbitai full review command to re-trigger a full review. If the issue persists, set path_filters to include or exclude specific files.


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

@robobun

robobun commented Jun 24, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 8:45 AM PT - Jul 11th, 2026

@robobun, your commit 2db819f has 3 failures in Build #71934 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 32678

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

bun-32678 --bun

Comment thread packages/bun-uws/src/Http3Context.h Outdated

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

Thanks — the #ifdef BUN_DEBUG gating addresses my concern. The client-side guard looks correct and mirrors the H2 path, but since this still embeds a routing-bypass test hook in the H3 server request path (debug builds only) and touches protocol-error handling, I'd like a human to sign off on the approach.

Extended reasoning...

Overview

This PR adds a guard in the HTTP/3 client's on_stream_data callback (src/http/h3_client/callbacks.rs) that rejects the stream with HTTP3ProtocolError if any DATA bytes arrive before a final (non-1xx) response HEADERS frame, preventing unbounded growth of stream.body_buffer from a hostile peer. It mirrors the existing HTTP/2 guard at src/http/h2_client/dispatch.rs:432. To test it, the PR adds a debug-only adversarial hook in Http3Context.h / Http3Response.h triggered by an x-bun-test-100-then-data request header, plus a test.skipIf(!isDebug) test in fetch-http3-adversarial.test.ts.

Security risks

My earlier review flagged that the server hook ran unconditionally in release builds, letting any remote client bypass user routing/auth on a production Bun.serve({http3: true}) instance. Commit 175ce88 gates both the hook and endAfterInformational behind #ifdef BUN_DEBUG; I confirmed BUN_DEBUG=1 is set only when c.debug is true in scripts/build/flags.ts:770, matching the existing pattern in quic.c / libusockets.h / BunProcess.cpp. Release builds no longer compile the hook at all, so the production routing-bypass is resolved. The client-side change is purely defensive — it adds a rejection path, never relaxes one — and the len > 0 condition correctly lets a bare-FIN (null,0,fin=1) callback through to deliver().

The residual surface is that debug builds of Bun still honor the magic header on every H3 request before routing. That's acceptable for a non-shipped build, but it's test-only behavior living in the server request path rather than in a test fixture, which CLAUDE.md generally discourages.

Level of scrutiny

Moderate-to-high. The Rust change is small and pattern-matched against H2, but it sits in network protocol error handling (an lsquic FFI callback) where subtle ordering/borrow issues matter. The C++ change introduces a deliberate RFC-9114-violating code path in the server, gated to debug builds — that's a design/policy call (debug-only hook vs. a raw QUIC test fixture or an additional env-var opt-in) that a maintainer should ratify rather than a bot.

Other factors

No CODEOWNERS cover the touched paths. The bug-hunting pass on the revised commit found nothing. The PR description reports all 28+52+45 tests in the three H3 suites passing. Given the security-sensitive area and the embedded test hook design choice, I'm deferring rather than auto-approving.

@robobun

robobun commented Jun 24, 2026

Copy link
Copy Markdown
Collaborator Author

Addressed the review feedback in 5ee206f:

  • Rebased onto main. The guard sits at the top of on_stream_data, before the buffer append, and the post-deliver() receive-pause block from http: couple fetch() receive backpressure to JS body consumption (h1/h2/h3) #29831 is preserved verbatim: git diff main -- src/http/h3_client/callbacks.rs is a pure 7-line insertion, no session binding is reused across deliver().
  • The x-bun-test-100-then-data server hook moved out of the tracked uws fork. The response primitive is an exported entrypoint in Bun's own shim (uws_h3_res_test_end_after_informational, src/uws_sys/libuwsockets_h3.cpp, debug builds only) and the header routing happens in on_h3_request on the Rust side. packages/bun-uws is untouched by this PR.
  • Added positive interim-response coverage: HEADERS(100), HEADERS(200), DATA("body"), FIN resolves with status 200 and body "body". Deleting the 1xx early-return in on_stream_headers makes it fail with status 100 (and also flips the negative test).

Verified on a debug build: fetch-http3-adversarial.test.ts (29 tests), fetch-http3-client.test.ts + serve-http3.test.ts (97), and the h3 cases of fetch-backpressure.test.ts (the #29831 coverage) all pass. Fail-before for the guard still holds on the rebased code (HTTP3StreamReset instead of HTTP3ProtocolError).

While writing the positive test I found a pre-existing server-side bug: expect: 100-continue on the first request of an HTTP/3 connection tears the connection down with a QPACK decompression error (deterministic, details in the PR body). It is independent of this client change; the positive test uses a reused connection and says why.

@robobun
robobun force-pushed the farm/7a4c40fe/h3-data-before-headers branch from 175ce88 to 5ee206f Compare June 26, 2026 10:47

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

Thanks — the test hook is now properly gated (#[cfg(bun_debug)] / #ifdef BUN_DEBUG at all three layers) and moved out of the uws fork, so my earlier concern is resolved. Deferring to a human for the HTTP/3 client/server protocol-path changes themselves.

Extended reasoning...

Overview

This PR adds a 7-line guard at the top of on_stream_data in the HTTP/3 client (src/http/h3_client/callbacks.rs) that rejects DATA frames arriving before a final (non-1xx) response HEADERS with HTTP3ProtocolError, closing an unbounded-buffer-growth vector where a hostile server sends HEADERS(100) followed by an endless DATA stream. To test it, a debug-only server hook is added across three layers: on_h3_request in src/runtime/server/server_body.rs (#[cfg(bun_debug)]), a Rust FFI binding in src/uws_sys/h3.rs (#[cfg(bun_debug)]), and the C++ shim uws_h3_res_test_end_after_informational in src/uws_sys/libuwsockets_h3.cpp (#ifdef BUN_DEBUG). Two tests are added to fetch-http3-adversarial.test.ts: a debug-gated negative test for the malformed sequence, and an all-builds positive test that a legitimate 100-continue200 sequence delivers only the final response.

Security risks

My earlier review flagged that the original revision exposed the x-bun-test-100-then-data hook unconditionally in release builds, letting any remote client bypass the user's request handler. That has been fully addressed: the hook is now compiled out of release builds at every layer (Rust server entrypoint, Rust FFI binding, C++ shim), and the test is skipIf(!isDebug). In release builds the header is inert — it falls through to normal routing. I see no remaining security concern with the current revision; the client-side guard itself is a hardening change that strictly reduces attack surface.

Level of scrutiny

This is production-critical networking code: the HTTP/3 client's per-DATA-frame callback and the HTTP/3 server's per-request entrypoint. The core client guard is small, mirrors the existing HTTP/2 equivalent, and is well-justified by RFC 9114 §4.1 / RFC 9110 §15.2. But it sits in a hot path where ordering against deliver(), the receive-pause logic from #29831, and stream/session lifetime all interact, and the debug-only server hook directly manipulates Http3ResponseData state flags and the backpressure buffer. That is the kind of change a human reviewer with HTTP/3 / lsquic context (cirospaciari is suggested) should sign off on rather than a bot.

Other factors

The author addressed my prior feedback thoroughly — moved the hook out of the tracked packages/bun-uws fork, added positive coverage, documented fail-before output for both tests, and verified the existing h3 suites still pass. The PR also surfaces (but intentionally does not fix) a pre-existing server-side QPACK bug around expect: 100-continue on a fresh connection, which the positive test works around with a documented warm-up request; that workaround and the debug-gated test-hook design are both reasonable but worth a human confirming they're acceptable trade-offs.

@robobun

robobun commented Jun 26, 2026

Copy link
Copy Markdown
Collaborator Author

CI status (build 71934, latest, on 2db819f which is rebased onto main and has all review rounds applied): 283/286 passed. All three red lanes are main breaks unrelated to this PR and are each now owned by a dedicated fix session:

Test Lane Crash Overlap with this diff
test/js/node/test/parallel/test-worker-message-port-transfer-terminate.js x64-asan JSC getOwnPropertyDescriptor exception-scope assertion none (Worker/JSC)
test/js/bun/http/proxy-stress-matrix.test.ts darwin 14 aarch64 ConnectionRefused + 5 unmapped crashes none (proxy test is h1/h2 over TCP, does not use h3/quic)
test/js/sql/sql-prepare-false.test.ts darwin 14 x64 segfault in BundlerPlugin::NativePluginList::call / ThreadPool none (bundler/SQL)

git diff origin/main..HEAD --name-only has zero intersection with Worker, MessagePort, JSC exception handling, proxy, bundler, or SQL code; this diff is HTTP/3 client + lsquic + a debug-only server test hook. Main builds 71845-71852 are currently canceled with "build failed", so main is unstable at the moment.

Every HTTP/3 test file (fetch-http3-adversarial.test.ts, fetch-http3-client.test.ts, serve-http3.test.ts) passed on every lane that ran it, as on every previous build. The diff is green; ready to merge once main stabilizes.

@alii

alii commented Jun 29, 2026

Copy link
Copy Markdown
Member

@robobun bump — pause block confirmed intact after rebase (good). One spec fix before this can land: the guard does a graceful close (abort()qs.close() = FIN + STOP_SENDING(0)), indistinguishable from normal completion. RFC 9114 §4.1.2 requires a malformed message be a stream error of type H3_MESSAGE_ERROR (0x010E) — emit that via a reset (e.g. lsquic_stream_maybe_reset with 0x010E), not a clean close, mirroring the H2 sibling. Then assert the peer observes the reset code, not a FIN. Separately: the positive 1xx test masks an unfiled Bun.serve h3 cold-connection bug (0/30 cold vs 30/30 warm) via a load-bearing warmup — please file an issue and reference it in the test.

@robobun

robobun commented Jun 29, 2026

Copy link
Copy Markdown
Collaborator Author

Thanks, on it. Two things I ran into while digging:

1. lsquic_stream_maybe_reset alone won't do it for the common case. For a GET, the client has already FIN'd its send half (send_headers(end_stream=1)), so maybe_reset hits its STREAM_FIN_SENT|U_WRITE_DONE guard and degrades to stream_shutdown_readSTOP_SENDING(H3_NO_ERROR) (lsquic hardcodes that code). Wire looks identical to a clean close. Same reason the existing qs.reset() in ClientSession::detach() is dead on the fail() path today: abort() runs first and sets U_WRITE_DONE, which trips the same guard. So the RFC-correct shape for a client that already FIN'd its request is a STOP_SENDING(H3_MESSAGE_ERROR), and lsquic needs a small patch to carry an app error code onto STOP_SENDING at all.

2. The peer can't observe the code without a second small lsquic patch. lsquic_stream_rst_in / lsquic_stream_stop_sending_in take the error code but discard it before on_reset fires; there's no getter and no field it's stored in. So "assert the peer observes 0x010E" needs lsquic to store the received code + a getter.

Both are tiny (~15 lines, one new patches/lsquic/*.patch), so doing it properly:

  • lsquic: carry the caller's code onto STOP_SENDING for the write-half-already-closed branch; store the peer's incoming RESET/STOP_SENDING code + an accessor.
  • us_quic_stream_reset(s, code) gets an explicit code param (existing caller keeps 0x10C).
  • Client fail() on the protocol-error path emits the stream error with H3_MESSAGE_ERROR (0x010E) before anything FINs, mirroring h2_client's rst_stream(PROTOCOL_ERROR).
  • Test asserts the server observed 0x010E (via a debug-only bun:internal-for-testing hook), not a clean FIN.

Will also file the expect: 100-continue cold-connection bug and reference it from the positive test. Back shortly.

@robobun

robobun commented Jun 29, 2026

Copy link
Copy Markdown
Collaborator Author

Done in 1785c39. Both points addressed, plus the issue filed: #33082 is the 100-continue cold-connection bug, now referenced from the positive 1xx test's warmup comment.

The stream error. ClientSession::fail_malformed puts H3_MESSAGE_ERROR (0x010E) on the wire before anything FINs the send half, and is used at all three malformed-response sites (connection-specific field, missing :status, and DATA before the final HEADERS), not just the new one. It mirrors h2_client's rst_stream(PROTOCOL_ERROR).

Two things I found getting there that are worth your eyes:

  1. lsquic_stream_maybe_reset alone was not enough. Its STREAM_FIN_SENT|U_WRITE_DONE guard silently downgrades the reset to a codeless STOP_SENDING(NO_ERROR) once the send half is FIN'd, which every GET already is. And the existing qs.reset() in detach() was dead on the fail() path for the same reason (abort() runs first and sets U_WRITE_DONE). lsquic also hardcodes the STOP_SENDING error code to NO_ERROR and discards the peer's incoming RESET/STOP_SENDING code entirely, so neither emitting nor observing 0x010E was possible without a vendored patch. patches/lsquic/stream-error-code.patch (~15 lines) fills both: it carries the caller's code onto STOP_SENDING in the already-closed-write branch (first error wins, so detach()'s later H3_REQUEST_CANCELLED can't clobber it via lsquic's deferred frame generation), and it stores + exposes the peer's incoming code. Every internal lsquic caller passes 0, so nothing else changes.

  2. For the assertion to be deterministic, the debug test hook now leaves its stream open (no FIN, no markDone) and the test uses a never-FINing request body. Together they mean the only thing that can close the hook's stream is the client's error frame, so the hook's onAborted observer cannot race it. This is also what makes it a true RESET_STREAM(0x010E) rather than just a STOP_SENDING.

Fail-before is now exactly the property you asked for. With the wire reset removed (the pre-review behavior) and everything else in place, the server observes H3_REQUEST_CANCELLED:

error: expect(received).toBe(expected)
Expected: "10e"
Received: "10c"

Verified with BUN_DEBUG_lsquic=1: the client emits RESET_STREAM: stream 0; error code 270 and the server logs RX RST_STREAM frame: error code 270.

All of fetch-http3-adversarial.test.ts (29), fetch-http3-client.test.ts (52), and serve-http3.test.ts (45) pass locally.

@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: 2

🤖 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 `@packages/bun-usockets/src/quic.c`:
- Around line 1010-1016: Shorten the existing explanatory comment in the QUIC
stream error handling block so it fits the 3-line comment limit while preserving
the key rationale. Update the comment near the RESET_STREAM/STOP_SENDING
handling in quic.c to keep only the essential explanation: signal an HTTP/3
stream error instead of a clean FIN, and note that RESET_STREAM is used when
canceling the send, with STOP_SENDING carrying the code if the send half is
already closed.

In `@patches/lsquic/stream-error-code.patch`:
- Around line 70-77: The added explanatory comments in the lsquic stream error
code patch exceed the 3-line comment limit and need to be compressed. Shorten
the comment near the STOP_SENDING handling to a compact 3-line version that
still preserves the key invariant: the send half is already closed, only
STOP_SENDING can carry the caller’s error code, and the first error must win;
apply the same tightening to the matching comment block later in the patch as
well.
🪄 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: cb9a8a38-c275-42c9-bce2-119d3f43880e

📥 Commits

Reviewing files that changed from the base of the PR and between 175ce88 and 1785c39.

📒 Files selected for processing (4)
  • packages/bun-usockets/src/quic.c
  • packages/bun-usockets/src/quic.h
  • patches/lsquic/stream-error-code.patch
  • scripts/build/deps/lsquic.ts

Comment thread packages/bun-usockets/src/quic.c Outdated
Comment thread patches/lsquic/stream-error-code.patch Outdated
robobun added 5 commits July 11, 2026 14:25
RFC 9114 §4.1 requires the final (non-1xx) response HEADERS frame before
any DATA. lsquic's client-side frame filter only checks that some HEADERS
frame preceded DATA, so a server that sends HEADERS(:status 1xx) followed
by DATA reaches on_stream_data with status_code still 0. deliver() then
returns without draining body_buffer, letting a hostile peer grow it
without bound.

Reject the stream with HTTP3ProtocolError on the first such DATA byte,
matching the existing HTTP/2 client guard in h2_client/dispatch.rs.

A conformant h3 server cannot produce this sequence and there is no raw
QUIC framer in the test harness, so a server-side test hook
(x-bun-test-100-then-data in Http3Context.h) emits HEADERS(100) + DATA +
FIN with no final response. Without the client-side guard the fetch
rejects with HTTP3StreamReset only after buffering the DATA and retrying
once; with the guard it rejects with HTTP3ProtocolError on the first
byte.
The x-bun-test-100-then-data hook in Http3Context.h ran on every request
in release builds, letting any remote client bypass the application
handler and force an RFC 9114 §4.1-violating frame sequence. Compile it
(and Http3Response::endAfterInformational) out of release builds with
#ifdef BUN_DEBUG, matching the existing quic.c debug-logging gate, and
skip the corresponding test on non-debug builds.
…rim responses

The x-bun-test-100-then-data hook lived in the tracked uws fork
(Http3Context.h, Http3Response.h), where the next upstream sync would drop
it. The response primitive is now an exported entrypoint in Bun's own shim
(uws_sys/libuwsockets_h3.cpp, debug builds only) and the request-header
routing happens in on_h3_request on the Rust side, so nothing in
packages/bun-uws changes.

Also adds the positive counterpart to the DATA-after-1xx rejection test:
HEADERS(100) -> HEADERS(200) -> DATA -> FIN resolves with the final
response. It fails with status 100 if the 1xx skip in on_stream_headers is
removed.
…SAGE_ERROR

RFC 9114 §4.1.2 requires a malformed response be rejected with a stream
error of type H3_MESSAGE_ERROR (0x010E). The client previously tore down
the stream with abort()+close(), which is a graceful FIN + STOP_SENDING
that a peer cannot distinguish from normal completion. The existing
qs.reset() in ClientSession::detach() never corrected this: abort() runs
first and sets lsquic's STREAM_U_WRITE_DONE, which trips
lsquic_stream_maybe_reset's guard and turns the reset into a no-op.

Add ClientSession::fail_malformed, which puts the stream error on the
wire (before anything closes the send half) and then fails, and use it
at all three malformed-response sites: a connection-specific response
field, a missing :status, and DATA before the final response HEADERS.
Mirrors h2_client's rst_stream(PROTOCOL_ERROR).

lsquic cannot express this with its public API, so patches/lsquic/
stream-error-code.patch fills two gaps: the already-closed-write branch
of lsquic_stream_maybe_reset now records the caller's error code (first
error wins) so the STOP_SENDING frame carries it instead of NO_ERROR,
and the peer's incoming RESET_STREAM / STOP_SENDING code, which lsquic
parses and then drops, is stored and exposed via a new
lsquic_stream_peer_error_code() accessor. Every internal lsquic caller
passes 0, so existing behavior is unchanged.

us_quic_stream_reset now takes the application error code explicitly;
the one existing caller (detach) keeps H3_REQUEST_CANCELLED. An
RFC 9114 §8.1 ErrorCode newtype mirrors the H2 wire::ErrorCode.

The test asserts the peer observed 0x010E via a debug-only observer
installed by the x-bun-test-100-then-data hook, which now leaves the
stream open so only the client's stream error can close it (previously
the server's markDone() shut both halves down and raced the assertion).
Without the reset the observed code is H3_REQUEST_CANCELLED (0x010C):

  error: expect(received).toBe(expected)
  Expected: "10e"
  Received: "10c"

Also files the Bun.serve 100-continue cold-connection bug the positive
1xx test works around:

#33082
@robobun
robobun force-pushed the farm/7a4c40fe/h3-data-before-headers branch from 927cbe2 to f23d4ad Compare July 11, 2026 14:32
Comment thread src/uws_sys/libuwsockets_h3.cpp Outdated
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