From a568b6aed03aec265a7041a12e35de141d7f866b Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 16 Sep 2026 10:21:19 +0000 Subject: [PATCH 1/5] url: end the authority where new URL() ends it URL::parse read the userinfo as everything before the last `@` ahead of the first `/`, `?` or `#`. For http, https, ws, wss, ftp and file a `\` also ends the authority, so `http://u:p@first\x@second/` is host `first` to `new URL()` and was host `second` to URL::parse. The install path dials the host `new URL()` reads and chose the credentials with this one, so the two disagreed about which party gets them. The scheme scan had the same shape: it looked for `://` anywhere, so `http:first://second/` read `second` as the host where `new URL()` reads `first`. A scheme now ends at the first `:` and holds only the bytes RFC 3986 allows. One helper, `userinfo_end`, now answers where the userinfo ends for `parse` and for `href_without_userinfo`. --- src/url/lib.rs | 49 ++++++++++++-------- test/cli/install/npmrc.test.ts | 83 ++++++++++++++++++++++++++++++++++ test/js/bun/http/proxy.test.ts | 40 ++++++++++++++++ 3 files changed, 154 insertions(+), 18 deletions(-) diff --git a/src/url/lib.rs b/src/url/lib.rs index a62909154ca9..8f2078f2f9fc 100644 --- a/src/url/lib.rs +++ b/src/url/lib.rs @@ -421,6 +421,30 @@ impl<'a> URL<'a> { strings::eql_case_insensitive_ascii(self.protocol, b"http", true) } + /// The schemes WHATWG calls special: a `\` ends the authority of these, as a `/` does. + fn has_special_scheme(&self) -> bool { + [&b"http"[..], b"https", b"ws", b"wss", b"ftp", b"file"] + .into_iter() + .any(|scheme| strings::eql_case_insensitive_ascii(self.protocol, scheme, true)) + } + + /// The `@` that ends the userinfo in `after_scheme`, the text after `scheme://`: the last + /// `@` of the authority, which ends where `new URL()` ends it. + fn userinfo_end(&self, after_scheme: &[u8]) -> Option { + let backslash_ends_it = self.has_special_scheme(); + let mut last_at = None; + // One pass over the authority, which is short. + for (i, &byte) in after_scheme.iter().enumerate() { + match byte { + b'@' => last_at = Some(i), + b'/' | b'?' | b'#' => break, + b'\\' if backslash_ends_it => break, + _ => {} + } + } + last_at + } + pub fn display_hostname(&self) -> &[u8] { if !self.hostname.is_empty() { self.hostname @@ -481,13 +505,11 @@ impl<'a> URL<'a> { if self.username.is_empty() && self.password.is_empty() { return Cow::Borrowed(self.href); } - // The userinfo ends at the last `@` of the authority, as `parse` reads it. let Some(authority) = strings::index_of(self.href, b"://").map(|i| i + 3) else { return Cow::Borrowed(self.href); }; let rest = &self.href[authority..]; - let end = strings::index_of_any(rest, b"/?#").unwrap_or(rest.len()); - let Some(at) = strings::last_index_of_char(&rest[..end], b'@') else { + let Some(at) = self.userinfo_end(rest) else { return Cow::Borrowed(self.href); }; let mut out = Vec::with_capacity(self.href.len() - at - 1); @@ -681,17 +703,7 @@ impl<'a> URL<'a> { // what precedes the last `@` of the authority. if offset > 0 { let rest = &base[offset as usize..]; - // One pass over the authority, which is short: the last - // `@` before the first `/`, `?` or `#` ends the userinfo. - let mut last_at = None; - for (i, &byte) in rest.iter().enumerate() { - match byte { - b'@' => last_at = Some(i), - b'/' | b'?' | b'#' => break, - _ => {} - } - } - if let Some(at) = last_at { + if let Some(at) = url.userinfo_end(rest) { let userinfo = &rest[..at]; (url.username, url.password) = strings::split_once_char(userinfo, b':').unwrap_or((userinfo, b"")); @@ -796,16 +808,17 @@ impl<'a> URL<'a> { } for i in 0..str.len() { match str[i] { - b'/' | b'?' | b'%' => { - return None; - } + // RFC 3986 §3.1: the scheme ends at the first `:`, and holds only these bytes. + // `new URL()` reads it the same way, so neither can find a host the other misses. b':' => { if i + 3 <= str.len() && str[i + 1] == b'/' && str[i + 2] == b'/' { self.protocol = &str[0..i]; return Some(u32::try_from(i + 3).expect("int cast")); } + return None; } - _ => {} + b'a'..=b'z' | b'A'..=b'Z' | b'0'..=b'9' | b'+' | b'-' | b'.' => {} + _ => return None, } } diff --git a/test/cli/install/npmrc.test.ts b/test/cli/install/npmrc.test.ts index ec2e6adfcaee..7b0675b1c5c4 100644 --- a/test/cli/install/npmrc.test.ts +++ b/test/cli/install/npmrc.test.ts @@ -773,6 +773,89 @@ describe("--registry override", () => { }); }); +describe.concurrent("a registry URL whose host is easy to misread", () => { + // The requests go to the URL as `new URL()` reads it, so the credentials have to be chosen for + // the host `new URL()` reads. A loopback proxy records the requests and answers them itself: + // no name is resolved and nothing leaves the machine. + type Config = { files?: Record; args?: string[]; env?: Record }; + + async function install({ files, args = [], env: extraEnv }: Config) { + const requests: { host: string; auth: string | null }[] = []; + await using proxy = Bun.serve({ + port: 0, + hostname: "127.0.0.1", + fetch(req) { + requests.push({ host: new URL(req.url).host, auth: req.headers.get("authorization") }); + return new Response("not found", { status: 404 }); + }, + }); + const proxyUrl = `http://127.0.0.1:${proxy.port}`; + using dir = tempDir("npmrc-backslash-registry", { + "package.json": JSON.stringify({ name: "app", version: "1.0.0", dependencies: { "no-deps": "1.0.0" } }), + ...files, + }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "install", "--no-cache", ...args], + cwd: String(dir), + env: { ...env, http_proxy: proxyUrl, HTTP_PROXY: proxyUrl, no_proxy: "", NO_PROXY: "", ...extraEnv }, + stdout: "pipe", + stderr: "pipe", + }); + const [, , exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + // The proxy answers 404 to the manifest request, so the install itself fails. + return { requests, exitCode }; + } + + test.each<[string, (url: string) => Config]>([ + ["--registry", url => ({ args: [`--registry=${url}`] })], + [".npmrc", url => ({ files: { ".npmrc": `registry=${url}\n` } })], + ["bunfig.toml", url => ({ files: { "bunfig.toml": `[install]\nregistry = '${url}'\n` } })], + ["BUN_CONFIG_REGISTRY", url => ({ env: { BUN_CONFIG_REGISTRY: url } })], + ])("the credentials of the URL go to the host in front of the backslash: %s", async (_, configure) => { + expect(await install(configure(String.raw`http://u:p@first.example\x@second.example/`))).toEqual({ + requests: [{ host: "first.example", auth: `Basic ${btoa("u:p")}` }], + exitCode: 1, + }); + }); + + test("the token keyed to the host behind the backslash is not sent", async () => { + expect( + await install({ + files: { + ".npmrc": + String.raw`registry=http://first.example\@second.example/` + + "\n//second.example/:_authToken=second-host-SECRET-token\n", + }, + }), + ).toEqual({ requests: [{ host: "first.example", auth: null }], exitCode: 1 }); + }); + + test("--registry does not inherit the token of the host behind the backslash", async () => { + expect( + await install({ + files: { + ".npmrc": "registry=http://second.example/\n//second.example/:_authToken=second-host-SECRET-token\n", + }, + args: [String.raw`--registry=http://first.example\@second.example/`], + }), + ).toEqual({ requests: [{ host: "first.example", auth: null }], exitCode: 1 }); + }); + + test("a second scheme inside the registry URL does not claim the token of the host after it", async () => { + // `new URL("http:first.example://second.example/")` reads `first.example` as the host: a scheme + // ends at the first `:`. The token keyed to `second.example` must not go to `first.example`. + expect( + await install({ + files: { + ".npmrc": + "registry=http:first.example://second.example/\n" + + "//second.example/:_authToken=second-host-SECRET-token\n", + }, + }), + ).toEqual({ requests: [{ host: "first.example", auth: null }], exitCode: 1 }); + }); +}); + describe.skipIf(!isIPv6())("registry on a bracketed IPv6 host", () => { test("sends the token keyed to //[::1]:port/ to the default and the scoped registry", async () => { type Req = { path: string; auth: string | null }; diff --git a/test/js/bun/http/proxy.test.ts b/test/js/bun/http/proxy.test.ts index afcdcb1d7048..b9035803c09c 100644 --- a/test/js/bun/http/proxy.test.ts +++ b/test/js/bun/http/proxy.test.ts @@ -2467,6 +2467,46 @@ describe("proxy resolution", () => { ["http://example.test/#a@b", "", "", "example.test", ""], ["http://example.test:8080/user@other.test:9090", "", "", "example.test", "8080"], ["http://user:pass@example.test/path@other.test", "user", "pass", "example.test", ""], + // A `\` ends the authority of http, https, ws, wss, ftp and file, as it does for `new URL()`, + // so an `@` after it is not userinfo. `hostname` runs on to the next `/`: no name that resolves. + [ + String.raw`http://user:pass@example.test\x@other.test/`, + "user", + "pass", + String.raw`example.test\x@other.test`, + "", + ], + [String.raw`http://a:b@c\@d/`, "a", "b", String.raw`c\@d`, ""], + [String.raw`http://example.test\@other.test/`, "", "", String.raw`example.test\@other.test`, ""], + [ + String.raw`HTTPS://user:pass@example.test\@other.test/`, + "user", + "pass", + String.raw`example.test\@other.test`, + "", + ], + [String.raw`ws://user:pass@example.test\@other.test/`, "user", "pass", String.raw`example.test\@other.test`, ""], + [String.raw`wss://user:pass@example.test\@other.test/`, "user", "pass", String.raw`example.test\@other.test`, ""], + [String.raw`ftp://user:pass@example.test\@other.test/`, "user", "pass", String.raw`example.test\@other.test`, ""], + [String.raw`file://example.test\@other.test/`, "", "", String.raw`example.test\@other.test`, ""], + [String.raw`http://example.test\\@other.test/`, "", "", String.raw`example.test\\@other.test`, ""], + [`http://example.test\t\\@other.test/`, "", "", `example.test\t\\@other.test`, ""], + [String.raw`http://example.test\@[::1]:8080/`, "", "", String.raw`example.test\@[`, ":1]:8080"], + [String.raw`http://u:p@[::1]:80\@other.test:8080/sub`, "u", "p", "[::1]", String.raw`80\@other.test:8080`], + // In any other scheme a `\` is part of the userinfo, again as for `new URL()`. + [ + String.raw`socks5://user:pass@example.test\x@other.test/`, + "user", + String.raw`pass@example.test\x`, + "other.test", + "", + ], + // The scheme ends at the first `:` and holds only the bytes RFC 3986 allows, so the `://` of + // a later one starts no authority. `new URL()` reads `other.test` as the host of these two, + // and the name this reads is one no user can have written down. + ["http:other.test://example.test/", "", "", "http", "other.test:"], + [String.raw`http:\other.test://example.test/`, "", "", "http", String.raw`\other.test:`], + ["git+ssh://user@example.test/repo.git", "user", "", "example.test", ""], // IPv6 hosts keep their brackets in `hostname` ["http://[::1]:3000/", "", "", "[::1]", "3000"], ["http://user:pass@[::1]:3000/", "user", "pass", "[::1]", "3000"], From eacb99e540a005afeaee7dcabb942a8d5e63654f Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 16 Sep 2026 10:21:19 +0000 Subject: [PATCH 2/5] url: keep the old authority rule where this parser is the only reader The `\` rule of the previous commit is right where something else reads the string too, and wrong where this parser alone reads it. A proxy variable is the second kind: `URL::parse` picks both the host to dial and the credentials to send, and `http://DOMAIN\user:pass@proxy:8080` is how a Windows domain account is spelled there. curl reads the `\` as an ordinary userinfo byte. `AuthorityEnd` names the two rules. `URL::parse` keeps `LikeNewURL`. `URL::parse_single_reader` takes `SlashQueryOrHash`, and the three places that read a proxy href use it. `split_url_userinfo` in NetworkTask.rs was a third copy of the scan. A tarball URL of `http://u:p@first\x@second/pkg.tgz` sent `Basic` of `u:p@first\x` to `second`. npm reads that URL with `new URL()`: host `first`, user `u`, password `p`. It now shares `userinfo_end`. `fetch()` rejected a URL whose protocol `URL::parse` does not take only when the protocol was non-empty, so `blob:http://host/id` became a request for a host named `blob`. The check no longer reads the protocol first. --- src/dotenv/env_loader.rs | 5 ++- src/http/lib.rs | 2 +- src/install/NetworkTask.rs | 4 +- src/runtime/webcore/fetch.rs | 15 ++++---- src/runtime/webcore/fetch/FetchTasklet.rs | 2 +- src/url/lib.rs | 45 +++++++++++++++++------ test/cli/install/bun-install.test.ts | 20 ++++++++++ test/js/bun/http/proxy.test.ts | 17 +++++++++ test/js/web/fetch/fetch-args.test.ts | 4 ++ 9 files changed, 89 insertions(+), 25 deletions(-) diff --git a/src/dotenv/env_loader.rs b/src/dotenv/env_loader.rs index d784319b45f7..a0fcafe596df 100644 --- a/src/dotenv/env_loader.rs +++ b/src/dotenv/env_loader.rs @@ -323,7 +323,8 @@ impl Loader { } pub fn get_http_proxy_for(&self, url: &URL<'_>) -> Option> { - let proxy = URL::parse(self.proxy_env_for_scheme(url.is_http())?); + // `http://DOMAIN\user:pass@proxy:8080` is a domain login, as curl reads it. + let proxy = URL::parse_single_reader(self.proxy_env_for_scheme(url.is_http())?); if self.is_no_proxy(url.hostname, url.get_port_auto()) { return None; } @@ -372,7 +373,7 @@ impl Loader { let value = self .get_lower_then_upper(b"all_proxy", b"ALL_PROXY") .filter(|p| !Self::is_emptyish(p))?; - let url = URL::parse(value); + let url = URL::parse_single_reader(value); (url.protocol.is_empty() || url.has_http_like_protocol()).then_some(value) } diff --git a/src/http/lib.rs b/src/http/lib.rs index ff06bde2f0b6..d95940839ce6 100644 --- a/src/http/lib.rs +++ b/src/http/lib.rs @@ -2707,7 +2707,7 @@ impl<'a> HTTPClient<'a> { Some(href) => { // SAFETY: self-borrow. `href` points into `self.proxy_settings`'s // boxed storage, which lives as long as `self` (>= `'a`). - let proxy: URL<'a> = unsafe { URL::parse(href).erase_lifetime() }; + let proxy: URL<'a> = unsafe { URL::parse_single_reader(href).erase_lifetime() }; self.proxy_authorization = async_http::basic_authorization(&proxy); self.http_proxy = Some(proxy); } diff --git a/src/install/NetworkTask.rs b/src/install/NetworkTask.rs index 0abb6e90d0b1..d34c4e0f3a6e 100644 --- a/src/install/NetworkTask.rs +++ b/src/install/NetworkTask.rs @@ -414,8 +414,8 @@ fn count_auth(header_builder: &mut HeaderBuilder, scope: &npm::registry::Scope) fn split_url_userinfo(url: &[u8]) -> Option<(&[u8], Box<[u8]>)> { let authority_start = strings::index_of(url, b"://")? + b"://".len(); let rest = &url[authority_start..]; - let authority = &rest[..strings::index_of_any(rest, b"/?#").unwrap_or(rest.len())]; - let at = strings::last_index_of_char(authority, b'@')?; + // npm reads a tarball URL with `new URL()`, so the authority ends where that ends. + let at = URL::parse(url).userinfo_end(rest, bun_url::AuthorityEnd::LikeNewURL)?; let mut without_userinfo = Vec::with_capacity(url.len() - (at + 1)); without_userinfo.extend_from_slice(&url[..authority_start]); diff --git a/src/runtime/webcore/fetch.rs b/src/runtime/webcore/fetch.rs index ebe61b1ec062..c57e5b03af12 100644 --- a/src/runtime/webcore/fetch.rs +++ b/src/runtime/webcore/fetch.rs @@ -1420,14 +1420,13 @@ fn fetch_impl( )); } - if !url.protocol.is_empty() { - if !(url.is_http() || url.is_https() || url.is_s3()) { - let err = global_this.to_type_error( - jsc::ErrorCode::INVALID_ARG_VALUE, - format_args!("protocol must be http:, https: or s3:"), - ); - return Ok(JSPromise::rejected_promise(global_this, err).to_js()); - } + // `file:`, `blob:` and `data:` returned above. An empty protocol is a scheme this cannot dial. + if !(url.is_http() || url.is_https() || url.is_s3()) { + let err = global_this.to_type_error( + jsc::ErrorCode::INVALID_ARG_VALUE, + format_args!("protocol must be http:, https: or s3:"), + ); + return Ok(JSPromise::rejected_promise(global_this, err).to_js()); } // WHATWG Fetch step 36 forbids a body for GET/HEAD; Bun additionally diff --git a/src/runtime/webcore/fetch/FetchTasklet.rs b/src/runtime/webcore/fetch/FetchTasklet.rs index 66f0b4945daa..b6a73a797d21 100644 --- a/src/runtime/webcore/fetch/FetchTasklet.rs +++ b/src/runtime/webcore/fetch/FetchTasklet.rs @@ -2002,7 +2002,7 @@ impl FetchTasklet { let proxy: Option = proxy_settings.as_deref().and_then(|s| { let href: *const [u8] = s.resolve(&url)?; // SAFETY: see block comment above. - Some(ZigURL::parse(unsafe { &*href })) + Some(ZigURL::parse_single_reader(unsafe { &*href })) }); // The callback keeps a request on HTTP/1.1. Under `rejectUnauthorized: diff --git a/src/url/lib.rs b/src/url/lib.rs index 8f2078f2f9fc..1462aa239210 100644 --- a/src/url/lib.rs +++ b/src/url/lib.rs @@ -183,6 +183,15 @@ pub use whatwg::{ file_url_from_string, href_from_string, join, origin_from_slice, path_from_file_url, }; +/// Where the authority ends, which is where the search for the `@` of the userinfo stops. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum AuthorityEnd { + /// `/`, `?`, `#`, and a `\` in a special scheme. For a string that something else reads too. + LikeNewURL, + /// `/`, `?` or `#`, so a `\` stays userinfo. Only for a string this parser alone reads. + SlashQueryOrHash, +} + // URL is a pure view struct — every field is a slice into `href` (or a // literal default). #[derive(Clone)] @@ -203,6 +212,8 @@ pub struct URL<'a> { pub(crate) search_params: Option, pub username: &'a [u8], pub(crate) port_was_automatically_set: bool, + /// The rule `parse` used, so `href_without_userinfo` cuts the same bytes. + pub(crate) authority_end: AuthorityEnd, } impl<'a> Default for URL<'a> { @@ -222,6 +233,7 @@ impl<'a> Default for URL<'a> { search_params: None, username: b"", port_was_automatically_set: false, + authority_end: AuthorityEnd::LikeNewURL, } } } @@ -312,6 +324,7 @@ impl<'a> URL<'a> { search_params: self.search_params, username: d(self.username), port_was_automatically_set: self.port_was_automatically_set, + authority_end: self.authority_end, } } @@ -423,15 +436,15 @@ impl<'a> URL<'a> { /// The schemes WHATWG calls special: a `\` ends the authority of these, as a `/` does. fn has_special_scheme(&self) -> bool { - [&b"http"[..], b"https", b"ws", b"wss", b"ftp", b"file"] - .into_iter() - .any(|scheme| strings::eql_case_insensitive_ascii(self.protocol, scheme, true)) + strings::eql_any_case_insensitive_ascii( + self.protocol, + &[b"http", b"https", b"ws", b"wss", b"ftp", b"file"], + ) } - /// The `@` that ends the userinfo in `after_scheme`, the text after `scheme://`: the last - /// `@` of the authority, which ends where `new URL()` ends it. - fn userinfo_end(&self, after_scheme: &[u8]) -> Option { - let backslash_ends_it = self.has_special_scheme(); + /// The last `@` of the authority of `after_scheme`, the text after `scheme://`. + pub fn userinfo_end(&self, after_scheme: &[u8], end: AuthorityEnd) -> Option { + let backslash_ends_it = end == AuthorityEnd::LikeNewURL && self.has_special_scheme(); let mut last_at = None; // One pass over the authority, which is short. for (i, &byte) in after_scheme.iter().enumerate() { @@ -509,7 +522,7 @@ impl<'a> URL<'a> { return Cow::Borrowed(self.href); }; let rest = &self.href[authority..]; - let Some(at) = self.userinfo_end(rest) else { + let Some(at) = self.userinfo_end(rest, self.authority_end) else { return Cow::Borrowed(self.href); }; let mut out = Vec::with_capacity(self.href.len() - at - 1); @@ -668,12 +681,23 @@ impl<'a> URL<'a> { } } + /// Reads the authority as `new URL()` reads it. See [`URL::parse_single_reader`] for the other rule. pub fn parse(base: &'a [u8]) -> URL<'a> { + Self::parse_with(base, AuthorityEnd::LikeNewURL) + } + + /// `parse` for a string this parser alone reads, where a `\` before the `@` is userinfo. + pub fn parse_single_reader(base: &'a [u8]) -> URL<'a> { + Self::parse_with(base, AuthorityEnd::SlashQueryOrHash) + } + + fn parse_with(base: &'a [u8], authority_end: AuthorityEnd) -> URL<'a> { if base.is_empty() { return URL::default(); } let mut url = URL { href: base, + authority_end, ..Default::default() }; let mut offset: u32 = 0; @@ -703,7 +727,7 @@ impl<'a> URL<'a> { // what precedes the last `@` of the authority. if offset > 0 { let rest = &base[offset as usize..]; - if let Some(at) = url.userinfo_end(rest) { + if let Some(at) = url.userinfo_end(rest, authority_end) { let userinfo = &rest[..at]; (url.username, url.password) = strings::split_once_char(userinfo, b':').unwrap_or((userinfo, b"")); @@ -808,8 +832,7 @@ impl<'a> URL<'a> { } for i in 0..str.len() { match str[i] { - // RFC 3986 §3.1: the scheme ends at the first `:`, and holds only these bytes. - // `new URL()` reads it the same way, so neither can find a host the other misses. + // RFC 3986 §3.1, and `new URL()`: the scheme ends at the first `:`. b':' => { if i + 3 <= str.len() && str[i + 1] == b'/' && str[i + 2] == b'/' { self.protocol = &str[0..i]; diff --git a/test/cli/install/bun-install.test.ts b/test/cli/install/bun-install.test.ts index 76cd0da1dfab..9011ee22d4e0 100644 --- a/test/cli/install/bun-install.test.ts +++ b/test/cli/install/bun-install.test.ts @@ -1010,6 +1010,26 @@ describe.concurrent("bun-install", () => { }); }); + it("does not hand the credentials to the host behind a backslash", async () => { + // `new URL("http://u:p@first\\x@second/pkg.tgz")` reads the host as `first` and the + // credentials as `u:p`, because a `\` ends the authority of an http URL. npm reads it the + // same way. Neither host may receive the credentials the other was given. + const firstReceived: Received[] = []; + const secondReceived: Received[] = []; + await using first = serveTarball(firstReceived, null); + await using second = serveTarball(secondReceived, null); + + const result = await install( + String.raw`http://u:p@127.0.0.1:${first.port}\x@127.0.0.1:${second.port}${tarballPath}`, + ); + + expect({ firstReceived, secondReceived, exitCode: result.exitCode }).toEqual({ + firstReceived: [], + secondReceived: [], + exitCode: 1, + }); + }); + it("keeps the credentials across a redirect within the host", async () => { const received: Received[] = []; await using server = serveTarball(received, basic("carol:s3cret")); diff --git a/test/js/bun/http/proxy.test.ts b/test/js/bun/http/proxy.test.ts index b9035803c09c..43cfe4e02b18 100644 --- a/test/js/bun/http/proxy.test.ts +++ b/test/js/bun/http/proxy.test.ts @@ -2950,6 +2950,23 @@ describe("http_proxy/NO_PROXY re-evaluated per redirect hop", () => { expect(exitCode).toBe(0); }); + test("a domain login in http_proxy reaches the proxy as written", async () => { + // `http://DOMAIN\user:pass@host:port` is how a Windows domain account is spelled in a proxy + // variable, and curl reads the `\` as an ordinary userinfo byte. Only this parser reads the + // variable, and it names the host that is dialed, so there is no second reading to agree with. + const { stdout, stderr, exitCode, proxyLog, proxyAuth } = await runFetch( + { http_proxy: String.raw`http://DOMAIN\user:pass@127.0.0.1:${proxy.port}` }, + `http://127.0.0.1:${originA.port}/final`, + ); + expect({ stdout, proxyLog, proxyAuth }).toEqual({ + stdout: "FINAL-PROXY", + proxyLog: [`GET http://127.0.0.1:${originA.port}/final HTTP/1.1`], + proxyAuth: [`Basic ${btoa(String.raw`DOMAIN\user:pass`)}`], + }); + if (exitCode !== 0) console.error("stderr:", stderr); + expect(exitCode).toBe(0); + }); + test("http->https redirect drops http_proxy when https_proxy is unset", async () => { // ProxySettings::resolve() picks by scheme: hop 2 (https) must not inherit // the http_proxy hop 1 used. diff --git a/test/js/web/fetch/fetch-args.test.ts b/test/js/web/fetch/fetch-args.test.ts index b3fb2eb161a4..1ba6a31db6a8 100644 --- a/test/js/web/fetch/fetch-args.test.ts +++ b/test/js/web/fetch/fetch-args.test.ts @@ -162,6 +162,10 @@ describe.concurrent("fetch() early rejections are reported when unhandled", () = ["blank url", `fetch("")`, "fetch() URL must not be a blank string"], ["invalid url", `fetch("not a url")`, "fetch() URL is invalid"], ["unsupported protocol", `fetch("gopher://example.com/")`, "protocol must be http:, https: or s3:"], + // A scheme `URL::parse` does not take leaves the protocol empty. The request still has to be + // refused: the host of `blob:http://example.com/id` is `blob` to this parser. + ["a scheme in front of a scheme", `fetch("blob:http://example.com/id")`, "protocol must be http:, https: or s3:"], + ["view-source:", `fetch("view-source:http://example.com/")`, "protocol must be http:, https: or s3:"], [ "revoked blob: url", `const url = URL.createObjectURL(new Blob(["x"])); URL.revokeObjectURL(url); fetch(url);`, From a578f7ad019f2b3a06f7ce7b36e635197dc2f57f Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 16 Sep 2026 10:21:19 +0000 Subject: [PATCH 3/5] url: end the host and the port where the userinfo ends `userinfo_end` stopped at the end of the authority and `parse_host` ran on to the next `/`, so `http://u:p@first:8080\x@second/` gave the port text `8080\x@second`. `get_port` failed on it and `get_port_auto` fell back to 80 with the `Authorization` header attached. `ends_authority` is now the one predicate for the userinfo, the host and the port, and a `#` ends the host too. `parse_protocol` keeps the verdict it always gave: the text in front of a `://` that comes before any `/`, `?` or `%`. It reads an authority only when that text is a scheme of RFC 3986 bytes. So `blob:http://host/id` still has a protocol `fetch` refuses, a string like `localhost:3000/api` still has none, and no host is read behind a second scheme. The `fetch.rs` change of the last commit is not needed and is gone. A proxy reaches the HTTP client through `make_client` whoever parsed it, so the single-reader rule is applied there. S3 keeps the proxy as a string and parses it again in three places, and missed the rule. --- src/http/AsyncHTTP.rs | 3 +- src/runtime/webcore/fetch.rs | 15 +++--- src/runtime/webcore/fetch/FetchTasklet.rs | 2 +- src/url/lib.rs | 52 ++++++++++++--------- test/cli/install/bun-install.test.ts | 16 ++++--- test/js/bun/http/proxy.test.ts | 57 ++++++++++++----------- test/js/web/fetch/fetch-args.test.ts | 9 +++- 7 files changed, 88 insertions(+), 66 deletions(-) diff --git a/src/http/AsyncHTTP.rs b/src/http/AsyncHTTP.rs index b7b1057c98ef..31776836f9c5 100644 --- a/src/http/AsyncHTTP.rs +++ b/src/http/AsyncHTTP.rs @@ -180,7 +180,8 @@ fn make_client<'a>( result_callback: noop_callback(), if_modified_since: b"", request_content_len_buf: [0u8; b"18446744073709551615".len()], - http_proxy, + // The client dials and authenticates a proxy from this one parse, whoever made the URL. + http_proxy: http_proxy.map(|proxy| URL::parse_single_reader(proxy.href)), proxy_settings: None, proxy_headers, proxy_authorization: None, diff --git a/src/runtime/webcore/fetch.rs b/src/runtime/webcore/fetch.rs index c57e5b03af12..ebe61b1ec062 100644 --- a/src/runtime/webcore/fetch.rs +++ b/src/runtime/webcore/fetch.rs @@ -1420,13 +1420,14 @@ fn fetch_impl( )); } - // `file:`, `blob:` and `data:` returned above. An empty protocol is a scheme this cannot dial. - if !(url.is_http() || url.is_https() || url.is_s3()) { - let err = global_this.to_type_error( - jsc::ErrorCode::INVALID_ARG_VALUE, - format_args!("protocol must be http:, https: or s3:"), - ); - return Ok(JSPromise::rejected_promise(global_this, err).to_js()); + if !url.protocol.is_empty() { + if !(url.is_http() || url.is_https() || url.is_s3()) { + let err = global_this.to_type_error( + jsc::ErrorCode::INVALID_ARG_VALUE, + format_args!("protocol must be http:, https: or s3:"), + ); + return Ok(JSPromise::rejected_promise(global_this, err).to_js()); + } } // WHATWG Fetch step 36 forbids a body for GET/HEAD; Bun additionally diff --git a/src/runtime/webcore/fetch/FetchTasklet.rs b/src/runtime/webcore/fetch/FetchTasklet.rs index b6a73a797d21..66f0b4945daa 100644 --- a/src/runtime/webcore/fetch/FetchTasklet.rs +++ b/src/runtime/webcore/fetch/FetchTasklet.rs @@ -2002,7 +2002,7 @@ impl FetchTasklet { let proxy: Option = proxy_settings.as_deref().and_then(|s| { let href: *const [u8] = s.resolve(&url)?; // SAFETY: see block comment above. - Some(ZigURL::parse_single_reader(unsafe { &*href })) + Some(ZigURL::parse(unsafe { &*href })) }); // The callback keeps a request on HTTP/1.1. Under `rejectUnauthorized: diff --git a/src/url/lib.rs b/src/url/lib.rs index 1462aa239210..e6f4eb80e797 100644 --- a/src/url/lib.rs +++ b/src/url/lib.rs @@ -442,17 +442,26 @@ impl<'a> URL<'a> { ) } + fn backslash_ends_authority(&self, end: AuthorityEnd) -> bool { + end == AuthorityEnd::LikeNewURL && self.has_special_scheme() + } + + /// The one definition of where an authority ends, for the userinfo, the host and the port. + fn ends_authority(byte: u8, backslash_ends_it: bool) -> bool { + matches!(byte, b'/' | b'?' | b'#') || (backslash_ends_it && byte == b'\\') + } + /// The last `@` of the authority of `after_scheme`, the text after `scheme://`. pub fn userinfo_end(&self, after_scheme: &[u8], end: AuthorityEnd) -> Option { - let backslash_ends_it = end == AuthorityEnd::LikeNewURL && self.has_special_scheme(); + let backslash_ends_it = self.backslash_ends_authority(end); let mut last_at = None; // One pass over the authority, which is short. for (i, &byte) in after_scheme.iter().enumerate() { - match byte { - b'@' => last_at = Some(i), - b'/' | b'?' | b'#' => break, - b'\\' if backslash_ends_it => break, - _ => {} + if Self::ends_authority(byte, backslash_ends_it) { + break; + } + if byte == b'@' { + last_at = Some(i); } } last_at @@ -832,16 +841,20 @@ impl<'a> URL<'a> { } for i in 0..str.len() { match str[i] { - // RFC 3986 §3.1, and `new URL()`: the scheme ends at the first `:`. + b'/' | b'?' | b'%' => { + return None; + } b':' => { if i + 3 <= str.len() && str[i + 1] == b'/' && str[i + 2] == b'/' { self.protocol = &str[0..i]; - return Some(u32::try_from(i + 3).expect("int cast")); + // RFC 3986 §3.1: only behind a scheme of these bytes is there an authority. + let is_scheme = self.protocol.iter().all(|byte| { + matches!(byte, b'a'..=b'z' | b'A'..=b'Z' | b'0'..=b'9' | b'+' | b'-' | b'.') + }); + return is_scheme.then(|| u32::try_from(i + 3).expect("int cast")); } - return None; } - b'a'..=b'z' | b'A'..=b'Z' | b'0'..=b'9' | b'+' | b'-' | b'.' => {} - _ => return None, + _ => {} } } @@ -879,6 +892,7 @@ impl<'a> URL<'a> { pub(crate) fn parse_host(&mut self, str: &'a [u8]) -> Option { let mut i: u32 = 0; + let backslash_ends_it = self.backslash_ends_authority(self.authority_end); // reset it self.host = b""; @@ -902,12 +916,8 @@ impl<'a> URL<'a> { } else { colon_i }; - match str[i as usize] { - // alright, we found the slash or "?" - b'?' | b'/' => { - break; - } - _ => {} + if Self::ends_authority(str[i as usize], backslash_ends_it) { + break; } i += 1; } @@ -936,12 +946,8 @@ impl<'a> URL<'a> { colon_i }; - match str[i as usize] { - // alright, we found the slash or "?" - b'?' | b'/' => { - break; - } - _ => {} + if Self::ends_authority(str[i as usize], backslash_ends_it) { + break; } i += 1; } diff --git a/test/cli/install/bun-install.test.ts b/test/cli/install/bun-install.test.ts index 9011ee22d4e0..ac4f70daa956 100644 --- a/test/cli/install/bun-install.test.ts +++ b/test/cli/install/bun-install.test.ts @@ -1010,23 +1010,27 @@ describe.concurrent("bun-install", () => { }); }); - it("does not hand the credentials to the host behind a backslash", async () => { + it("sends the credentials to the host in front of a backslash, not the one behind it", async () => { // `new URL("http://u:p@first\\x@second/pkg.tgz")` reads the host as `first` and the // credentials as `u:p`, because a `\` ends the authority of an http URL. npm reads it the - // same way. Neither host may receive the credentials the other was given. + // same way. The path of the request is not compared: Windows turns the `\` into a `/`. const firstReceived: Received[] = []; const secondReceived: Received[] = []; - await using first = serveTarball(firstReceived, null); + await using first = serveTarball(firstReceived, basic("u:p")); await using second = serveTarball(secondReceived, null); const result = await install( String.raw`http://u:p@127.0.0.1:${first.port}\x@127.0.0.1:${second.port}${tarballPath}`, ); - expect({ firstReceived, secondReceived, exitCode: result.exitCode }).toEqual({ - firstReceived: [], + expect({ + first: firstReceived.map(({ url, authorization }) => ({ host: new URL(url).host, authorization })), + secondReceived, + ...result, + }).toEqual({ + first: [{ host: `127.0.0.1:${first.port}`, authorization: basic("u:p") }], secondReceived: [], - exitCode: 1, + ...installed, }); }); diff --git a/test/js/bun/http/proxy.test.ts b/test/js/bun/http/proxy.test.ts index 43cfe4e02b18..8fc3350c9cf9 100644 --- a/test/js/bun/http/proxy.test.ts +++ b/test/js/bun/http/proxy.test.ts @@ -2467,32 +2467,24 @@ describe("proxy resolution", () => { ["http://example.test/#a@b", "", "", "example.test", ""], ["http://example.test:8080/user@other.test:9090", "", "", "example.test", "8080"], ["http://user:pass@example.test/path@other.test", "user", "pass", "example.test", ""], - // A `\` ends the authority of http, https, ws, wss, ftp and file, as it does for `new URL()`, - // so an `@` after it is not userinfo. `hostname` runs on to the next `/`: no name that resolves. - [ - String.raw`http://user:pass@example.test\x@other.test/`, - "user", - "pass", - String.raw`example.test\x@other.test`, - "", - ], - [String.raw`http://a:b@c\@d/`, "a", "b", String.raw`c\@d`, ""], - [String.raw`http://example.test\@other.test/`, "", "", String.raw`example.test\@other.test`, ""], - [ - String.raw`HTTPS://user:pass@example.test\@other.test/`, - "user", - "pass", - String.raw`example.test\@other.test`, - "", - ], - [String.raw`ws://user:pass@example.test\@other.test/`, "user", "pass", String.raw`example.test\@other.test`, ""], - [String.raw`wss://user:pass@example.test\@other.test/`, "user", "pass", String.raw`example.test\@other.test`, ""], - [String.raw`ftp://user:pass@example.test\@other.test/`, "user", "pass", String.raw`example.test\@other.test`, ""], - [String.raw`file://example.test\@other.test/`, "", "", String.raw`example.test\@other.test`, ""], - [String.raw`http://example.test\\@other.test/`, "", "", String.raw`example.test\\@other.test`, ""], - [`http://example.test\t\\@other.test/`, "", "", `example.test\t\\@other.test`, ""], - [String.raw`http://example.test\@[::1]:8080/`, "", "", String.raw`example.test\@[`, ":1]:8080"], - [String.raw`http://u:p@[::1]:80\@other.test:8080/sub`, "u", "p", "[::1]", String.raw`80\@other.test:8080`], + // A `\` ends the authority of http, https, ws, wss, ftp and file, as it does for `new URL()`: + // an `@` after it is not userinfo, and the host and the port end there too. + [String.raw`http://user:pass@example.test\x@other.test/`, "user", "pass", "example.test", ""], + [String.raw`http://a:b@c\@d/`, "a", "b", "c", ""], + [String.raw`http://example.test\@other.test/`, "", "", "example.test", ""], + [String.raw`HTTPS://user:pass@example.test\@other.test/`, "user", "pass", "example.test", ""], + [String.raw`ws://user:pass@example.test\@other.test/`, "user", "pass", "example.test", ""], + [String.raw`wss://user:pass@example.test\@other.test/`, "user", "pass", "example.test", ""], + [String.raw`ftp://user:pass@example.test\@other.test/`, "user", "pass", "example.test", ""], + [String.raw`file://example.test\@other.test/`, "", "", "example.test", ""], + [String.raw`http://example.test\\@other.test/`, "", "", "example.test", ""], + [String.raw`http://example.test\@[::1]:8080/`, "", "", "example.test", ""], + [String.raw`http://u:p@[::1]:80\@other.test:8080/sub`, "u", "p", "[::1]", "80"], + [String.raw`http://u:p@example.test:8080\@other.test:9090/`, "u", "p", "example.test", "8080"], + // `new URL()` drops a tab before it parses. This keeps it, so the name resolves to nothing. + [`http://example.test\t\\@other.test/`, "", "", "example.test\t", ""], + // A `#` ends the authority too, with or without a `/` in front of it. + ["http://u:p@example.test:8080#@other.test/", "u", "p", "example.test", "8080"], // In any other scheme a `\` is part of the userinfo, again as for `new URL()`. [ String.raw`socks5://user:pass@example.test\x@other.test/`, @@ -2721,6 +2713,19 @@ describe.concurrent("proxy environment", () => { expect(results).toEqual(["proxy", "origin"]); }); + test("fetch('s3://…') reaches a proxy whose variable holds a domain login", async () => { + // S3 keeps the proxy as a string and parses it again, so it needs the same reading as fetch(). + const results = await run( + () => ({}), + ` + const s3 = { accessKeyId: "test", secretAccessKey: "test", endpoint: "http://127.0.0.1:" + ORIGIN_PORT }; + process.env.HTTP_PROXY = PROXY.replace("http://", "http://DOMAIN" + String.fromCharCode(92) + "user:pass@"); + console.log(JSON.stringify([await fetch("s3://bucket/key", { s3 }).then(r => r.text(), e => e.code)])); + `, + ); + expect(results).toEqual(["proxy"]); + }); + test("a worker starts from the proxy environment its parent has at that moment", async () => { const results = await run( () => ({}), diff --git a/test/js/web/fetch/fetch-args.test.ts b/test/js/web/fetch/fetch-args.test.ts index 1ba6a31db6a8..cc46c4d327f6 100644 --- a/test/js/web/fetch/fetch-args.test.ts +++ b/test/js/web/fetch/fetch-args.test.ts @@ -31,6 +31,12 @@ test("fetch(request subclass with headers)", async () => { expect(headers.get("hello")).toBe("world"); }); +test("fetch(host:port/path) without a scheme is an http request", async () => { + // `new URL()` reads `localhost` as the scheme of this string. The client reads a host and a port. + const response = await fetch(`localhost:${server!.port}/hello`, { headers: { hello: "world" } }); + expect({ status: response.status, hello: response.headers.get("hello") }).toEqual({ status: 200, hello: "world" }); +}); + test("fetch(RequestInit, headers)", async () => { const myRequest = { headers: { @@ -162,8 +168,7 @@ describe.concurrent("fetch() early rejections are reported when unhandled", () = ["blank url", `fetch("")`, "fetch() URL must not be a blank string"], ["invalid url", `fetch("not a url")`, "fetch() URL is invalid"], ["unsupported protocol", `fetch("gopher://example.com/")`, "protocol must be http:, https: or s3:"], - // A scheme `URL::parse` does not take leaves the protocol empty. The request still has to be - // refused: the host of `blob:http://example.com/id` is `blob` to this parser. + // No host may be read behind the second scheme, and the request still has to be refused. ["a scheme in front of a scheme", `fetch("blob:http://example.com/id")`, "protocol must be http:, https: or s3:"], ["view-source:", `fetch("view-source:http://example.com/")`, "protocol must be http:, https: or s3:"], [ From d32449e9dd5962c1f928b67f7228d29e360f2629 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 17 Sep 2026 00:44:20 +0000 Subject: [PATCH 4/5] bun_core: compare in Rust under Miri instead of calling strncasecmp `URL::parse` now asks whether the scheme is a special one, and that goes through `eql_case_insensitive_ascii`, which calls libc `strncasecmp`. Miri has no shim for it, so `cargo miri test -p bun_url` stopped at `can't call foreign function strncasecmp` in the first test that parses a URL. Under `cfg(miri)` the helper compares with `eq_ignore_ascii_case`, the way `bun_highway` gives each of its kernels a scalar path for Miri. Other builds are unchanged. `cargo test -p bun_url` does not link, but Miri runs the crate's tests, so the authority rules get three unit tests: where the authority ends, the proxy reading, and no host behind a second scheme. --- src/bun_core/lib.rs | 9 +++++-- src/url/lib.rs | 57 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+), 2 deletions(-) diff --git a/src/bun_core/lib.rs b/src/bun_core/lib.rs index aa42dd2f9f2d..c13f39ee42f2 100644 --- a/src/bun_core/lib.rs +++ b/src/bun_core/lib.rs @@ -1330,13 +1330,18 @@ pub(crate) mod strings_impl { debug_assert!(!b.is_empty()); debug_assert!(!a.is_empty()); + // Miri has no shim for either libc call, and `bun_url`'s unit tests reach this. + #[cfg(miri)] + { + a.eq_ignore_ascii_case(&b[..a.len()]) + } // SAFETY: a.len() <= b.len() here; strncasecmp reads at most a.len() bytes from each. - #[cfg(not(windows))] + #[cfg(all(not(miri), not(windows)))] unsafe { libc::strncasecmp(a.as_ptr().cast(), b.as_ptr().cast(), a.len()) == 0 } // Windows MSVC libc has no `strncasecmp`; `_strnicmp` is the equivalent. - #[cfg(windows)] + #[cfg(all(not(miri), windows))] unsafe { unsafe extern "C" { fn _strnicmp( diff --git a/src/url/lib.rs b/src/url/lib.rs index e6f4eb80e797..be4a855b6602 100644 --- a/src/url/lib.rs +++ b/src/url/lib.rs @@ -1845,6 +1845,63 @@ mod tests { assert_eq!(url.hash, b"#frag?x=2"); } + #[test] + fn the_authority_ends_where_new_url_ends_it() { + let url = URL::parse(br"http://u:p@first.example:8080\x@second.example/path"); + assert_eq!((url.username, url.password), (&b"u"[..], &b"p"[..])); + assert_eq!( + (url.hostname, url.port), + (&b"first.example"[..], &b"8080"[..]) + ); + + let url = URL::parse(b"HTTPS://u:p@first.example:8443#@second.example/"); + assert_eq!((url.username, url.password), (&b"u"[..], &b"p"[..])); + assert_eq!( + (url.hostname, url.port), + (&b"first.example"[..], &b"8443"[..]) + ); + + // In a scheme that is not special, a `\` is part of the userinfo, as for `new URL()`. + let url = URL::parse(br"socks5://u:p@first.example\x@second.example/"); + assert_eq!( + (url.username, url.password), + (&b"u"[..], &br"p@first.example\x"[..]) + ); + assert_eq!(url.hostname, b"second.example"); + } + + #[test] + fn a_proxy_keeps_a_domain_login() { + let proxy = URL::parse_single_reader(br"http://DOMAIN\user:pass@proxy.example:8080"); + assert_eq!( + (proxy.username, proxy.password), + (&br"DOMAIN\user"[..], &b"pass"[..]) + ); + assert_eq!( + (proxy.hostname, proxy.port), + (&b"proxy.example"[..], &b"8080"[..]) + ); + assert_eq!( + &*proxy.href_without_userinfo(), + b"http://proxy.example:8080" + ); + } + + #[test] + fn no_host_is_read_behind_a_second_scheme() { + let url = URL::parse(b"http:first.example://second.example/"); + assert_eq!(url.protocol, b"http:first.example"); + assert_eq!(url.hostname, b"http"); + + let url = URL::parse(b"blob:http://second.example/id"); + assert_eq!(url.protocol, b"blob:http"); + assert_eq!(url.hostname, b"blob"); + + let url = URL::parse(b"localhost:3000/api"); + assert_eq!(url.protocol, b""); + assert_eq!((url.hostname, url.port), (&b"localhost"[..], &b"3000"[..])); + } + #[test] fn join_normalizes_the_path() { assert_eq!( From 108bc50d32ad4125bf550fa4ab1978e1f9b0ef37 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 17 Sep 2026 00:58:31 +0000 Subject: [PATCH 5/5] url: a scheme starts with a letter `parse_protocol` read an authority behind any run of RFC 3986 scheme bytes. The RFC also wants the first byte to be a letter, and the comment on that check names the RFC. `1http://host/` now has the protocol `1http` and no authority is read behind it. No request changes: every caller that dials refuses a protocol that is not http, https or s3, and `new URL()` rejects such a string. --- src/url/lib.rs | 13 +++++++++---- test/js/bun/http/proxy.test.ts | 1 + 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/src/url/lib.rs b/src/url/lib.rs index be4a855b6602..48aae737834a 100644 --- a/src/url/lib.rs +++ b/src/url/lib.rs @@ -847,10 +847,11 @@ impl<'a> URL<'a> { b':' => { if i + 3 <= str.len() && str[i + 1] == b'/' && str[i + 2] == b'/' { self.protocol = &str[0..i]; - // RFC 3986 §3.1: only behind a scheme of these bytes is there an authority. - let is_scheme = self.protocol.iter().all(|byte| { - matches!(byte, b'a'..=b'z' | b'A'..=b'Z' | b'0'..=b'9' | b'+' | b'-' | b'.') - }); + // RFC 3986 §3.1: only behind `ALPHA *( ALPHA / DIGIT / "+" / "-" / "." )` is there an authority. + let is_scheme = self.protocol.first().is_some_and(u8::is_ascii_alphabetic) + && self.protocol.iter().all(|byte| { + matches!(byte, b'a'..=b'z' | b'A'..=b'Z' | b'0'..=b'9' | b'+' | b'-' | b'.') + }); return is_scheme.then(|| u32::try_from(i + 3).expect("int cast")); } } @@ -1897,6 +1898,10 @@ mod tests { assert_eq!(url.protocol, b"blob:http"); assert_eq!(url.hostname, b"blob"); + let url = URL::parse(b"1http://second.example/"); + assert_eq!(url.protocol, b"1http"); + assert_eq!(url.hostname, b"1http"); + let url = URL::parse(b"localhost:3000/api"); assert_eq!(url.protocol, b""); assert_eq!((url.hostname, url.port), (&b"localhost"[..], &b"3000"[..])); diff --git a/test/js/bun/http/proxy.test.ts b/test/js/bun/http/proxy.test.ts index 8fc3350c9cf9..47f1aaa21358 100644 --- a/test/js/bun/http/proxy.test.ts +++ b/test/js/bun/http/proxy.test.ts @@ -2498,6 +2498,7 @@ describe("proxy resolution", () => { // and the name this reads is one no user can have written down. ["http:other.test://example.test/", "", "", "http", "other.test:"], [String.raw`http:\other.test://example.test/`, "", "", "http", String.raw`\other.test:`], + ["1http://example.test/", "", "", "1http", ""], ["git+ssh://user@example.test/repo.git", "user", "", "example.test", ""], // IPv6 hosts keep their brackets in `hostname` ["http://[::1]:3000/", "", "", "[::1]", "3000"],