fetch: reject non-HTTP(S) schemes before touching the network#33960
fetch: reject non-HTTP(S) schemes before touching the network#33960robobun wants to merge 2 commits into
Conversation
fetch() with a URL whose scheme is not http/https/s3 must reject with TypeError before any network step. The existing scheme gate in fetch_impl was wrapped in `if !url.protocol.is_empty()`, but the simple URL parser only populates `protocol` when it sees `://`. WHATWG-valid URLs without an authority (`about:blank`, `javascript:...`, `localhost:3000/x`) therefore reached the HTTP connect path with the scheme name treated as a hostname, issuing real DNS lookups for `about`, `javascript`, etc. Drop the is_empty guard. By this point the input has already been WHATWG-normalized, and http/https are special schemes that always normalize to `scheme://host`, so `is_http()`/`is_https()` are reliable. data:, file:, and blob: URLs are handled earlier and are unaffected.
|
Updated 11:01 PM PT - Jul 10th, 2026
❌ @robobun, your commit a3619b5 has 2 failures in
🧪 To try this PR locally: bunx bun-pr 33960That installs a local version of the PR into your bun-33960 --bun |
WalkthroughFetch protocol validation is now unconditional, rejecting unsupported schemes and scheme-parsed host URLs. Tests cover rejection errors, prevented network requests, ChangesFetch protocol validation
🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
Comment |
There was a problem hiding this comment.
LGTM — dropping the empty-protocol guard is the right fix and matches fetch.preconnect() a few hundred lines up.
What was reviewed:
- Confirmed
ZigURL::from_stringruns WHATWG normalization first, so http/https always arrive asscheme://hostandis_http()/is_https()remain reliable without the guard. - Verified
data:,file:, andblob:are handled before this check and are unaffected; the new positive test covers http + data. - Checked that s3 URLs (written as
s3://…) still parse a non-empty protocol; theunixsocket path still requires an http(s) URL and is unaffected.
Extended reasoning...
Overview
Single-line semantic change in src/runtime/webcore/fetch.rs: removes the if !url.protocol.is_empty() outer guard around the http/https/s3 scheme check in fetch_impl. The rest of the Rust diff is de-indentation. New tests in test/js/web/fetch/fetch-args.test.ts cover about:, javascript:, chrome:, foo:, the localhost:<port>/path footgun, new Request('about:blank'), and a positive test for http + data.
Security risks
This is a security-positive change: it closes a hole where non-HTTP schemes leaked DNS queries (and potentially connections) for the scheme name. No new attack surface; the check is strictly tighter than before.
Level of scrutiny
Low-medium. The change is mechanical (drop one guard) and the PR description traces the root cause precisely: URL::parse_protocol (src/url/lib.rs:727) only assigns protocol when :// follows, so non-special WHATWG schemes fell through the old guard. I verified is_http/is_https/is_s3 compare against self.protocol (src/url/lib.rs:399-409), and that ZigURL::from_string (src/url/lib.rs:359) WHATWG-normalizes the href first — meaning http/https (special schemes) always serialize as scheme://host and will never have an empty protocol after re-parse. The identical unconditional check already exists in bun_fetch_preconnect in the same file.
Other factors
data:is handled by an earlyhas_prefix_comptimereturn;file:andblob:seturl_type != Remoteand return before this check. All unaffected.node_http_clientsharesfetch_implbut node:http always constructs explicithttp:///https://URLs, so no compat regression there.- Tests assert both the TypeError and that
requestCountis unchanged (no network activity), plus a positive control.
There was a problem hiding this comment.
LGTM — removing the empty-protocol guard makes the scheme check effective for opaque URLs, matching fetch.preconnect() and Node/spec behavior.
What was reviewed:
- Confirmed
is_http()/is_https()/is_s3()compare againstprotocolset byparse_protocol, which WHATWG-normalized http/https/s3 URLs always populate — no valid inputs are newly rejected. data:,file:, andblob:are handled before this check, so they're unaffected (and covered by the positive test).- The
node_http_cliententry sharesfetch_implbut only receives http/https URLs, so it's not impacted.
Extended reasoning...
Overview
The PR removes the outer !url.protocol.is_empty() guard around the http/https/s3 scheme check in fetch_impl (src/runtime/webcore/fetch.rs). Previously, opaque-scheme URLs like about:blank or localhost:3000/x would keep an empty protocol field after bun_url::URL::parse (which only sets protocol when the colon is followed by //), causing the scheme check to be skipped and the scheme name to be treated as a hostname — leaking DNS queries and potentially connecting. Now the check runs unconditionally, rejecting any non-http/https/s3 scheme with a TypeError before any network activity. New tests in test/js/web/fetch/fetch-args.test.ts cover several opaque schemes, the localhost:<port> case with a request-count assertion, new Request('about:blank'), and a positive check that http:// and data: still work.
Security risks
This is a strict tightening: it closes an unintended network egress path (DNS lookup / connection to a host derived from a URL scheme name). No new attack surface is introduced; the change only rejects more inputs earlier.
Level of scrutiny
Low-to-moderate. The native change is a two-line guard removal; the identical unconditional pattern already exists in bun_fetch_preconnect a few lines up in the same file. The key soundness question — whether any valid http/https/s3 URL could have an empty protocol and thus be newly rejected — is answered by the WHATWG special-scheme normalization guarantee (http/https always serialize as scheme://…), which parse_protocol recognizes. data: is handled before URL parsing, and file:/blob: set url_type != Remote and return before this check.
Other factors
The PR includes fail-on-main / pass-on-PR evidence for both debug+ASAN and release builds. Tests are placed in the existing fetch-args.test.ts alongside related "does not send a request when" coverage, use the shared requestCount server to assert no network activity, and include a positive regression guard. The node_http_client entry point (shared fetch_impl<true>) only receives http/https URLs from node:http, so it's unaffected. No outstanding reviewer comments.
|
CI status: the diff is green on its own tests. The only hard failures across builds #71785 and #71795 are
Both are unrelated to this change. The remaining annotations are yellow (passed on retry) on Windows install/napi/bake, darwin proxy-stress siblings, and the aarch64 solc timeout.
Ready for review. |
Reproduction
fetch()of a URL whose WHATWG scheme is not http/https leaks a DNS query for the scheme name and, if the resolver answers, opens a real connection. Node/undici and the fetch spec reject these withTypeErrorand zero network activity.Cause
fetch_implre-parses the WHATWG-normalized href with the simplebun_url::URLparser and gates on scheme:URL::parse_protocol(src/url/lib.rs) only assignsself.protocolwhen the colon is followed by//. Non-special schemes keep their opaquescheme:restform through WHATWG normalization, soprotocolstays empty, the outer guard skips the check, and the rest ofparse()treatsscheme:restashost:port.ftp:example.com/was already rejected only because ftp is a WHATWG special scheme and normalizes toftp://example.com/.Fix
Drop the
!url.protocol.is_empty()guard. The input has already been WHATWG-validated by this point; http/https are special schemes that always normalize toscheme://host, sois_http()/is_https()are reliable without the guard.data:,file:, and registeredblob:URLs are handled earlier and are unaffected.This matches the existing unconditional check in
fetch.preconnect()in the same file.Verification
New tests in
test/js/web/fetch/fetch-args.test.tscoverabout:blank,javascript:alert(1),chrome:flags,foo:bar,localhost:<port>/path(asserting the local server is not hit), andnew Request("about:blank"). A positive test confirmshttp://anddata:still succeed.Fails on main with
Received value: null(fetch resolved via network) or a DNS error; passes with this change.[review] gate passed · iteration 1 · 2 files touched
fails on main (without fix)
passes on PR (with fix)
diff hotspot
gate history · 2 passed · 0 rejected · iteration 1
evidence per changed file