Skip to content

Bun.serve HTTP/3: validate :path and :authority before building req.url - #37600

Open
robobun wants to merge 5 commits into
mainfrom
farm/988644f3/h3-request-url-authority
Open

robobun wants to merge 5 commits into
mainfrom
farm/988644f3/h3-request-url-authority

Conversation

@robobun

@robobun robobun commented Aug 11, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

Bun.serve({ http3: true }) built req.url as "https://" + :authority + :path without looking at either value (server_body.rs, the Ctx::IS_H3 block in prepare_js_request_context_for). The HTTP/1 path (Request::ensure_url) only uses a Host that passes is_valid_host_header, reduces an absolute-form target to its path, and runs the result through the URL parser. HTTP/3 clients got none of that.

Observed with a node:quic client against a server with routes: { "/declared": ... } plus a fetch fallback, on the current release:

:path /x                            :authority good.com@evil  -> 200, req.url = https://good.com@evil/x   (new URL().hostname is "evil")
:path /x                            :authority a/b            -> 200, req.url = https://a/b/x            (pathname is /b/x, router matched /x)
:path https://other-tenant/declared                           -> 200 from fetch(), req.url = https://other-tenant/declared
:path xdeclared                                               -> 200 from the /declared route, req.url = "xdeclared"
:path *                                                       -> 200, req.url = "*"

The same requests over HTTP/1 give req.url = /x for the first two and 400 for the last two, because HttpParser rejects request-targets that are neither origin-form nor absolute-form. HttpRouter assumes a leading / and strips the first byte unconditionally, which is why xdeclared reached the /declared route.

Fix

  • packages/bun-uws/src/Http3Context.h: a :path that does not start with / (absolute URL, *, empty, anything else) gets a 400 before routing. RFC 9114 4.3.1 defines :path as the path and query of the target URI (plus * for OPTIONS), so apart from asterisk-form these are malformed requests (4.1.2); this is the same layer where HTTP/1 rejects its equivalents. Side effects: OPTIONS * gets 400 over HTTP/3 as it already does over HTTP/1 (HttpParser only accepts origin-form, absolute-form and CONNECT; previously it reached the handler with req.url === "*"), and a request with no :path at all (CONNECT) now gets 400 instead of the router's 404.
  • src/runtime/webcore/Request.rs: the body of ensure_url moves into Request::set_url_from_request_target(host, target), and size_of_url shares the host selection through url_authority, which replaces the two debug_asserts that were keeping the two copies in sync. HTTP/1 behavior is unchanged.
  • src/runtime/server/server_body.rs: the HTTP/3 eager path calls the same function, so an :authority that cannot be a URL authority is left out of req.url (it becomes the bare path, exactly as HTTP/1 does) and a valid one is normalized the same way. This also drops the per-request Vec + core::fmt formatting that server: assemble H3 request URL directly into WTFStringImpl #31055 set out to remove; the shared code uses the stack buffer / direct latin1 paths.
  • src/uws_sys/h3.rs: Request::url and Request::header take &self (the C++ side only reads), so host and path can be borrowed at the same time instead of copying one of them.

#33721 also routes HTTP/3 through a shared helper as part of changing the HTTP/1 fallback to the server's configured authority; this PR keeps the current fallback and only closes the HTTP/3 gaps, including the :path one that #33721 does not cover.

Tests

test/js/bun/http/serve-http3.test.ts, new describe using a node:quic client so the pseudo-headers can carry arbitrary bytes (the native client always derives them from a parsed URL):

  • :authority of good.com@evil, a/b, a?b, a b: served with req.url === "/x", raw value still visible in req.headers.get("host")
  • valid :authority LOCALHOST:<port> with /x/./y/../z?q=1: req.url === "https://localhost:<port>/x/z?q=1"
  • :path of https://other-tenant/declared, xdeclared, *, "": 400, no handler invoked

All 9 fail on the unpatched build (verbatim URLs / 200s as in the table above) and pass with the fix. Also ran serve-http3.test.ts (57 pass), serve.test.ts (the 4 failures there are IPv6 / root-port environment failures that fail identically without this change), request-smuggling.test.ts, bun-serve-routes.test.ts, websocket-server.test.ts, serve-protocols.test.ts, fetch-http3-client.test.ts and fetch-http3-adversarial.test.ts. A 2048-request run with a 15 KB request-target on the debug build measured the same per-request time before and after the ensure_url refactor (about 23.2 ms vs 23.5 ms, within run-to-run noise).


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

fails on main (without fix)
ASAN without fix: 9 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 (d26419272)

test/js/bun/http/serve-http3.test.ts:
(node:99080) ExperimentalWarning: quic is an experimental feature and might change at any time
(Use `bun-debug --trace-warnings ...` to show where the warning was created)
(pass) Bun.serve HTTP/3 > basic GET [1785.78ms]
(pass) Bun.serve HTTP/3 > POST echoes body, status, request headers [1754.52ms]
(pass) Bun.serve HTTP/3 > 204 with no body [1717.66ms]
(pass) Bun.serve HTTP/3 > query string is preserved [1657.04ms]
(pass) Bun.serve HTTP/3 > large response body crosses multiple QUIC packets [1672.64ms]
(pass) Bun.serve HTTP/3 > concurrent requests across separate connections [1661.78ms]
(pass) Bun.serve HTTP/3 > client abort mid-response does not crash the server [1672.77ms]
(pass) Bun.serve HTTP/3 > http1: false rejects HTTP/1.1 but accepts HTTP/3 [1701.09ms]
(pass) Bun.serve HTTP/3 > http1: false — url/address/stop see the QUIC listener [2575.68ms]
(pass) Bun.serve HTTP/3 > maxRequestBodySize is enforced for H3 bodies without Co
... (truncated)

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

test/js/bun/http/serve-http3.test.ts:
(node:99759) ExperimentalWarning: quic is an experimental feature and might change at any time
(Use `bun --trace-warnings ...` to show where the warning was created)
(pass) Bun.serve HTTP/3 > basic GET [145.09ms]
(pass) Bun.serve HTTP/3 > POST echoes body, status, request headers [144.03ms]
(pass) Bun.serve HTTP/3 > 204 with no body [141.86ms]
(pass) Bun.serve HTTP/3 > query string is preserved [143.81ms]
(pass) Bun.serve HTTP/3 > large response body crosses multiple QUIC packets [142.95ms]
(pass) Bun.serve HTTP/3 > concurrent requests across separate connections [141.00ms]
(pass) Bun.serve HTTP/3 > client abort mid-response does not crash the server [141.06ms]
(pass) Bun.serve HTTP/3 > http1: false rejects HTTP/1.1 but accepts HTTP/3 [142.41ms]
(pass) Bun.serve HTTP/3 > http1: false — url/address/stop see the QUIC listener [1039.75ms]
(pass) Bun.serve HTTP/3 > maxRequestBodySize is enforced for H3 bodies without Content-Length [137.10ms]
(pass) Bun.serve HTTP/3 > unknown route returns 404 [144.57ms]
(pass) Bun.serve HTTP/3 > routes: handler with :params [143.39ms]
(pass) Bun.serve HTTP/3 
... (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 (d26419272)

test/js/bun/http/serve-http3.test.ts:
(node:105382) ExperimentalWarning: quic is an experimental feature and might change at any time
(Use `bun-debug --trace-warnings ...` to show where the warning was created)
(pass) Bun.serve HTTP/3 > basic GET [1636.20ms]
(pass) Bun.serve HTTP/3 > POST echoes body, status, request headers [1570.56ms]
(pass) Bun.serve HTTP/3 > 204 with no body [1563.21ms]
(pass) Bun.serve HTTP/3 > query string is preserved [1585.81ms]
(pass) Bun.serve HTTP/3 > large response body crosses multiple QUIC packets [1563.22ms]
(pass) Bun.serve HTTP/3 > concurrent requests across separate connections [1596.68ms]
(pass) Bun.serve HTTP/3 > client abort mid-response does not crash the server [1575.59ms]
(pass) Bun.serve HTTP/3 > http1: false rejects HTTP/1.1 but accepts HTTP/3 [1599.74ms]
(pass) Bun.serve HTTP/3 > http1: false — url/address/stop see the QUIC listener [2493.49ms]
(pass) Bun.serve HTTP/3 > maxRequestBodySize is enforced for H3 bodies without C
... (truncated)

release with fix: 1 skipped
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 937ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/7] gen generated_host_exports.rs
generated_host_exports.rs: 93 exports (host=3, lazy=10, generic=80, rust=0); 239 extern-C blocks audited
[1/7] cargo bun_bin → libbun_rust.a (--target x86_64-unknown-linux-gnu)

  nightly-2026-07-20-x86_64-unknown-linux-gnu unchanged - rustc 1.99.0-nightly (9f36de775 2026-07-19)

�[1m�[92m   Compiling�[0m bun_core v0.0.0 (/workspace/bun/src/bun_core)
�[1m�[92m   Compiling�[0m bun_errno v0.0.0 (/workspace/bun/src/errno)
�[1m�[92m   Compiling�[0m bun_ptr v0.0.0 (/workspace/bun/src/ptr)
�[1m�[92m   Compiling�[0m bun_boringssl_sys v0.0.0 (/workspace/bun/src/boringssl_sys)
�[1m�[92m   Compiling�[0m bun_safety v0.0.0 (/workspace/bun/src/safety)
�[1m�[92m   Compiling�[0m bun_zlib_sys v0.0.0 (/workspace/bun/src/zlib_sys)
�[1m�[92m   Compiling�[0m bun_cares_sys v0.0.0 (/workspace/bun/src/cares_sys)
�[1m�[92m   Compiling�[0m bun_zstd v0.0.0 (/workspace/bun/src/zstd)
�[1m�[92m   Compiling�[0m bun_picohttp v0.0.0 (/workspace/bun/src/picohttp)
�[1m�[92m   Compiling�[0m bun_brotli v
... (truncated)
diff hotspot
packages/bun-uws/src/Http3Context.h  |   9 ++
 src/runtime/server/server_body.rs    |  40 ++-------
 src/runtime/webcore/Request.rs       | 161 ++++++++++++++++-------------------
 src/uws_sys/h3.rs                    |   8 +-
 test/js/bun/http/serve-http3.test.ts | 105 +++++++++++++++++++++++
 5 files changed, 199 insertions(+), 124 deletions(-)

gate history · 2 passed · 0 rejected · iteration 0

evidence per changed file
file                                  reads  edits  tests
packages/bun-uws/src/Http3Context.h       3      4      0
src/runtime/server/server_body.rs         4      4      0
src/runtime/webcore/Request.rs            8      8      0
src/uws_sys/h3.rs                         2      3      0
test/js/bun/http/serve-http3.test.ts      8      3      0

Over HTTP/3, req.url was assembled as "https://" + :authority + :path with
no checks on either value, while the HTTP/1 path (Request::ensure_url) only
uses a Host value that passes is_valid_host_header, reduces absolute-form
targets to their path, and normalizes the result through the URL parser.
So an H3 client could send ":authority: good.com@evil" or "a/b" and get a
req.url whose host or pathname disagrees with what the router matched on,
and a :path that was not origin-form (an absolute URL, "*", "xfoo") was
routed on whatever followed its first byte and handed to the handler
verbatim as req.url.

Http3Context now answers 400 to any :path that does not start with "/"
before routing (RFC 9114 4.3.1 / 4.1.2), which is the layer where
HttpParser rejects the HTTP/1 equivalents. The HTTP/1 synthesis is moved
out of ensure_url into Request::set_url_from_request_target and the eager
HTTP/3 path calls it too, so both transports apply the same authority
filter and normalization. The h3 request accessors for url/header take
&self so host and path can be borrowed together without copying.
@coderabbitai

coderabbitai Bot commented Aug 11, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

HTTP/3 request handling now validates request targets before routing. Shared URL construction centralizes authority and path handling. URL and header access use immutable request references. New raw QUIC tests cover valid and invalid targets.

Changes

HTTP/3 URL validation

Layer / File(s) Summary
Shared request-target URL synthesis
src/runtime/webcore/Request.rs, src/runtime/server/server_body.rs
Centralizes authority validation and request-target URL construction. Server initialization now uses set_url_from_request_target.
Immutable request access
src/runtime/server/server_body.rs, src/uws_sys/h3.rs
URL and header access now use immutable request references for HTTP/1 and HTTP/3. Method access remains mutable.
HTTP/3 target validation and coverage
packages/bun-uws/src/Http3Context.h, test/js/bun/http/serve-http3.test.ts
Rejects empty and non-origin-form targets with 400 Bad Request before routing. Tests cover authorities, URL normalization, and malformed targets.

Suggested reviewers: jarred-sumner, cirospaciari, dylan-conway

🚥 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 and concisely describes HTTP/3 :path and :authority validation before req.url construction.
Description check ✅ Passed The description explains the problem, fix, affected behavior, and verification results, covering both required template topics.

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

@robobun

robobun commented Aug 11, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status: reproduced on the current release with a node:quic client sending hand-built :path / :authority values to Bun.serve({ http3: true }) (table in the description). Fix and tests are in this PR: of the 10 new cases in test/js/bun/http/serve-http3.test.ts, 9 fail without the src/ + packages/ changes and pass with them; the tenth (no :authority at all) locks down behavior that was already correct. All review threads are resolved. CI is green on d264192 (https://buildkite.com/bun/bun/builds/92452, 190/190 jobs). Ready for a maintainer.

@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 nit, I also checked that the ensure_url → set_url_from_request_target extraction is a mechanical move (HTTP/1 path unchanged, the two dropped debug_asserts are replaced by the shared url_authority helper), and that the ReqLike::{url,header} signature change to &self matches the existing H1 uws_sys::Request signatures. Given this is security-sensitive input validation on the per-request hot path, a human look is still worthwhile.

Extended reasoning...

This PR closes an HTTP/3 authority/path injection gap and refactors the URL-synthesis hot path. The only finding is a comment-wording nit (RFC 9114 §4.3.1 does permit :path = * for OPTIONS; the rejection is fine for HttpParser parity but the comment overstates the spec). I verified the Request.rs refactor is a byte-for-byte extraction of the old ensure_url body, that size_of_url now shares the same authority selection instead of duplicating it, and that the &mut self → &self change on h3::Request::{url,header} brings it in line with the H1 uws_sys::Request which already used &self. Not approving because this touches security-sensitive validation of hostile network input and the Bun.serve per-request path.

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

robobun commented Aug 11, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 2:39 PM PT - Aug 11th, 2026

✅ @robobun, your commit d26419272f4a237bd5887ccff2e7d0b2d3196122 passed in Build #92452! 🎉


🧪   To try this PR locally:

bunx bun-pr 37600

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

bun-37600 --bun

Comment thread src/runtime/server/server_body.rs Outdated
Comment thread src/runtime/webcore/Request.rs Outdated
Comment thread src/runtime/webcore/Request.rs Outdated
Comment thread src/runtime/webcore/Request.rs Outdated
Comment thread src/runtime/webcore/Request.rs Outdated
Comment thread src/runtime/webcore/Request.rs Outdated
Comment thread src/runtime/webcore/Request.rs Outdated

@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 1360-1382: Add a test case alongside the existing HTTP/3 authority
cases that omits :authority while sending a non-CONNECT path, then assert
req.url remains the bare request target and verify the observed host value after
confirming whether uWS exposes it as empty or omitted. Reuse serveRecordingHits,
rawH3, and the existing response/hits assertions.
🪄 Autofix

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: 5f18231a-fbfb-4d0b-8406-afa8cb27a506

📥 Commits

Reviewing files that changed from the base of the PR and between 97e21e5 and 8f2bb24.

📒 Files selected for processing (5)
  • packages/bun-uws/src/Http3Context.h
  • src/runtime/server/server_body.rs
  • src/runtime/webcore/Request.rs
  • src/uws_sys/h3.rs
  • 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.

I reviewed this PR and didn't find any bugs; my earlier note on the RFC 9114 §4.3.1 comment was addressed in 3c49b7b, and the comment-cop / CodeRabbit threads are resolved. Because it changes request-target validation on the network boundary, refactors the per-request ensure_url hot path, and overlaps with #33721's approach to the same helper, a human sign-off would still be worthwhile.

What was reviewed:

  • set_url_from_request_target is a straight extract of the old ensure_url body; size_of_url and the new function now share host selection via url_authority, so the removed debug_asserts no longer guard anything.
  • &mut self → &self on h3::Request::{url,header} is sound — the C shims only read; the HTTP/1 uws_sys::Request methods were already &self, so the ReqLike trait relaxation just aligns with them.
  • get_protocol() returns https:// on the H3 path because Request::init is called with SSL (always true for the H3 context), confirmed by the normalization test.
  • The 10 new node:quic tests cover invalid :authority, absent :authority, valid-and-normalized, and the four rejected :path shapes; the hits array proves no handler fires on 400.
Extended reasoning...

Overview

Five files: a 9-line guard in Http3Context.h that 400s any :path not starting with / before routing; Request.rs extracts the body of ensure_url into set_url_from_request_target(host, target) and factors the host-selection predicate into url_authority (shared with size_of_url); server_body.rs replaces the H3-only Vec+write! URL assembly with a call to that shared function and relaxes the ReqLike trait so header and url can be borrowed together; uws_sys/h3.rs changes url/header (and their extern decls) to &self/*const; and serve-http3.test.ts gains a 10-case describe driving raw pseudo-headers through node:quic.

Security risks

This is itself a security fix — before it, an HTTP/3 client could put @, /, or ? in :authority and have new URL(req.url).hostname disagree with the router's view, or put a non-/ :path and have HttpRouter strip the first byte and route on the remainder. The fix reuses the exact is_valid_host_header byte-set and request_target_path that HTTP/1 already applies, and the C++ guard mirrors what HttpParser already enforces, so the attack surface converges rather than diverges. I did not find a way for the new code to accept something the old HTTP/1 path rejects.

Level of scrutiny

High — this is untrusted-input validation on the network boundary and a refactor of code that runs once per request. Two things push it toward a human look: (1) the intentional behaviour change that OPTIONS * and CONNECT over H3 now get 400 (documented, matches HTTP/1, but still a user-visible change), and (2) the PR description flags an overlap with #33721, which takes a different approach to the same shared helper (it also changes the HTTP/1 fallback authority). Which of the two lands, or how they reconcile, is a maintainer call.

Other factors

The refactor itself is mechanical: I diffed the old ensure_url body against set_url_from_request_target and the only non-whitespace changes are (a) the two debug_assert!(self.size_of_url() == …) lines dropped — now redundant because both sides go through url_authority — and (b) the // TODO: what is the right thing to do for invalid URLS? comments dropped alongside unchanged behaviour. The &mut → & FFI relaxation is safe because uws_h3_req_get_url/get_header only write to the out-param and read from request-owned storage; the HTTP/1 uws_sys::Request::{url,header} were already &self. Test coverage is strong: 9 of 10 new cases fail-before/pass-after per the PR's evidence block, the hits array asserts the negative (no handler ran on 400), and the surrounding suites the author lists as re-run cover the HTTP/1 ensure_url path that was refactored.

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