diff --git a/docs/pm/cli/add.mdx b/docs/pm/cli/add.mdx
index 15b4b286eb8e..74570b804982 100644
--- a/docs/pm/cli/add.mdx
+++ b/docs/pm/cli/add.mdx
@@ -231,6 +231,8 @@ bun add zod@https://registry.npmjs.org/zod/-/zod-3.21.4.tgz
}
```
+A tarball URL can carry credentials, such as `https://user:password@example.com/zod-3.21.4.tgz`. Bun sends them as an `Authorization: Basic` header and requests the URL without them, like npm. The URL, credentials included, is written to `package.json` and to the lockfile.
+
---
diff --git a/src/install/NetworkTask.rs b/src/install/NetworkTask.rs
index 615177ac1646..620edd60a07f 100644
--- a/src/install/NetworkTask.rs
+++ b/src/install/NetworkTask.rs
@@ -408,6 +408,40 @@ fn count_auth(header_builder: &mut HeaderBuilder, scope: &npm::registry::Scope)
header_builder.count("npm-auth-type", "legacy");
}
+/// Splits `http://user:pass@host/pkg.tgz` into `user:pass` and
+/// `http://host/pkg.tgz`. `None` when the authority has no `@`; the `@` of a
+/// scoped package in the path (`/@scope/pkg/-/pkg.tgz`) is not one.
+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'@')?;
+
+ let mut without_userinfo = Vec::with_capacity(url.len() - (at + 1));
+ without_userinfo.extend_from_slice(&url[..authority_start]);
+ without_userinfo.extend_from_slice(&rest[at + 1..]);
+ Some((&rest[..at], without_userinfo.into_boxed_slice()))
+}
+
+/// `Basic base64(userinfo)`, the header npm sends for credentials embedded in a
+/// tarball URL: minipass-fetch (`getNodeRequestOptions` in `lib/request.js`)
+/// hands the URL's `username:password` to node's `auth` option as is, so
+/// nothing is percent-decoded here either, and a userinfo without a `:` is a
+/// username with an empty password.
+fn basic_authorization_from_userinfo(userinfo: &[u8]) -> Vec {
+ const SCHEME: &[u8] = b"Basic ";
+ let mut user_pass = Vec::with_capacity(userinfo.len() + 1);
+ user_pass.extend_from_slice(userinfo);
+ if !strings::contains_char(userinfo, b':') {
+ user_pass.push(b':');
+ }
+ let mut value = vec![0u8; SCHEME.len() + bun_core::base64::encode_len(&user_pass)];
+ value[..SCHEME.len()].copy_from_slice(SCHEME);
+ let encoded_len = bun_core::base64::encode(&mut value[SCHEME.len()..], &user_pass);
+ value.truncate(SCHEME.len() + encoded_len);
+ value
+}
+
#[derive(thiserror::Error, Debug, strum::IntoStaticStr)]
pub enum ForManifestError {
#[error("OutOfMemory")]
@@ -784,6 +818,21 @@ impl NetworkTask {
return Err(ForTarballError::InvalidURL);
}
+ // `"dep": "https://user:pass@host/dep.tgz"`: the credentials become a
+ // header, as npm sends them, and the URL is requested without them.
+ // They cannot stay in the URL: `bun_url` keeps the userinfo in `origin`,
+ // and the HTTP client compares origins to decide whether `Authorization`
+ // follows a redirect, so a redirect to the same host would lose it.
+ let url_authorization: Option> = match split_url_userinfo(&self.url_buf) {
+ Some((userinfo, url_without_userinfo)) => {
+ let value =
+ (!userinfo.is_empty()).then(|| basic_authorization_from_userinfo(userinfo));
+ self.url_buf = url_without_userinfo;
+ value
+ }
+ None => None,
+ };
+
// Only attach the registry `Authorization` header when the tarball URL
// origin matches the configured registry scope origin. The npm manifest
// is registry-controlled, so a malicious registry could otherwise point
@@ -815,9 +864,23 @@ impl NetworkTask {
count_auth(&mut header_builder, scope);
}
+ // Same precedence as npm, where node derives `Authorization` from the
+ // URL only when the request does not carry one already: credentials
+ // configured for the registry win over the ones embedded in the URL.
+ let url_authorization = match url_authorization {
+ Some(value) if header_builder.header_count == 0 => {
+ header_builder.count("Authorization", &value);
+ Some(value)
+ }
+ _ => None,
+ };
+
let header_buf: &'static [u8] = if header_builder.header_count > 0 {
header_builder.allocate()?;
- append_auth(&mut header_builder, scope);
+ match &url_authorization {
+ Some(value) => header_builder.append("Authorization", value),
+ None => append_auth(&mut header_builder, scope),
+ }
debug_assert_eq!(header_builder.content.len, header_builder.content.cap);
self.header_buf = header_builder.content.move_to_slice();
// SAFETY: `self.header_buf` outlives the request; it is freed when the slot returns to the pool.
diff --git a/test/cli/install/bun-install.test.ts b/test/cli/install/bun-install.test.ts
index e64ecf3dcf4e..522908b0d37d 100644
--- a/test/cli/install/bun-install.test.ts
+++ b/test/cli/install/bun-install.test.ts
@@ -854,6 +854,223 @@ describe.concurrent("bun-install", () => {
expect(exitCode).toBe(0);
});
+ // A tarball URL with credentials in it is downloaded the way npm downloads
+ // it: the userinfo becomes `Authorization: Basic base64(user:pass)` and the
+ // request goes to the URL without it (`NetworkTask::for_tarball`).
+ describe.concurrent("credentials embedded in a tarball URL", () => {
+ const tgz = join(import.meta.dir, "registry", "packages", "no-deps", "no-deps-1.0.0.tgz");
+ const tarballPath = "/cdn/no-deps-1.0.0.tgz";
+ const basic = (userPass: string) => `Basic ${Buffer.from(userPass).toString("base64")}`;
+ const installed = {
+ stdout: expect.stringContaining("1 package installed"),
+ stderr: expect.stringContaining("Saved lockfile"),
+ exitCode: 0,
+ };
+
+ type Received = { url: string; authorization: string | null };
+
+ function recording(received: Received[], handler: (req: Request, server: { port: number }) => Response) {
+ return (req: Request, server: { port: number }) => {
+ received.push({ url: req.url, authorization: req.headers.get("authorization") });
+ return handler(req, server);
+ };
+ }
+
+ // Serves `tgz` to `.tgz` requests carrying exactly `authorization` and
+ // answers 401 to the others. A request under `/redirect/` is first
+ // redirected to `redirectTo`, or to the same file under `/cdn/`.
+ function serveTarball(received: Received[], authorization: string | null, redirectTo?: string) {
+ return Bun.serve({
+ port: 0,
+ hostname: "127.0.0.1",
+ fetch: recording(received, (req, server) => {
+ const { pathname } = new URL(req.url);
+ if (pathname.startsWith("/redirect/")) {
+ const name = pathname.slice("/redirect/".length);
+ return Response.redirect(redirectTo ?? `http://127.0.0.1:${server.port}/cdn/${name}`, 302);
+ }
+ if (req.headers.get("authorization") !== authorization) {
+ return new Response("unauthorized", { status: 401 });
+ }
+ return new Response(file(tgz));
+ }),
+ });
+ }
+
+ // `bun install` of a project whose only dependency `no-deps` is `dependency`.
+ async function install(dependency: string, files: Record = {}, args: string[] = []) {
+ using dir = tempDir("tarball-url-credentials", {
+ "package.json": JSON.stringify({ name: "app", version: "1.0.0", dependencies: { "no-deps": dependency } }),
+ ...files,
+ });
+ await using proc = spawn({
+ cmd: [bunExe(), "install", ...args],
+ 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]);
+ return { stdout, stderr, exitCode };
+ }
+
+ // Each row is the userinfo of the dependency URL and the `user:pass` the
+ // header must encode. It is sent as written: like npm (checked with npm
+ // 11), a missing password is sent as an empty one and percent-encoding is
+ // left alone. npm would percent-encode the second colon of the last row
+ // because it serializes the URL first.
+ it.each([
+ ["a username and a password", "carol:s3cret", "carol:s3cret", []],
+ ["a username and a password, isolated linker", "carol:s3cret", "carol:s3cret", ["--linker", "isolated"]],
+ ["a username only", "carol", "carol:", []],
+ ["a password only", ":s3cret", ":s3cret", []],
+ ["a percent-encoded password", "carol:s3%40cret", "carol:s3%40cret", []],
+ ["a password containing a colon", "carol:s3:cret", "carol:s3:cret", []],
+ ])("sends %s as Basic authorization", async (_, userinfo, userPass, args) => {
+ const authorization = basic(userPass);
+ const received: Received[] = [];
+ await using server = serveTarball(received, authorization);
+
+ const result = await install(`http://${userinfo}@127.0.0.1:${server.port}${tarballPath}`, {}, args);
+
+ expect({ received, ...result }).toEqual({
+ received: [{ url: `http://127.0.0.1:${server.port}${tarballPath}`, authorization }],
+ ...installed,
+ });
+ });
+
+ it("does not take the @ of a scoped package path for credentials", async () => {
+ const received: Received[] = [];
+ await using server = serveTarball(received, null);
+ const scopedPath = "/@scope/no-deps/-/no-deps-1.0.0.tgz";
+
+ const result = await install(`http://127.0.0.1:${server.port}${scopedPath}`);
+
+ expect({ received, ...result }).toEqual({
+ received: [{ url: `http://127.0.0.1:${server.port}${scopedPath}`, authorization: null }],
+ ...installed,
+ });
+ });
+
+ it("keeps the credentials across a redirect within the host", async () => {
+ const received: Received[] = [];
+ await using server = serveTarball(received, basic("carol:s3cret"));
+
+ const result = await install(`http://carol:s3cret@127.0.0.1:${server.port}/redirect/no-deps-1.0.0.tgz`);
+
+ expect({ received, ...result }).toEqual({
+ received: [
+ { url: `http://127.0.0.1:${server.port}/redirect/no-deps-1.0.0.tgz`, authorization: basic("carol:s3cret") },
+ { url: `http://127.0.0.1:${server.port}${tarballPath}`, authorization: basic("carol:s3cret") },
+ ],
+ ...installed,
+ });
+ });
+
+ it("drops the credentials on a redirect to another host", async () => {
+ // The same machine, reached under a hostname other than the one the
+ // credentials were written for. This host serves the tarball regardless.
+ const otherHostReceived: Received[] = [];
+ await using otherHost = Bun.serve({
+ port: 0,
+ fetch: recording(otherHostReceived, () => new Response(file(tgz))),
+ });
+ const received: Received[] = [];
+ await using server = serveTarball(received, null, `http://localhost:${otherHost.port}${tarballPath}`);
+
+ const result = await install(`http://carol:s3cret@127.0.0.1:${server.port}/redirect/no-deps-1.0.0.tgz`);
+
+ expect({ received, otherHostReceived, ...result }).toEqual({
+ received: [
+ { url: `http://127.0.0.1:${server.port}/redirect/no-deps-1.0.0.tgz`, authorization: basic("carol:s3cret") },
+ ],
+ otherHostReceived: [{ url: `http://localhost:${otherHost.port}${tarballPath}`, authorization: null }],
+ ...installed,
+ });
+ });
+
+ it("reports a rejected download by the URL without the credentials", async () => {
+ const received: Received[] = [];
+ await using server = serveTarball(received, basic("carol:s3cret"));
+
+ const result = await install(`http://carol:wrong@127.0.0.1:${server.port}${tarballPath}`);
+
+ expect({ received, ...result }).toEqual({
+ received: [{ url: `http://127.0.0.1:${server.port}${tarballPath}`, authorization: basic("carol:wrong") }],
+ stdout: expect.stringContaining("bun install v1."),
+ stderr: expect.stringContaining(`error: GET http://127.0.0.1:${server.port}${tarballPath} - 401`),
+ exitCode: 1,
+ });
+ });
+
+ // A registry whose manifest puts credentials into `dist.tarball`. As with
+ // npm, the credentials configured for the registry take precedence; the
+ // URL's are used when the registry has none.
+ describe.concurrent("in the dist.tarball URL of a registry manifest", () => {
+ const token = "registry-token";
+ const distPath = "/no-deps/-/no-deps-1.0.0.tgz";
+
+ function serveRegistry(received: Received[], tarballAuthorization: string | null) {
+ return Bun.serve({
+ port: 0,
+ hostname: "127.0.0.1",
+ fetch: recording(received, (req, server) => {
+ const { pathname } = new URL(req.url);
+ if (pathname === "/no-deps") {
+ return Response.json({
+ name: "no-deps",
+ "dist-tags": { latest: "1.0.0" },
+ versions: {
+ "1.0.0": {
+ name: "no-deps",
+ version: "1.0.0",
+ dist: { tarball: `http://dist:d1st@127.0.0.1:${server.port}${distPath}` },
+ },
+ },
+ });
+ }
+ if (pathname === distPath && req.headers.get("authorization") === tarballAuthorization) {
+ return new Response(file(tgz));
+ }
+ return new Response("unauthorized", { status: 401 });
+ }),
+ });
+ }
+
+ it("sends the registry's credentials when it has some", async () => {
+ const received: Received[] = [];
+ await using registry = serveRegistry(received, `Bearer ${token}`);
+
+ const result = await install("1.0.0", {
+ ".npmrc": `registry=http://127.0.0.1:${registry.port}/\n//127.0.0.1:${registry.port}/:_authToken=${token}\n`,
+ });
+
+ expect({ received, ...result }).toEqual({
+ received: [
+ { url: `http://127.0.0.1:${registry.port}/no-deps`, authorization: `Bearer ${token}` },
+ { url: `http://127.0.0.1:${registry.port}${distPath}`, authorization: `Bearer ${token}` },
+ ],
+ ...installed,
+ });
+ });
+
+ it("sends the URL's credentials when the registry has none", async () => {
+ const received: Received[] = [];
+ await using registry = serveRegistry(received, basic("dist:d1st"));
+
+ const result = await install("1.0.0", { ".npmrc": `registry=http://127.0.0.1:${registry.port}/\n` });
+
+ expect({ received, ...result }).toEqual({
+ received: [
+ { url: `http://127.0.0.1:${registry.port}/no-deps`, authorization: null },
+ { url: `http://127.0.0.1:${registry.port}${distPath}`, authorization: basic("dist:d1st") },
+ ],
+ ...installed,
+ });
+ });
+ });
+ });
+
it("--silent suppresses verbose output even when RUNNER_DEBUG is set", async () => {
using dir = tempDir("install-silent-verbose", {
"package.json": JSON.stringify({ name: "app", dependencies: {} }),