Skip to content

fetch: honor per-request TLS options on the HTTP/3 client - #31420

Open
Jarred-Sumner wants to merge 3 commits into
mainfrom
claude/h3-client-tls-options
Open

Jarred-Sumner wants to merge 3 commits into
mainfrom
claude/h3-client-tls-options

Conversation

@Jarred-Sumner

Copy link
Copy Markdown
Collaborator

What

The experimental HTTP/3 client (protocol: "http3" / BUN_FEATURE_FLAG_EXPERIMENTAL_HTTP3_CLIENT) ignored the request's tls options. Every QUIC connection verified the server certificate against the default trust store, so fetch(url, { protocol: "http3", tls: { ca } }) failed with HTTP3HandshakeFailed even when the CA was correct, and client certificates (cert/key) were never presented — unlike the same options over the TCP path.

This makes the HTTP/3 client honor ca, cert/key, and serverName, mirroring the TCP client's one-SSL-context-per-interned-SSLConfig design:

  • quic.c: factor the engine-construction tail of us_create_quic_client_context into a shared static helper and add us_create_quic_client_context_with_options, which builds the SSL_CTX from us_bun_socket_context_options_t via the existing us_ssl_ctx_build_raw (the same primitive the QUIC server context and the TCP client use — Node ca semantics, exclusive trust store, client cert/key loading) before applying the existing TLS 1.3 + custom-verify setup. The default client context construction is unchanged.
  • ClientContext: keep a registry of per-interned-SSLConfig quic contexts, gated on requires_custom_request_ctx exactly like HTTPThread::connect, so tls: { rejectUnauthorized: false }-only requests keep sharing the default context. tls.serverName is used as the SNI / verification name when set.
  • ClientSession: store the config and include it in matches(), so sessions with different TLS configs are never coalesced — same keying as the TCP keep-alive pool (HTTPContext) and the h2 session pool.

checkServerIdentity remains unsupported on h3 (the existing CertErrors guards in lib.rs are unchanged). Default (flag-off) behavior is untouched: the new C function is additive and the new Rust code is only reachable from the h3 connect path.

Why

Custom-CA and mTLS deployments could not use the HTTP/3 client at all; the options were silently ignored, which also meant the only workaround was rejectUnauthorized: false.

Verification

  • bun bd test test/js/web/fetch/fetch-http3-client.test.ts — 54/54 pass, including new tests:
    • tls.ca with the harness self-signed cert succeeds over forced http3 (fails on current Bun with HTTP3HandshakeFailed)
    • a wrong tls.ca is rejected
    • sessions with different tls configs are not coalesced
    • mTLS: a server with requestCert: true, rejectUnauthorized: true, ca accepts a fetch presenting cert/key and rejects one without
    • Alt-Svc upgrade path honors tls.ca (subprocess fixture with the experimental flag)
  • test/js/web/fetch/fetch-http3-adversarial.test.ts: identical results before/after this change on the same machine.
  • After the change, the correct-CA / wrong-CA outcomes over h3 match the TCP path's outcomes for the same options (succeed / DEPTH_ZERO_SELF_SIGNED_CERT-style failure). Node comparison is not applicable (undici has no per-request tls option and no h3 client); the TCP fetch path is the reference behavior.
  • cargo check clean for the touched crates on linux-x64, windows-x64 (msvc), and darwin-aarch64; clippy clean.

Notes

  • Each distinct interned SSLConfig gets its own lsquic engine + lazily-created UDP endpoint, kept for the process lifetime (the existing default client context is also never freed). Configs are interned and typically few; eviction can be added later if needed.
  • An invalid ca/cert currently maps to ConnectionRefused on the h3 path (TCP maps it to FailedToOpenSocket); can be refined later by threading the error out of the new C constructor.

The HTTP/3 client previously ignored the request's tls options (ca, cert,
key, serverName): every connection verified against the default trust store,
so fetch with a custom CA failed the handshake and client certificates were
never presented, unlike the TCP path.

Mirror the TCP client's one-SSL-context-per-interned-SSLConfig design:

- quic.c: factor the engine construction tail of us_create_quic_client_context
  into a shared helper and add us_create_quic_client_context_with_options,
  which builds the SSL_CTX from us_bun_socket_context_options_t via
  us_ssl_ctx_build_raw (same primitive the QUIC server context and the TCP
  client use) before installing the existing custom verify callback.
- ClientContext keeps a per-interned-SSLConfig quic context registry (only for
  configs with requires_custom_request_ctx, matching HTTPThread's gate) and
  uses tls.serverName as the SNI when set.
- ClientSession stores the config and includes it in matches(), so sessions
  with different TLS configs are never coalesced (same keying as the TCP
  keep-alive pool and the h2 session pool).

rejectUnauthorized-only requests keep using the default context, and the
default client context construction is unchanged.
@coderabbitai

coderabbitai Bot commented May 26, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

This PR enables per-request TLS configuration for HTTP/3 QUIC clients. It introduces a C FFI layer for options-based context creation, implements a Rust-level per-SSLConfig context cache with lazy initialization and centralized unsafe access, integrates context selection into the connection flow with TLS-aware SNI extraction, extends session matching to respect TLS config boundaries, updates HTTP/3 eligibility gating, and provides comprehensive test coverage.

Changes

Per-Request TLS for HTTP/3 QUIC

Layer / File(s) Summary
C FFI for Options-Based QUIC Context Creation
packages/bun-usockets/src/quic.c, packages/bun-usockets/src/quic.h, src/uws_sys/quic/Context.rs
Introduces us_create_quic_client_context_with_ssl_ctx internal helper, refactors existing constructors to share this helper, and adds us_create_quic_client_context_with_options to build SSL_CTX from structured options. Rust FFI bindings import and wrap the new options-based constructor.
Context Cache Storage and Unsafe Accessor Centralization
src/http/h3_client/ClientContext.rs
Adds custom_contexts vector to cache per-SSLConfig QUIC contexts, introduces ctx_mut helper to centralize invariant-backed mutable access, and initializes the cache during ClientContext creation.
Lazy Per-Config Context Creation and Registration
src/http/h3_client/ClientContext.rs
Implements context_for method that lazily creates, configures, and caches additional QUIC client contexts based on SSLConfig pointer identity, returning either a custom context or the default context.
Connect Flow with TLS-Aware Context Selection and SNI Extraction
src/http/h3_client/ClientContext.rs
Integrates context selection into connect by filtering TLS props, resolving the appropriate context, gating on availability, extracting SNI from TLS config, constructing ClientSession with TLS props, and routing lsquic connect through the selected context with SNI and TLS parameters.
Session TLS Configuration Tracking and Matching
src/http/h3_client/ClientSession.rs
Adds tls_props field to ClientSession, updates new constructor to accept and store TLS config, and extends matches to include TLS pointer-identity equality checks, preventing session coalescing across different TLS configurations.
HTTP/3 Alt-Svc Eligibility Gating Update
src/http/lib.rs
Changes has_tls_options_unsupported_by_h3() to return whether signals::Field::CertErrors is set instead of checking requires_custom_request_ctx, enabling HTTP/3 Alt-Svc upgrades with per-request TLS options.
Comprehensive Test Coverage for Per-Request TLS and Alt-Svc Upgrades
test/js/web/fetch/fetch-http3-client.test.ts
Adds tests for custom tls.ca in forced HTTP/3 (success/rejection/non-coalescing), mTLS-over-HTTP/3, and Alt-Svc upgrade path with custom CA preservation. Parameterizes subprocess fixture to accept dynamic TLS options.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately and concisely captures the main change: making the HTTP/3 client honor per-request TLS options, which is the core focus across all modified files.
Description check ✅ Passed The description fully addresses both required template sections with comprehensive detail: 'What does this PR do?' covers all changes and design rationale, and 'How did you verify your code works?' provides thorough verification steps and test results.
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.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

Warning

Review ran into problems

🔥 Problems

Stopped waiting for pipeline failures after 30000ms. One of your pipelines takes longer than our 30000ms fetch window to run, so review may not consider pipeline-failure results for inline comments if any failures occurred after the fetch window. Increase the timeout if you want to wait longer or run a @coderabbit review after the pipeline has finished.


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

@robobun

robobun commented May 26, 2026 •

Copy link
Copy Markdown
Collaborator
Updated 1:25 PM PT - May 27th, 2026

✅ @Jarred-Sumner, your commit 2faf71662bbf3f3b9df088547e02b0c4822bc8cc passed in Build #58405! 🎉


🧪   To try this PR locally:

bunx bun-pr 31420

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

bun-31420 --bun

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

I didn't find any bugs, but this adds new SSL_CTX construction and per-config session keying in the TLS verification path for the HTTP/3 client — security-sensitive enough that a human should sign off.

Extended reasoning...

Overview

This PR makes the experimental HTTP/3 fetch client honor per-request tls options (ca, cert/key, serverName). It touches six files: quic.c/quic.h (new us_create_quic_client_context_with_options that builds an SSL_CTX from us_bun_socket_context_options_t via the existing us_ssl_ctx_build_raw, with the engine-construction tail factored into a shared static helper), src/uws_sys/quic/Context.rs (FFI binding for the new constructor), src/http/h3_client/ClientContext.rs (a custom_contexts registry keyed by interned SSLConfig pointer, plus tls.serverName → SNI), src/http/h3_client/ClientSession.rs (store tls_props and include it in matches() so sessions with different TLS configs are not coalesced), and new tests covering tls.ca, wrong-CA rejection, mTLS, non-coalescing, and the Alt-Svc upgrade path.

Security risks

This is squarely in TLS-verification territory. The change reuses the existing us_quic_client_verify callback and the same us_ssl_ctx_build_raw primitive the QUIC server and TCP client already use, and the requires_custom_request_ctx gate mirrors HTTPThread::connect. Session coalescing now keys on the interned-config pointer (via SSLConfig::raw_ptr), which is the same identity scheme the TCP keep-alive pool uses. I did not spot a way for a request without a custom CA to piggyback on a custom-CA session (the matches() pointer-equality check covers that, and there is a test for it). Still, mistakes here affect certificate validation, so the cost of a subtle bug is high.

Level of scrutiny

Medium-high. The feature is behind an experimental flag and the default code path (us_create_quic_client_context) is byte-for-byte unchanged after the refactor, so blast radius for users who have not opted in is small. But the change is not mechanical: it adds a new C entry point, new unsafe FFI in Rust, a process-lifetime registry of lsquic engines (one per distinct config, never freed — called out in the PR notes), and changes session-reuse keying. Those are real design decisions a maintainer should glance at.

Other factors

No CODEOWNERS cover these paths. Test coverage for the new behavior is good (positive ca, wrong ca, mTLS accept/reject, non-coalescing, Alt-Svc subprocess). The bug-hunting system found nothing. There are no prior human review comments to address. Given the security-sensitive nature plus the moderate size, I am deferring rather than approving.

@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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
test/js/web/fetch/fetch-http3-client.test.ts (1)

712-719: 🛠️ Refactor suggestion | 🟠 Major | ⚡ Quick win

Use -e for this single-file subprocess fixture.

This helper only executes one generated script, so the temp directory and cwd setup are unnecessary here.

♻️ Suggested simplification
-import { bunEnv, bunExe, expiredTls, tempDir, tls } from "harness";
+import { bunEnv, bunExe, expiredTls, tls } from "harness";
...
 async function run(extra: { env?: Record<string, string>; args?: string[]; opts?: unknown }) {
-  using dir = tempDir("h3-altsvc", {
-    "fixture.ts": fixture(extra.opts ?? { tls: { rejectUnauthorized: false } }),
-  });
   await using proc = Bun.spawn({
-    cmd: [bunExe(), ...(extra.args ?? []), "fixture.ts"],
+    cmd: [bunExe(), ...(extra.args ?? []), "-e", fixture(extra.opts ?? { tls: { rejectUnauthorized: false } })],
     env: { ...bunEnv, ...extra.env },
-    cwd: String(dir),
     stderr: "pipe",
   });

As per coding guidelines, "For single-file tests, prefer -e over tempDir" and "Use -e flag with bunExe() for single-file tests; for multi-file tests, create a temporary directory using tempDir from harness`.

🤖 Prompt for 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.

In `@test/js/web/fetch/fetch-http3-client.test.ts` around lines 712 - 719, The
test helper function run currently creates a tempDir with "fixture.ts" and
launches it via Bun.spawn using cwd, which is unnecessary for a single-file
fixture; instead, remove the tempDir and cwd usage and invoke bunExe() with the
"-e" flag to evaluate the generated script text directly (update call sites that
build the script via fixture(extra.opts...) to pass the script string to
Bun.spawn via cmd: [bunExe(), "-e", script] and preserve env merging and args
handling), and drop tempDir(...) and cwd: String(dir) references so run uses
direct "-e" execution for single-file subprocesses.
🤖 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 `@src/http/h3_client/ClientContext.rs`:
- Around line 211-245: The code caches a quic::Context per interned SSLConfig in
context_for using custom_contexts and never frees them, leaking FDs and QUIC
loop registrations; fix by making custom_contexts bounded or reference-counted
and teardown contexts when unused: in context_for (where you push
(config.clone(), ctx)) change the entry to include a usage count or use an
LRU/bounded map keyed by cfg_ptr, increment the count when a ClientSession is
created for that config and decrement on session drop (hook into ClientSession
creation/destructor), and when evicting or refcount reaches zero call the QUIC
teardown API and undo the registration (e.g., invoke the loop unregister
equivalent to callbacks::register(Self::ctx_mut(ctx)) and free the quic::Context
via the library’s context deletion function) before removing the entry from
custom_contexts; ensure qctx_mut(), ctx_mut(), and context_for logic use the
same keys so lookups remain correct.

---

Outside diff comments:
In `@test/js/web/fetch/fetch-http3-client.test.ts`:
- Around line 712-719: The test helper function run currently creates a tempDir
with "fixture.ts" and launches it via Bun.spawn using cwd, which is unnecessary
for a single-file fixture; instead, remove the tempDir and cwd usage and invoke
bunExe() with the "-e" flag to evaluate the generated script text directly
(update call sites that build the script via fixture(extra.opts...) to pass the
script string to Bun.spawn via cmd: [bunExe(), "-e", script] and preserve env
merging and args handling), and drop tempDir(...) and cwd: String(dir)
references so run uses direct "-e" execution for single-file subprocesses.
🪄 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: 956f3231-612e-43af-b4d1-dbadc077963f

📥 Commits

Reviewing files that changed from the base of the PR and between 13bbb38 and 2faf716.

📒 Files selected for processing (7)
  • packages/bun-usockets/src/quic.c
  • packages/bun-usockets/src/quic.h
  • src/http/h3_client/ClientContext.rs
  • src/http/h3_client/ClientSession.rs
  • src/http/lib.rs
  • src/uws_sys/quic/Context.rs
  • test/js/web/fetch/fetch-http3-client.test.ts
💤 Files with no reviewable changes (1)
  • src/http/lib.rs

Comment thread src/http/h3_client/ClientContext.rs

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

I didn't find any bugs, but this changes TLS trust-store / cert-verification plumbing and session coalescing for the HTTP/3 client across C and unsafe Rust FFI, so it's worth a human look.

Extended reasoning...

Overview

This PR makes the experimental HTTP/3 fetch client honor per-request tls options (ca, cert/key, serverName). It refactors us_create_quic_client_context in quic.c to share an engine-construction tail and adds us_create_quic_client_context_with_options (building the SSL_CTX via the existing us_ssl_ctx_build_raw), exposes it through new Rust FFI in uws_sys/quic/Context.rs, and wires it into ClientContext (a per-interned-SSLConfig registry of process-lifetime quic contexts) and ClientSession (storing the config and including it in matches() so differently-configured sessions are never coalesced). lib.rs drops the requires_custom_request_ctx guard so these requests now reach the h3 path. Tests cover custom CA accept/reject, mTLS, session non-coalescing, and the Alt-Svc upgrade path.

Security risks

This is squarely security-sensitive: it controls which trust store and client certificate are used for QUIC TLS handshakes, and which existing pooled session a request is allowed to reuse. A mistake in matches() keying or in the requires_custom_request_ctx gate could let a request with a custom CA piggyback on a default-trust (or rejectUnauthorized:false) session, or vice-versa. The new C path replaces the default Mozilla/system root store with whatever us_ssl_ctx_build_raw derives from user options and reinstalls the same us_quic_client_verify custom-verify callback — the design mirrors the TCP path and looks correct, but the consequences of a subtle bug here are MITM-class.

Level of scrutiny

High. Even though the feature is behind BUN_FEATURE_FLAG_EXPERIMENTAL_HTTP3_CLIENT / protocol: "http3", it touches BoringSSL SSL_CTX construction, X.509 verification flow, SNI selection, and session-pool keying, plus new unsafe Rust FFI with raw-pointer lifetime invariants (ctx_mut, context_for) and process-lifetime leaked engines/UDP endpoints per distinct SSLConfig.

Other factors

The implementation is well-structured, closely mirrors the existing TCP-path design (interned SSLConfig → dedicated SSL context, same requires_custom_request_ctx gate), and ships solid test coverage (54/54 passing per the description, including negative cases and non-coalescing). The bug-hunting system found no issues. Still, given the TLS-trust surface and cross-language unsafe code, this is exactly the kind of change a human reviewer should sign off on rather than a bot.

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

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants