From ed4246edf7eaefef112cbe44943811c42e7e3468 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 16 Jul 2026 08:14:16 +0000 Subject: [PATCH 1/5] install: honor .npmrc //host/:_authToken= for tarballs on a different host than the registry 1.4 added a same-origin guard on the registry Authorization header for tarball downloads: a manifest's dist.tarball can point anywhere, so a malicious registry could otherwise exfiltrate the scope token to an attacker-controlled host. That guard also breaks legitimate setups (Artifactory/Nexus with a separate tarball/CDN host) with no config escape hatch, because .npmrc //cdn-host/:_authToken= lines that do not match a configured registry were parsed and then dropped. npm's own model (npm-registry-fetch getAuth) is: look up auth by the URL being fetched. If the tarball host differs from the registry host, npm sends no Authorization unless the user has an explicit //tarball-host/ nerf-dart entry, in which case it sends that entry's credential. Keep .npmrc //host/:*= entries that match no registry and consult them in for_tarball when the tarball origin differs from the scope registry origin. The same-origin guard is unchanged: a registry still cannot forward scope credentials to a host the user has not configured. --- docs/pm/npmrc.mdx | 13 ++ src/ini/lib.rs | 114 ++++++------ src/install/NetworkTask.rs | 73 ++++++-- .../PackageManager/PackageManagerOptions.rs | 10 ++ src/options_types/schema.rs | 7 + test/cli/install/bun-install.test.ts | 166 ++++++++++++++++++ 6 files changed, 320 insertions(+), 63 deletions(-) diff --git a/docs/pm/npmrc.mdx b/docs/pm/npmrc.mdx index d4bcfa78ff6d..b56de3305eeb 100644 --- a/docs/pm/npmrc.mdx +++ b/docs/pm/npmrc.mdx @@ -81,6 +81,19 @@ The equivalent `bunfig.toml` option is to add a key in [`install.scopes`](/runti myorg = { url = "http://localhost:4873/", username = "myusername", password = "$NPM_PASSWORD" } ``` +#### Tarballs served from a different host than the registry + +Bun only forwards a registry's `Authorization` header to tarball downloads from the same origin as that registry. If your registry serves package manifests from one host and tarballs from a separate authenticated host (for example, Artifactory or Nexus with a CDN), add an auth entry for the tarball host too: + +```ini .npmrc icon="npm" +@myorg:registry=https://packages.example.com/npm/ +//packages.example.com/npm/:_authToken=${NPM_TOKEN} +# dist.tarball points at a separate authenticated host: +//cdn.example.com/:_authToken=${NPM_TOKEN} +``` + +Bun looks up tarball auth by the host and path prefix of the download URL, the same way npm does. + ### `link-workspace-packages`: Control workspace package installation Controls how workspace packages are installed when available locally: diff --git a/src/ini/lib.rs b/src/ini/lib.rs index 194aedb197ec..1b419166d4d1 100644 --- a/src/ini/lib.rs +++ b/src/ini/lib.rs @@ -1683,12 +1683,14 @@ mod draft { for conf_item in configs.iter() { let conf_item_url = URL::parse(&conf_item.registry_url); + let mut matched_any_registry = false; if bun_core::without_trailing_slash(&default_registry_host) == bun_core::without_trailing_slash(conf_item_url.host) && bun_core::without_trailing_slash(&default_registry_pathname) == bun_core::without_trailing_slash(conf_item_url.pathname) { + matched_any_registry = true; // Apply config to default registry let v: &mut NpmRegistry = 'brk: { if let Some(r) = install.default_registry.as_mut() { @@ -1707,32 +1709,7 @@ mod draft { install.default_registry.as_mut().unwrap() }; - match conf_item.optname { - ConfigOpt::_AuthToken => { - if let Some(x) = conf_item.dupe_value_decoded(log, source)? { - v.token = x; - } - } - ConfigOpt::Username => { - if let Some(x) = conf_item.dupe_value_decoded(log, source)? { - v.username = x; - } - } - ConfigOpt::_Password => { - if let Some(x) = conf_item.dupe_value_decoded(log, source)? { - v.password = x; - } - } - ConfigOpt::_Auth => { - handle_auth(v, conf_item, log, source)?; - } - ConfigOpt::Email => { - if let Some(x) = conf_item.dupe_value_decoded(log, source)? { - v.email = x; - } - } - ConfigOpt::Certfile | ConfigOpt::Keyfile => unreachable!(), - } + apply_config_opt(v, conf_item, log, source)?; } // `keys()`/`values_mut()` on the same map alias; since @@ -1758,37 +1735,35 @@ mod draft { continue; } } + matched_any_registry = true; // Apply config to scoped registry - match conf_item.optname { - ConfigOpt::_AuthToken => { - if let Some(x) = conf_item.dupe_value_decoded(log, source)? { - v.token = x; - } - } - ConfigOpt::Username => { - if let Some(x) = conf_item.dupe_value_decoded(log, source)? { - v.username = x; - } - } - ConfigOpt::_Password => { - if let Some(x) = conf_item.dupe_value_decoded(log, source)? { - v.password = x; - } - } - ConfigOpt::_Auth => { - handle_auth(v, conf_item, log, source)?; - } - ConfigOpt::Email => { - if let Some(x) = conf_item.dupe_value_decoded(log, source)? { - v.email = x; - } - } - ConfigOpt::Certfile | ConfigOpt::Keyfile => unreachable!(), - } + apply_config_opt(v, conf_item, log, source)?; // We have to keep going as it could match multiple scopes continue; } } + + if !matched_any_registry { + // `//host/path/:*=` entry that matches neither the default + // nor any scoped registry. npm looks up auth by the URL + // being fetched, so such an entry can still apply to a + // tarball download whose `dist.tarball` points at this + // origin. Group by `.npmrc` URL so multiple options for the + // same host accumulate into one `NpmRegistry`. + let v: &mut NpmRegistry = 'brk: { + for entry in install.tarball_url_auth.iter_mut() { + if *entry.url == *conf_item.registry_url { + break 'brk entry; + } + } + install.tarball_url_auth.push(NpmRegistry { + url: Box::<[u8]>::from(&*conf_item.registry_url), + ..Default::default() + }); + install.tarball_url_auth.last_mut().unwrap() + }; + apply_config_opt(v, conf_item, log, source)?; + } } drop(url_map); @@ -1923,6 +1898,41 @@ mod draft { }) } + fn apply_config_opt( + v: &mut NpmRegistry, + conf_item: &ConfigItem, + log: &mut Log, + source: &Source, + ) -> OOM<()> { + match conf_item.optname { + ConfigOpt::_AuthToken => { + if let Some(x) = conf_item.dupe_value_decoded(log, source)? { + v.token = x; + } + } + ConfigOpt::Username => { + if let Some(x) = conf_item.dupe_value_decoded(log, source)? { + v.username = x; + } + } + ConfigOpt::_Password => { + if let Some(x) = conf_item.dupe_value_decoded(log, source)? { + v.password = x; + } + } + ConfigOpt::_Auth => { + handle_auth(v, conf_item, log, source)?; + } + ConfigOpt::Email => { + if let Some(x) = conf_item.dupe_value_decoded(log, source)? { + v.email = x; + } + } + ConfigOpt::Certfile | ConfigOpt::Keyfile => unreachable!(), + } + Ok(()) + } + fn handle_auth( v: &mut NpmRegistry, conf_item: &ConfigItem, diff --git a/src/install/NetworkTask.rs b/src/install/NetworkTask.rs index 938421d73b89..2261f5d2d8c8 100644 --- a/src/install/NetworkTask.rs +++ b/src/install/NetworkTask.rs @@ -375,6 +375,42 @@ fn append_auth(header_builder: &mut HeaderBuilder, scope: &npm::registry::Scope) header_builder.append("npm-auth-type", "legacy"); } +/// Look up a `.npmrc` `//host/path/:*=` auth entry for a tarball URL. The +/// entry's host (including port) must match exactly; its pathname must be a +/// path prefix of the tarball pathname (npm walks the target path upward until +/// a matching key is found). Returns the entry with the longest matching +/// pathname so more specific entries win. +fn tarball_url_auth_for<'a>( + entries: &'a [npm::registry::Scope], + tarball: &URL<'_>, +) -> Option<&'a npm::registry::Scope> { + let tarball_host = strings::without_trailing_slash(tarball.host); + let tarball_path = tarball.pathname; + let mut best: Option<(&'a npm::registry::Scope, usize)> = None; + for entry in entries { + let entry_url = entry.url.url(); + if strings::without_trailing_slash(entry_url.host) != tarball_host { + continue; + } + let entry_path = strings::without_trailing_slash(entry_url.pathname); + // `without_trailing_slash` keeps a lone `/`; treat it as the root + // prefix that matches every path. + let is_prefix = entry_path.is_empty() + || entry_path == b"/" + || (tarball_path.len() >= entry_path.len() + && tarball_path[..entry_path.len()] == *entry_path + && (tarball_path.len() == entry_path.len() + || tarball_path[entry_path.len()] == b'/')); + if !is_prefix { + continue; + } + if best.is_none_or(|(_, len)| entry_path.len() > len) { + best = Some((entry, entry_path.len())); + } + } + best.map(|(s, _)| s) +} + fn count_auth(header_builder: &mut HeaderBuilder, scope: &npm::registry::Scope) { if !scope.token.is_empty() { header_builder.count("Authorization", ""); @@ -802,28 +838,43 @@ impl NetworkTask { // registries emit `dist.tarball` URLs with the default port spelled // out; without normalization those installs lose the `Authorization` // header and fail with 401. - let send_auth = matches!(authorization, Authorization::AllowAuthorization) && { - let tarball = URL::parse(&self.url_buf); - let registry = scope.url.url(); - tarball.protocol == registry.protocol - && tarball.hostname == registry.hostname - && tarball.get_port_auto() == registry.get_port_auto() - }; + // + // When the origin does NOT match, fall back to `.npmrc` `//host/:*=` + // entries the user configured explicitly for the tarball host + // (`options.tarball_url_auth`), so registries that serve tarballs from + // a separate authenticated origin have a config escape hatch. This is + // how `npm-registry-fetch` resolves auth: by the URL being fetched, + // not by the package scope. + let auth_scope: Option<&npm::registry::Scope> = + if matches!(authorization, Authorization::AllowAuthorization) { + let tarball = URL::parse(&self.url_buf); + let registry = scope.url.url(); + if tarball.protocol == registry.protocol + && tarball.hostname == registry.hostname + && tarball.get_port_auto() == registry.get_port_auto() + { + Some(scope) + } else { + tarball_url_auth_for(&pm.options.tarball_url_auth, &tarball) + } + } else { + tarball_url_auth_for(&pm.options.tarball_url_auth, &URL::parse(&self.url_buf)) + }; self.response_buffer = MutableString::init_empty(); let mut header_builder = HeaderBuilder::default(); let mut header_buf: &'static [u8] = b""; - if send_auth { - count_auth(&mut header_builder, scope); + if let Some(auth_scope) = auth_scope { + count_auth(&mut header_builder, auth_scope); } if header_builder.header_count > 0 { header_builder.allocate()?; - if send_auth { - append_auth(&mut header_builder, scope); + if let Some(auth_scope) = auth_scope { + append_auth(&mut header_builder, auth_scope); } // SAFETY: `written_slice()` is the safe (ptr,len) accessor; only the diff --git a/src/install/PackageManager/PackageManagerOptions.rs b/src/install/PackageManager/PackageManagerOptions.rs index 6db9513be05f..85723c15d1e5 100644 --- a/src/install/PackageManager/PackageManagerOptions.rs +++ b/src/install/PackageManager/PackageManagerOptions.rs @@ -26,6 +26,10 @@ pub struct Options { pub scope: Npm::registry::Scope, pub registries: Npm::registry::Map, + /// `.npmrc` `//host/path/:*=` auth entries that do not match any + /// configured registry. Consulted by tarball downloads when + /// `dist.tarball` points at a different origin than the registry. + pub tarball_url_auth: Vec, pub cache_directory: &'static [u8], pub enable: Enable, pub do_: Do, @@ -104,6 +108,7 @@ impl Default for Options { // Always assigned in `load()` before read. scope: Npm::registry::Scope::default(), registries: Npm::registry::Map::default(), + tarball_url_auth: Vec::new(), cache_directory: b"", enable: Enable::default(), do_: Do::default(), @@ -435,6 +440,11 @@ impl Options { } } + for registry_ in &config.tarball_url_auth { + self.tarball_url_auth + .push(Npm::registry::Scope::from_api(b"", registry_.clone(), env)?); + } + if let Some(ca) = &config.ca { match ca { Api::Ca::List(ca_list) => { diff --git a/src/options_types/schema.rs b/src/options_types/schema.rs index e80f2cad3f7a..52bd3ce95a37 100644 --- a/src/options_types/schema.rs +++ b/src/options_types/schema.rs @@ -273,6 +273,13 @@ pub mod api { pub default_registry: Option, /// scoped pub scoped: Option, + /// `.npmrc` `//host/path/:_authToken=`-style entries whose URL does not + /// match the default registry or any scoped registry. Consulted by + /// tarball downloads when `dist.tarball` points at a host other than + /// the configured registry origin (Artifactory/Nexus with a separate + /// tarball/CDN host). `NpmRegistry::url` holds the raw `.npmrc` URL + /// part (no protocol, e.g. `cdn.example.com/`). + pub tarball_url_auth: Vec, /// lockfile_path pub lockfile_path: Option>, /// save_lockfile_path diff --git a/test/cli/install/bun-install.test.ts b/test/cli/install/bun-install.test.ts index 51f91d596712..8c32dd662c12 100644 --- a/test/cli/install/bun-install.test.ts +++ b/test/cli/install/bun-install.test.ts @@ -800,6 +800,172 @@ describe.concurrent("bun-install", () => { expect(exitCode).toBe(0); }); + // Registry serves manifests from one origin and tarballs from a separate + // authenticated origin (Artifactory/Nexus with a CDN host). The same-origin + // guard above strips the registry Authorization for the CDN host, so users + // add an explicit `.npmrc` `//cdn-host/:_authToken=` entry for it. npm + // honours that (npm-registry-fetch resolves auth by the fetch URL), and bun + // must too: send the user-configured credential to the host the user named, + // and never forward the registry token to any host the user did not name. + it("should send .npmrc //host/:_authToken= to a tarball host that is not the registry", async () => { + const regToken = "registry-token"; + const cdnToken = "cdn-token"; + const tgz = join(import.meta.dir, "registry", "packages", "no-deps", "no-deps-1.0.0.tgz"); + const integrity = "sha512-v4w12JRjUGvfHDUP8vFDwu0gUWu04j0cv9hLb1Abf9VdaXu4XcrddYFTMVBVvmldKViGWH7jrb6xPJRF0wq6gw=="; + + const cdnAuth: (string | null)[] = []; + const unrelatedAuth: (string | null)[] = []; + + // Authenticated tarball host: requires its own Bearer token. + await using cdn = Bun.serve({ + port: 0, + hostname: "127.0.0.1", + async fetch(req) { + cdnAuth.push(req.headers.get("authorization")); + if (req.headers.get("authorization") !== `Bearer ${cdnToken}`) { + return new Response("unauthorized", { status: 401 }); + } + return new Response(Bun.file(tgz)); + }, + }); + + // A third origin the user configured NO auth for. A registry that points + // dist.tarball here must not receive the registry token or the CDN token. + await using unrelated = Bun.serve({ + port: 0, + hostname: "127.0.0.1", + async fetch(req) { + unrelatedAuth.push(req.headers.get("authorization")); + return new Response(Bun.file(tgz)); + }, + }); + + await using registry = Bun.serve({ + port: 0, + hostname: "127.0.0.1", + async fetch(req) { + const url = new URL(req.url); + const manifest = (name: string, host: number) => + Response.json({ + name, + "dist-tags": { latest: "1.0.0" }, + versions: { + "1.0.0": { + name, + version: "1.0.0", + dist: { integrity, tarball: `http://127.0.0.1:${host}/${name}/-/${name}-1.0.0.tgz` }, + }, + }, + }); + if (url.pathname === "/on-cdn") return manifest("on-cdn", cdn.port); + if (url.pathname === "/on-unrelated") return manifest("on-unrelated", unrelated.port); + return new Response("not found", { status: 404 }); + }, + }); + + using dir = tempDir("tarball-auth-cdn", { + "package.json": JSON.stringify({ + name: "app", + version: "1.0.0", + dependencies: { "on-cdn": "1.0.0", "on-unrelated": "1.0.0" }, + }), + ".npmrc": [ + `registry=http://127.0.0.1:${registry.port}/`, + `//127.0.0.1:${registry.port}/:_authToken=${regToken}`, + // User-configured credential for the tarball host; must be sent. + `//127.0.0.1:${cdn.port}/:_authToken=${cdnToken}`, + // No entry for the `unrelated` host. + ``, + ].join("\n"), + }); + + await using proc = spawn({ + cmd: [bunExe(), "install"], + cwd: String(dir), + env: { ...env, BUN_INSTALL_CACHE_DIR: join(String(dir), ".cache") }, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect({ stderr, cdnAuth, unrelatedAuth }).toEqual({ + stderr: expect.stringContaining("Saved lockfile"), + // CDN host received the user-configured CDN token (not the registry token). + cdnAuth: [`Bearer ${cdnToken}`], + // Unconfigured host received no Authorization at all. + unrelatedAuth: [null], + }); + expect(stdout).toContain("2 packages installed"); + expect(exitCode).toBe(0); + }); + + it("should send .npmrc //host/:_auth= Basic auth to a tarball host that is not the registry", async () => { + const tgz = join(import.meta.dir, "registry", "packages", "no-deps", "no-deps-1.0.0.tgz"); + const integrity = "sha512-v4w12JRjUGvfHDUP8vFDwu0gUWu04j0cv9hLb1Abf9VdaXu4XcrddYFTMVBVvmldKViGWH7jrb6xPJRF0wq6gw=="; + const basic = Buffer.from("cdnuser:cdnpass").toString("base64"); + + const cdnAuth: (string | null)[] = []; + + await using cdn = Bun.serve({ + port: 0, + hostname: "127.0.0.1", + async fetch(req) { + cdnAuth.push(req.headers.get("authorization")); + if (req.headers.get("authorization") !== `Basic ${basic}`) { + return new Response("unauthorized", { status: 401 }); + } + return new Response(Bun.file(tgz)); + }, + }); + + await using registry = Bun.serve({ + port: 0, + hostname: "127.0.0.1", + async fetch(req) { + if (new URL(req.url).pathname === "/pkg") { + return Response.json({ + name: "pkg", + "dist-tags": { latest: "1.0.0" }, + versions: { + "1.0.0": { + name: "pkg", + version: "1.0.0", + dist: { integrity, tarball: `http://127.0.0.1:${cdn.port}/pkg/-/pkg-1.0.0.tgz` }, + }, + }, + }); + } + return new Response("not found", { status: 404 }); + }, + }); + + using dir = tempDir("tarball-auth-cdn-basic", { + "package.json": JSON.stringify({ name: "app", version: "1.0.0", dependencies: { pkg: "1.0.0" } }), + ".npmrc": [ + `registry=http://127.0.0.1:${registry.port}/`, + `//127.0.0.1:${cdn.port}/:username=cdnuser`, + `//127.0.0.1:${cdn.port}/:_password=${Buffer.from("cdnpass").toString("base64")}`, + ``, + ].join("\n"), + }); + + await using proc = spawn({ + cmd: [bunExe(), "install"], + cwd: String(dir), + env: { ...env, BUN_INSTALL_CACHE_DIR: join(String(dir), ".cache") }, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect({ stderr, cdnAuth }).toEqual({ + stderr: expect.stringContaining("Saved lockfile"), + cdnAuth: [`Basic ${basic}`], + }); + expect(stdout).toContain("1 package installed"); + expect(exitCode).toBe(0); + }); + it("should handle empty string in dependencies", async () => { await withContext(defaultOpts, async ctx => { const urls: string[] = []; From 346630648497241c8c52ccced660a62f27e6d547 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Thu, 16 Jul 2026 08:16:47 +0000 Subject: [PATCH 2/5] [autofix.ci] apply automated fixes --- src/install/PackageManager/PackageManagerOptions.rs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/install/PackageManager/PackageManagerOptions.rs b/src/install/PackageManager/PackageManagerOptions.rs index 85723c15d1e5..b012c1e2d119 100644 --- a/src/install/PackageManager/PackageManagerOptions.rs +++ b/src/install/PackageManager/PackageManagerOptions.rs @@ -441,8 +441,11 @@ impl Options { } for registry_ in &config.tarball_url_auth { - self.tarball_url_auth - .push(Npm::registry::Scope::from_api(b"", registry_.clone(), env)?); + self.tarball_url_auth.push(Npm::registry::Scope::from_api( + b"", + registry_.clone(), + env, + )?); } if let Some(ca) = &config.ca { From e89bba7f31a096283fbf1bd0f95a5be0e5f23b46 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 16 Jul 2026 08:38:15 +0000 Subject: [PATCH 3/5] address review: default-port normalization, test rename, direct-URL tarball test - tarball_url_auth_for: match a portless .npmrc key against a tarball URL whose port is the scheme default (npm builds the key from WHATWG URL.host, which strips default ports) - rename the Basic-auth test to reflect what it exercises (username+_password, not _auth) - add a test for direct-URL tarball dependencies (NoAuthorization path) with a matching .npmrc //host/ entry - doc comment on Authorization::NoAuthorization clarifying it gates only the registry-scope credential --- src/install/NetworkTask.rs | 16 +++++++-- test/cli/install/bun-install.test.ts | 50 +++++++++++++++++++++++++++- 2 files changed, 63 insertions(+), 3 deletions(-) diff --git a/src/install/NetworkTask.rs b/src/install/NetworkTask.rs index 2261f5d2d8c8..592c950e56df 100644 --- a/src/install/NetworkTask.rs +++ b/src/install/NetworkTask.rs @@ -344,6 +344,8 @@ impl NetworkTask { #[derive(Clone, Copy)] pub enum Authorization { + /// Do not attach the package scope's registry credential. `.npmrc` + /// `//host/:*=` entries that match the tarball URL are still honored. NoAuthorization, AllowAuthorization, } @@ -384,12 +386,22 @@ fn tarball_url_auth_for<'a>( entries: &'a [npm::registry::Scope], tarball: &URL<'_>, ) -> Option<&'a npm::registry::Scope> { - let tarball_host = strings::without_trailing_slash(tarball.host); let tarball_path = tarball.pathname; + // `.npmrc` keys have no scheme, so a portless key can only mean "default + // port for the tarball's scheme" (npm builds the key from + // `new URL(uri).host`, which strips default ports). Normalize on the + // tarball side so `//cdn/:_authToken=` matches `https://cdn:443/...`. + let tarball_port_is_default = tarball.get_port() == Some(tarball.get_default_port()); let mut best: Option<(&'a npm::registry::Scope, usize)> = None; for entry in entries { let entry_url = entry.url.url(); - if strings::without_trailing_slash(entry_url.host) != tarball_host { + let host_matches = if entry_url.port.is_empty() && tarball_port_is_default { + entry_url.hostname == tarball.hostname + } else { + strings::without_trailing_slash(entry_url.host) + == strings::without_trailing_slash(tarball.host) + }; + if !host_matches { continue; } let entry_path = strings::without_trailing_slash(entry_url.pathname); diff --git a/test/cli/install/bun-install.test.ts b/test/cli/install/bun-install.test.ts index 8c32dd662c12..3aaad0f57a02 100644 --- a/test/cli/install/bun-install.test.ts +++ b/test/cli/install/bun-install.test.ts @@ -899,7 +899,7 @@ describe.concurrent("bun-install", () => { expect(exitCode).toBe(0); }); - it("should send .npmrc //host/:_auth= Basic auth to a tarball host that is not the registry", async () => { + it("should send .npmrc //host/:username+_password Basic auth to a tarball host that is not the registry", async () => { const tgz = join(import.meta.dir, "registry", "packages", "no-deps", "no-deps-1.0.0.tgz"); const integrity = "sha512-v4w12JRjUGvfHDUP8vFDwu0gUWu04j0cv9hLb1Abf9VdaXu4XcrddYFTMVBVvmldKViGWH7jrb6xPJRF0wq6gw=="; const basic = Buffer.from("cdnuser:cdnpass").toString("base64"); @@ -966,6 +966,54 @@ describe.concurrent("bun-install", () => { expect(exitCode).toBe(0); }); + // A direct URL tarball dependency ("pkg": "https://host/pkg.tgz") is + // enqueued with Authorization::NoAuthorization (no registry scope applies). + // npm-registry-fetch still resolves auth by the fetch URL for these, so a + // matching `.npmrc` `//host/:_authToken=` entry must be honored. + it("should send .npmrc //host/:_authToken= to a direct URL tarball dependency", async () => { + const cdnToken = "cdn-token"; + const tgz = join(import.meta.dir, "registry", "packages", "no-deps", "no-deps-1.0.0.tgz"); + + const cdnAuth: (string | null)[] = []; + + await using cdn = Bun.serve({ + port: 0, + hostname: "127.0.0.1", + async fetch(req) { + cdnAuth.push(req.headers.get("authorization")); + if (req.headers.get("authorization") !== `Bearer ${cdnToken}`) { + return new Response("unauthorized", { status: 401 }); + } + return new Response(Bun.file(tgz)); + }, + }); + + using dir = tempDir("tarball-auth-direct-url", { + "package.json": JSON.stringify({ + name: "app", + version: "1.0.0", + dependencies: { pkg: `http://127.0.0.1:${cdn.port}/pkg-1.0.0.tgz` }, + }), + ".npmrc": `//127.0.0.1:${cdn.port}/:_authToken=${cdnToken}\n`, + }); + + await using proc = spawn({ + cmd: [bunExe(), "install"], + cwd: String(dir), + env: { ...env, BUN_INSTALL_CACHE_DIR: join(String(dir), ".cache") }, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect({ stderr, cdnAuth }).toEqual({ + stderr: expect.stringContaining("Saved lockfile"), + cdnAuth: [`Bearer ${cdnToken}`], + }); + expect(stdout).toContain("1 package installed"); + expect(exitCode).toBe(0); + }); + it("should handle empty string in dependencies", async () => { await withContext(defaultOpts, async ctx => { const urls: string[] = []; From 5935d02551b2ddf6348a3a8463c48de4580fd196 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 16 Jul 2026 09:03:54 +0000 Subject: [PATCH 4/5] ini: rebuild tarball_url_auth from scratch on each .npmrc pass The configs vec accumulates across .npmrc files and is re-iterated in full each call; clearing tarball_url_auth before the loop ensures an entry that was unmatched in the global file is not left stale when the project file introduces a scoped registry at the same host. --- src/ini/lib.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/ini/lib.rs b/src/ini/lib.rs index 1b419166d4d1..9fdd83b8a40e 100644 --- a/src/ini/lib.rs +++ b/src/ini/lib.rs @@ -1681,6 +1681,12 @@ mod draft { } } + // `configs` accumulates across .npmrc files and is re-iterated in + // full each call, so rebuild tarball_url_auth from scratch: an + // entry that was unmatched in an earlier file may now match a + // scoped registry introduced by this file. + install.tarball_url_auth.clear(); + for conf_item in configs.iter() { let conf_item_url = URL::parse(&conf_item.registry_url); let mut matched_any_registry = false; From d45c4044320432f876f7e3f91bd8037a18a37cfb Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 16 Jul 2026 09:37:35 +0000 Subject: [PATCH 5/5] ci: retrigger