Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 24 additions & 1 deletion src/install/npm.rs
Original file line number Diff line number Diff line change
Expand Up @@ -304,7 +304,9 @@ pub mod registry {
// https://github.com/npm/npm-registry-fetch/blob/main/lib/auth.js#L96
// base64("${username}:${password}")
pub auth: Box<[u8]>,
// URL may contain these special suffixes in the pathname:
// The configured URL may carry credentials, which `from_api` moves
// into `token`/`auth` and strips from here: `user:pass@` / `:token@`
// userinfo, or these special suffixes in the pathname:
Comment thread
robobun marked this conversation as resolved.
Outdated
// :_authToken
// :username
// :_password
Expand Down Expand Up @@ -360,6 +362,27 @@ pub mod registry {
let mut user: &mut [u8] = &mut [];
let mut needs_normalize = false;

// `https://user:pass@host/` or `https://:token@host/`. The config
// loaders only split these out of literal registry strings
// (`parse_registry_url_string_impl`); a `$VAR` reference or the
// object form's `url` still carries them here. Credentials
// configured next to the URL win, as with the path suffixes
// below, but the URL is stripped either way.
Comment thread
robobun marked this conversation as resolved.
Outdated
if !url.password.is_empty() {
if registry.token.is_empty()
&& registry.username.is_empty()
&& registry.password.is_empty()
{
if url.username.is_empty() {
registry.token = url.password.into();
} else {
registry.username = url.username.into();
registry.password = url.password.into();
}
}
needs_normalize = true;
}

// Backing storage for `user`/`auth` when synthesized from
// username:password.
let mut output_buf_owned: Box<[u8]> = Box::default();
Expand Down
1 change: 1 addition & 0 deletions src/runtime/cli/publish_command.rs
Original file line number Diff line number Diff line change
Expand Up @@ -868,6 +868,7 @@ impl PublishCommand {
let registry_url = registry.url.url();

if registry.token.is_empty()
&& registry.auth.is_empty()
&& (registry_url.password.is_empty() || registry_url.username.is_empty())
{
return Err(PublishError::NeedAuth);
Expand Down
153 changes: 153 additions & 0 deletions test/cli/install/bun-install.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -854,6 +854,159 @@ describe.concurrent("bun-install", () => {
expect(exitCode).toBe(0);
});

// `registry = "https://user:pass@host/"` written literally is split into
// credentials while bunfig.toml is parsed. These cover the registry URLs that
// only exist later, when `Scope::from_api` runs: `$ENV_VAR` references and
// the object form's `url`.
describe("credentials embedded in a registry URL that is not a literal registry string", () => {
const tgz = join(import.meta.dir, "registry", "packages", "no-deps", "no-deps-1.0.0.tgz");
const integrity = "sha512-v4w12JRjUGvfHDUP8vFDwu0gUWu04j0cv9hLb1Abf9VdaXu4XcrddYFTMVBVvmldKViGWH7jrb6xPJRF0wq6gw==";
const basic = `Basic ${Buffer.from("alice:s3cret").toString("base64")}`;

async function installWith(opts: {
bunfig: (registryOrigin: string) => string;
env?: (registryOrigin: string) => Record<string, string>;
dependency?: string;
}) {
const requests: { path: string; authorization: string | null }[] = [];
await using registry = Bun.serve({
port: 0,
hostname: "127.0.0.1",
fetch(req) {
const { pathname } = new URL(req.url);
requests.push({ path: pathname, authorization: req.headers.get("authorization") });
if (pathname.endsWith(".tgz")) {
return new Response(Bun.file(tgz));
}
const name = decodeURIComponent(pathname.slice(1));
if (name !== "no-deps" && name !== "@myorg/pkg") {
return new Response("not found", { status: 404 });
}
return 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:${registry.port}/${name}/-/pkg-1.0.0.tgz` },
},
},
});
},
});
const origin = `127.0.0.1:${registry.port}`;
const dependency = opts.dependency ?? "no-deps";

using dir = tempDir("registry-url-credentials", {
"package.json": JSON.stringify({ name: "app", version: "1.0.0", dependencies: { [dependency]: "1.0.0" } }),
"bunfig.toml": opts.bunfig(origin),
});

await using proc = spawn({
cmd: [bunExe(), "install"],
cwd: String(dir),
env: { ...env, BUN_INSTALL_CACHE_DIR: join(String(dir), ".cache"), ...opts.env?.(origin) },
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
return { requests, stdout, stderr, exitCode };
}

const basicAuthForms: [name: string, opts: Parameters<typeof installWith>[0]][] = [
[
"install.registry = $ENV_VAR",
{
bunfig: () => `[install]\nregistry = "$MY_REG"\n`,
env: origin => ({ MY_REG: `http://alice:s3cret@${origin}/` }),
},
],
[
"install.registry = { url = $ENV_VAR }",
{
bunfig: () => `[install]\nregistry = { url = "$MY_REG" }\n`,
env: origin => ({ MY_REG: `http://alice:s3cret@${origin}/` }),
},
],
[
"install.registry = { url = literal }",
{
bunfig: origin => `[install]\nregistry = { url = "http://alice:s3cret@${origin}/" }\n`,
},
],
[
"install.scopes entry = $ENV_VAR",
{
bunfig: () => `[install.scopes]\n"@myorg" = "$MY_REG"\n`,
env: origin => ({ MY_REG: `http://alice:s3cret@${origin}/` }),
dependency: "@myorg/pkg",
},
],
];

for (const [name, opts] of basicAuthForms) {
it(`sends user:pass@ from the URL as Basic auth (${name})`, async () => {
const { requests, stdout, stderr, exitCode } = await installWith(opts);
const manifestPath = opts.dependency === "@myorg/pkg" ? "/@myorg%2fpkg" : "/no-deps";
const tarballPath = `/${opts.dependency ?? "no-deps"}/-/pkg-1.0.0.tgz`;
expect({ requests, stderr }).toEqual({
requests: [
{ path: manifestPath, authorization: basic },
{ path: tarballPath, authorization: basic },
],
stderr: expect.not.stringContaining("s3cret"),
});
expect(stdout).toContain("1 package installed");
expect(exitCode).toBe(0);
});
}

it("sends :token@ from the URL as a Bearer token", async () => {
const { requests, stdout, stderr, exitCode } = await installWith({
bunfig: () => `[install]\nregistry = "$MY_REG"\n`,
env: origin => ({ MY_REG: `http://:tok-from-env@${origin}/` }),
});
expect({ requests, stderr }).toEqual({
requests: [
{ path: "/no-deps", authorization: "Bearer tok-from-env" },
{ path: "/no-deps/-/pkg-1.0.0.tgz", authorization: "Bearer tok-from-env" },
],
stderr: expect.not.stringContaining("tok-from-env"),
});
expect(stdout).toContain("1 package installed");
expect(exitCode).toBe(0);
});

it("does not print the credentials with the request URL", async () => {
const { requests, stderr, exitCode } = await installWith({
bunfig: () => `[install]\nregistry = "$MY_REG"\n`,
env: origin => ({ MY_REG: `http://alice:s3cret@${origin}/` }),
dependency: "not-on-this-registry",
});
expect({ requests, stderr }).toEqual({
requests: [{ path: "/not-on-this-registry", authorization: basic }],
stderr: expect.stringMatching(/error: GET http:\/\/127\.0\.0\.1:\d+\/+not-on-this-registry - 404/),
});
expect(stderr).not.toContain("s3cret");
expect(exitCode).toBe(1);
});

it("credentials configured next to the URL win over the ones inside it", async () => {
const { requests, stderr, exitCode } = await installWith({
bunfig: () => `[install]\nregistry = { url = "$MY_REG", token = "configured-token" }\n`,
env: origin => ({ MY_REG: `http://alice:s3cret@${origin}/` }),
dependency: "not-on-this-registry",
});
expect({ requests, stderr }).toEqual({
requests: [{ path: "/not-on-this-registry", authorization: "Bearer configured-token" }],
stderr: expect.stringMatching(/error: GET http:\/\/127\.0\.0\.1:\d+\/+not-on-this-registry - 404/),
});
expect(stderr).not.toContain("s3cret");
expect(exitCode).toBe(1);
});
});

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: {} }),
Expand Down
39 changes: 39 additions & 0 deletions test/cli/install/bun-publish.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1236,6 +1236,45 @@ test("dist.tarball in the published manifest does not include userinfo from the
expect(exitCode).toBe(0);
});

test("user:pass@ in a registry url taken from an env var is sent as Basic auth", async () => {
const requests: { method: string; pathname: string; authorization: string | null }[] = [];
using mock = Bun.serve({
port: 0,
fetch(req) {
requests.push({
method: req.method,
pathname: new URL(req.url).pathname,
authorization: req.headers.get("authorization"),
});
return new Response("OK", { status: 200 });
},
});

using packageDir = tempDir("publish-env-registry-userinfo", {
"package.json": JSON.stringify({ name: "env-userinfo-pkg", version: "1.0.0" }),
"bunfig.toml": `[install]\nregistry = "$PUBLISH_REGISTRY"\n`,
});

const { out, err, exitCode } = await publish(
{ ...env, PUBLISH_REGISTRY: `http://pubuser:hunter2@localhost:${mock.port}/` },
String(packageDir),
);
expect({ requests, err }).toEqual({
requests: [
{
method: "PUT",
pathname: "/env-userinfo-pkg",
authorization: `Basic ${Buffer.from("pubuser:hunter2").toString("base64")}`,
},
],
err: expect.not.stringContaining("error:"),
});
expect(out).toContain(`Registry: http://localhost:${mock.port}/\n`);
expect(out).toContain(" + env-userinfo-pkg@1.0.0");
expect(out).not.toContain("hunter2");
expect(exitCode).toBe(0);
});

describe("--tolerate-republish", async () => {
test("republishing normally fails", async () => {
const { packageDir, packageJson } = await registry.createTestDir();
Expand Down
32 changes: 29 additions & 3 deletions test/cli/install/redacted-config-logs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,12 @@ import { describe, expect, test } from "bun:test";
import { bunEnv, bunExe, tempDir, tmpdirSync } from "harness";
import { join } from "path";

test("registry url password is masked in request error output", async () => {
test("registry url password is sent as Basic auth and left out of request error output", async () => {
const authorizations: (string | null)[] = [];
await using server = Bun.serve({
port: 0,
fetch() {
fetch(req) {
authorizations.push(req.headers.get("authorization"));
return new Response(JSON.stringify({ error: "unauthorized" }), {
status: 401,
headers: { "content-type": "application/json" },
Expand All @@ -32,12 +34,36 @@ test("registry url password is masked in request error output", async () => {

const [out, err, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);

expect(err).toContain(`401 Unauthorized: http://user:**********@${server.hostname}:${server.port}/is-number`);
expect(authorizations).toEqual([`Basic ${Buffer.from("user:secretpass").toString("base64")}`]);
expect(err).toContain(`401 Unauthorized: http://${server.hostname}:${server.port}/is-number`);
expect(err).not.toContain("secretpass");
expect(out).not.toContain("secretpass");
expect(exitCode).toBe(1);
});

test("url password is masked in the verbose request line", async () => {
await using server = Bun.serve({
port: 0,
fetch() {
return new Response("ok");
},
});

await using proc = Bun.spawn({
cmd: [bunExe(), "-e", `await fetch("http://user:secretpass@${server.hostname}:${server.port}/pkg")`],
env: { ...bunEnv, NO_COLOR: "1", BUN_CONFIG_VERBOSE_FETCH: "1" },
stdout: "pipe",
stderr: "pipe",
});

const [out, err, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);

expect(err).toContain(`GET http://user:**********@${server.hostname}:${server.port}/pkg`);
expect(err).not.toContain("secretpass");
expect(out).not.toContain("secretpass");
expect(exitCode).toBe(0);
});

test("registry port is not mistaken for a credential when the package is scoped", async () => {
await using server = Bun.serve({
port: 0,
Expand Down