Skip to content

Bun.serve: use the absolute-form request-target's authority for request.url - #33002

Open
robobun wants to merge 2 commits into
mainfrom
farm/df019b6c/serve-absolute-form-url
Open

Bun.serve: use the absolute-form request-target's authority for request.url#33002
robobun wants to merge 2 commits into
mainfrom
farm/df019b6c/serve-absolute-form-url

Conversation

@robobun

@robobun robobun commented Jun 28, 2026

Copy link
Copy Markdown
Collaborator

What

For an HTTP/1.1 request whose request-target is in absolute-form, Bun.serve replaced the target's authority with the Host header when building request.url. The real target was unrecoverable from the handler.

GET http://target-b/abs?x=1 HTTP/1.1
Host: host-a
server url seen by the handler
Bun.serve today (1.4.0 and main) http://host-a/abs?x=1
Bun.serve with this PR http://target-b/abs?x=1
Node http.createServer (req.url) http://target-b/abs?x=1
Bun's node:http server (req.url) http://target-b/abs?x=1

Why

RFC 9112, section 3.2.2:

When an origin server receives a request with an absolute-form of request-target, the origin server MUST ignore the received Host header field (if any) and instead use the host information of the request-target.

The same rule is in RFC 7230 (section 5.5) and RFC 2616 (section 5.2: "Any Host header field value in the request MUST be ignored"). Other implementations follow it: Node puts the raw target in req.url (verified against v26), Go's net/http takes req.Host from the request line when it is absolute, and nginx's $host prefers the "host name from the request line".

Absolute-form targets are what proxies emit, and a target/Host disagreement is the ingredient of host-confusion chains (cache poisoning, vhost/origin-check bypass). Because every conforming component resolves that disagreement in favor of the request-target, Bun resolving it in favor of Host is what creates the split, and the handler cannot even detect it today since the target's authority is gone.

Relation to #31495

#31495 changed two things for absolute-form targets: route them by path (before that they never matched routes), and derive request.url from Host. This PR keeps the routing fix and reverts only the authority choice, which is the part RFC 9112 forbids. Before #31495 request.url was already the raw request-target for absolute-form, so this restores that behavior, now normalized through the same URL serializer the origin-form path uses.

A handler that wants to reject a target/Host mismatch still can: request.headers.get("host") is unchanged.

Implementation

Request::ensure_url / size_of_url (src/runtime/webcore/Request.rs) no longer strip the scheme and authority off the target before prefixing Host; origin-form targets (starting with /) keep the protocol + Host + path construction, anything else is used as-is, and absolute-form targets are additionally normalized via href_from_string. request_target_path had no other callers and is deleted. Routing (getUrlForRouting in uWS) is untouched.

Tests

Updated the absolute-form test #31495 added in test/js/bun/http/bun-serve-routes.test.ts to assert the RFC behavior: the target's authority (and scheme) survive into request.url, the Host header stays visible, path routing still matches, and origin-form plus query-only absolute-form targets are covered.

GET https://target.example/admin/secret HTTP/1.1
Host: 127.0.0.1:<port>

now yields request.url === "https://target.example/admin/secret" (was http://127.0.0.1:<port>/admin/secret). The updated test fails on main and passes with this change; serve.test.ts, bun-serve-routes.test.ts, and hspec.test.ts pass locally.

RFC 9112 section 3.2.2: when the request-target is in absolute-form, an
origin server must use the request-target's authority and ignore the
Host field. Bun.serve did the opposite: it stripped the target's scheme
and authority and rebuilt request.url from the Host header, so the real
target was unrecoverable by the handler.

request.url is now the request-target itself (normalized) when the
target is absolute-form. Origin-form requests are unchanged, routing
still matches absolute-form targets by path, and the Host header stays
visible via request.headers.

request_target_path() had no other callers and is removed.
@coderabbitai

coderabbitai Bot commented Jun 28, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

@robobun, we couldn't start this review because you've reached your PR review rate limit.

More reviews will be available in 7 seconds. Learn how PR review limits work.

Your organization has used up its prepaid credits, and credit purchases are no longer available. Enable the review add-on in the billing tab to keep reviews running — you're only billed for reviews past your plan's rate limits ($0.25/file).

⌛ How to resolve this issue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based credits.

🚦 How do rate limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please see our Fair Usage Limits Policy for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: cd1179d7-b22f-4612-871e-b77c111e6bdf

📥 Commits

Reviewing files that changed from the base of the PR and between a1c39de and 0c601c0.

📒 Files selected for processing (2)
  • src/runtime/webcore/Request.rs
  • test/js/bun/http/bun-serve-routes.test.ts

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

@robobun

robobun commented Jun 28, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 6:06 AM PT - Jun 28th, 2026

@robobun, your commit 0c601c0 has some failures in Build #66298 (All Failures)


🧪   To try this PR locally:

bunx bun-pr 33002

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

bun-33002 --bun

@robobun

robobun commented Jun 28, 2026

Copy link
Copy Markdown
Collaborator Author

Status: reproduced on 1.4.0 and on main (18e6d8d). A raw GET http://target-b/abs?x=1 HTTP/1.1 with Host: host-a against Bun.serve gives request.url === "http://host-a/abs?x=1"; with this change the handler sees http://target-b/abs?x=1, which is what Node and Bun's own node:http server already report. Verified with bun bd test test/js/bun/http/bun-serve-routes.test.ts: the updated absolute-form test fails on main and passes with the fix.

CI on 0c601c0: 281 jobs passed. The one failed job (darwin 26 aarch64 - test-bun) is a Buildkite artifact-download timeout ("artifact download timed out after 120s for step 'darwin-aarch64-build-bun'"), not a test failure, and is unrelated to this change. The diff is ready for review.

@github-actions

Copy link
Copy Markdown
Contributor

Found 1 issue this PR may fix:

  1. Request object URL can be hijacked #17348 - Reports that Bun.serve derives request.url from the Host header, allowing URL hijacking; this PR fixes exactly that by using the absolute-form request-target's authority instead.

If this is helpful, copy the block below into the PR description to auto-close this issue on merge.

Fixes #17348

🤖 Generated with Claude Code

@robobun

robobun commented Jun 28, 2026

Copy link
Copy Markdown
Collaborator Author

#17348 is related but not fixed here, so I am not adding a closing reference. That issue is about origin-form requests, where the authority of request.url correctly comes from the Host field (RFC 9112, section 3.3) and a malformed Host value can make new URL(request.url) throw later. This PR only changes which source wins when the request-target is in absolute-form; origin-form handling of Host is unchanged.

@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 is a security-relevant behavioral change that partially reverts #31495's deliberate choice to derive request.url from Host — worth a human confirming the threat-model trade-off before it lands.

Extended reasoning...

Overview

This PR changes Request::ensure_url / size_of_url in src/runtime/webcore/Request.rs so that when an HTTP/1.1 request arrives with an absolute-form request-target (e.g. GET http://target-b/abs HTTP/1.1), request.url exposes the request-target's own scheme and authority instead of substituting the Host header. The request_target_path helper that stripped scheme+authority is deleted, and absolute-form targets are now normalized via href_from_string. The accompanying test in bun-serve-routes.test.ts is rewritten to assert the new behavior. Origin-form (/path) handling is unchanged.

Security risks

This is squarely in host-confusion territory. The PR's own description frames it as such: a target/Host disagreement is the ingredient of cache-poisoning and vhost/origin-check bypass chains. The RFC 9112 §3.2.2 argument (and Node/Go/nginx parity) is well-made, and the change does make the previously-discarded target authority observable to handlers. But it also means new URL(req.url).host becomes fully attacker-controlled for any client that sends an absolute-form request line — applications that were (perhaps unknowingly) relying on the post-#31495 behavior to get a Host-derived authority would now see whatever the client put on the request line. Whether that's a net security improvement or regression depends on how Bun users typically consume request.url, which is a judgment call for maintainers.

Level of scrutiny

High. This is a semantic change to the request URL surface of Bun.serve, it explicitly reverses one half of a prior merged decision (#31495), and the trade-off is about which of two attacker-influenceable inputs (Host vs. request-line authority) wins. Even though the diff is small and the implementation looks correct, the policy it encodes deserves a maintainer's sign-off rather than bot approval.

Other factors

The implementation itself is tidy: the Cow import and helper are cleanly removed, the &req_urlreq_url borrow adjustments are mechanical, size_of_url() stays consistent with ensure_url() for the debug assertion, and the new normalization step mirrors what the origin-form path already does. Test coverage is good (mismatched authority, matching authority, origin-form, query-only absolute-form). No prior human reviews on the timeline yet; CI build was just kicked off.

@robobun

robobun commented Jun 28, 2026

Copy link
Copy Markdown
Collaborator Author

Agreed that the trade-off is the thing that needs a human call; that is why the description spells out exactly which half of #31495 this reverts. Two facts that bound the blast radius for whoever makes that call:

  • Only absolute-form request lines are affected. Browsers and ordinary HTTP clients always send origin-form, which keeps the Host-derived URL, so the changed shape is only reachable from proxies or hand-written requests.
  • The Host-derived behavior for absolute-form is new in Hardening: input validation and protocol tightening across 24 subsystems (round 7) #31495 (in 1.4.0). Every release before that exposed the raw request-target, like Node, so code depending on the current behavior would have to be weeks old.

The two choices are also not symmetric in what they let an application do. With this PR, a handler that wants to trust Host can still read request.headers.get("host") and enforce agreement itself; today the target's authority is discarded before the handler runs, so it cannot be recovered, compared against Host, or rejected.

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.

1 participant