Skip to content

install: send credentials embedded in a registry URL that comes from an env var - #38834

Open
robobun wants to merge 7 commits into
mainfrom
farm/7ff6a80c/registry-env-url-credentials
Open

install: send credentials embedded in a registry URL that comes from an env var#38834
robobun wants to merge 7 commits into
mainfrom
farm/7ff6a80c/registry-env-url-credentials

Conversation

@robobun

@robobun robobun commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator

Related: #17531 and #18670 are the bun publish "missing authentication" reports for Basic credentials; the publish hunk here is the one from #38776, which has the tests for those configurations and closes them. Found while probing registry credential handling, not from a user report.

Problem

  • registry = "$MY_REG" in bunfig.toml, with MY_REG=http://alice:s3cret@127.0.0.1:PORT/, sends no Authorization header and prints the secret on failure: error: GET http://alice:s3cret@127.0.0.1:PORT/no-deps - 404 (bun 1.4.0 and main). Same for registry = { url = "$MY_REG" } and for [install.scopes] entries, and the :token@ form is dropped the same way. bun publish against such a registry sends its PUT with no header.
  • The same URL written literally works: the bunfig and .npmrc loaders split user:pass@ / :token@ out of literal registry strings (parse_registry_url_string_impl, src/api/lib.rs) into NpmRegistry.username/password or token.
  • Cause: a $VAR value has no userinfo when the config is parsed, so that split is a no-op. The variable is expanded later, in Scope::from_api (src/install/npm.rs), which goes straight on to parse the expanded URL and only looks for the yarn-style /:_authToken= pathname suffixes. The userinfo stays inside Scope.url, which requests never read for credentials (NetworkTask only sends Scope.token / Scope.auth) and which error output prints.
  • .npmrc is not affected: its ${VAR} syntax is expanded while the file is parsed, before the split.

Fix

  • Scope::from_api, right after the $VAR expansion, runs the expanded URL through the same splitter the config loaders use, NpmRegistry::from_url, stores the URL it returns (userinfo removed, otherwise byte-identical to the input) and, if no credential was configured explicitly (NpmRegistry::has_credentials), takes the credentials the URL carried. The rest of from_api is unchanged and sees exactly the NpmRegistry a literal string would have produced, so registry = "$VAR" now behaves the same as pasting the variable's value. URLs without userinfo are unaffected (same href, same url_hash, same manifest cache names).
  • Precedence: anything configured explicitly (token, or username/password, next to url = "$VAR") wins and the URL's credentials are dropped; they are only used when nothing was configured. The gate is all-or-nothing on purpose: from_api sends a token in preference to a username/password pair, so filling the fields one by one (an earlier revision of this PR) let a :token@ URL displace an explicitly configured pair, turning a working Basic configuration into a Bearer one. Both directions are pinned by tests. bunfig: send credentials written into the url of a registry object #38824 (merged meanwhile) applies the same rule to the object form's literal url, where explicit keys replace the URL's credentials as a set, so { url = "$VAR", ... } and the literal object agree; and it is the same has_credentials gate install: send credentials embedded in --registry and registry env var URLs #38796 applies when the registry env var layer decides whether to carry over a token; that layer hands from_api an already-split registry, at which point this hunk is a no-op for it, and until then it keeps behaving as today.
  • NpmRegistry::from_url / has_credentials (src/options_types/schema.rs, with src/api/lib.rs and src/ini/lib.rs delegating to them) are taken byte-for-byte from install: send credentials embedded in --registry and registry env var URLs #38796, and the publish() pre-flight change (src/runtime/cli/publish_command.rs, accept Scope.auth) byte-for-byte from publish: accept basic auth credentials from .npmrc and bunfig.toml #38776. They are here so this PR is correct on its own: from_api needs the shared splitter to exist, and stripping the userinfo out of Scope.url would otherwise make bun publish report "missing authentication" for an env var registry (its check accepted userinfo left in the URL). Being identical hunks, they rebase to nothing once either sibling lands, in whichever order; only the from_api hunk and the tests are specific to this PR.
  • Why the expansion itself stays in from_api rather than moving up to config parsing: bunfig.toml is parsed before PackageManager::init loads the project's .env into the loader from_api expands against, and a variable defined in .env is one of the cases tested here. Expanding earlier would also start matching .npmrc //host/ credential lines against expanded URLs, a separate behavior change; this PR only makes the expanded URL go through the split every other spelling already gets.
  • install: resolve .npmrc credentials by path-segment ancestor #33869 rewrites the body of from_api below this point (the suffix parsing); this hunk operates on the NpmRegistry before that body starts, so it applies on either side of that rewrite. The branch also merges cleanly with install: canonical registry URL in Scope; redact secrets in bun audit registry URLs #38183, which landed meanwhile and changed the end of the same function.
  • Tests (each fails on main, passes with this change; checked against a debug build of main with src/ stashed):
    • test/cli/install/bun-install.test.ts, new describe next to the existing tarball auth test: a mock registry serving a manifest and tarball records the Authorization it receives. registry = "$VAR" (variable from the process environment, and from the project's .env), registry = { url = "$VAR" } and an [install.scopes] entry send Basic on both the manifest and the tarball request; :token@ sends Bearer; a 404 is reported with the credentials removed from the printed URL; a configured token beats user:pass@ in the URL and a configured username/password beats :token@ in the URL (the headers these two send are the ones main sends today, so they pin that nothing regresses; on main they fail on the leaked URL).
    • test/cli/install/bun-publish.test.ts: registry = "$VAR" publish sends one PUT with Basic auth and prints the registry without the userinfo.
    • test/cli/install/redacted-config-logs.test.ts: its npm_config_registry test asserted the masked form of the leaked URL (http://user:**********@...), which this change removes from the output, so it now asserts Basic is sent and the URL is printed clean; a verbose fetch() test keeps the masking helper covered. This hunk is identical to install: send credentials embedded in --registry and registry env var URLs #38796's for the same reason.
    • Also run with the debug build: the rest of bun-install.test.ts, all of bun-publish.test.ts, npmrc.test.ts, config-precedence.test.ts, redacted-config-logs.test.ts, bun-install-pathname-trailing-slash.test.ts, the whoami and env var registry tests in bun-install-registry.test.ts; cargo clippy on the touched crates; the source lints.
  • Other related PRs: bunfig: send credentials written into the url of a registry object #38824 (merged) splits the object form's literal url at config time, which a $VAR url cannot go through, so the object form with url = "$VAR" is covered here; url: emit a single slash in href_without_auth() for root-path registry URLs #38812 fixes href_without_auth producing http://host// for a path-less registry, which every credential-splitting spelling inherits (the tests here accept either form); install: redact secrets in the URLs printed for failed manifest and tarball downloads #38817 redacts the install error lines themselves.

Background

  • Api::NpmRegistry is the parsed config for one registry (url plus optional username / password / token), produced by the bunfig and .npmrc loaders. Scope is what the package manager uses per registry: the URL, token (sent as Authorization: Bearer, and preferred when both are set) and auth (base64 user:password, sent as Authorization: Basic). Scope::from_api converts one into the other and is called by Options::load for the default registry, each scoped registry and the registry env vars.
  • $VAR registry values: bunfig registry URLs (like the username / password / token fields) may name an environment variable, e.g. url = "$NPM_CONFIG_REGISTRY" in the Artifactory guide; from_api resolves them against the package manager's env loader, which includes the project's .env.
  • NpmRegistry::from_url is the userinfo rule used for literal strings: http://:x@host/ is a bearer token, http://u:p@host/ is a basic auth pair, a username without a password is left alone; when it splits something off it rebuilds the href without the userinfo (URL::href_without_auth), otherwise it returns the input unchanged. has_credentials is true when any of token / username / password is set; the .npmrc loader already used that predicate to decide whether //host/ lines apply to a registry.
Earlier revisions of this PR (superseded)

no test proof · iteration 0 · Platform-specific test(s) that do not run on this machine. Deferring to CI, which covers all platforms: test/cli/install/bun-install.test.ts test/cli/install/bun-publish.test.ts

…an env var

Scope::from_api expands `registry = "$VAR"` after the config loaders have
already split user:pass@ / :token@ out of literal registry strings, so a
URL that arrives through an env var reference (string form, object form
url, [install.scopes] entries) or through the registry env vars kept its
credentials inside Scope.url, where no request reads them, and printed
them in error output. Split them out in from_api, which every registry
goes through, into token (":token@") or username/password ("user:pass@"),
and store the URL without them. Credentials configured next to the URL
still win, as they do for the path suffix forms.

publish's pre-flight check accepts Scope.auth, which is where these
credentials now land.
@coderabbitai

coderabbitai Bot commented Aug 15, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

@robobun, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 3 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 0ca69c8a-7b53-42f5-9dc5-ba0d339d8a70

📥 Commits

Reviewing files that changed from the base of the PR and between 7adb357 and 0fa97f6.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (9)
  • src/api/Cargo.toml
  • src/api/lib.rs
  • src/ini/lib.rs
  • src/install/npm.rs
  • src/options_types/schema.rs
  • src/runtime/cli/publish_command.rs
  • test/cli/install/bun-install.test.ts
  • test/cli/install/bun-publish.test.ts
  • test/cli/install/redacted-config-logs.test.ts

Comment @coderabbitai help to get the list of available commands.

@robobun

robobun commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 4:41 AM PT - Aug 15th, 2026

@robobun, your commit 0fa97f6 is still building in Build #97964, but has 1 failures so far (All Failures):

@robobun

robobun commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator Author

Status: ready for a maintainer. Reworked after review; automated review of the current head found nothing further; CI on it is green apart from one unrelated lane.

  • Reproduced on bun 1.4.0 and on a debug build of main with a Bun.serve mock registry that logs the Authorization header: registry = "$MY_REG" (string form, { url = "$MY_REG" }, an [install.scopes] entry, and the variable coming from the project's .env) with MY_REG=http://alice:s3cret@127.0.0.1:PORT/ sends no header, and the failure line prints error: GET http://alice:s3cret@127.0.0.1:PORT/no-deps - 404. The same URL written literally sends Basic YWxpY2U6czNjcmV0.
  • Current shape: Scope::from_api runs the expanded URL through NpmRegistry::from_url, the one splitter the config loaders use, and takes its credentials only when none were configured explicitly. The splitter move is byte-identical to install: send credentials embedded in --registry and registry env var URLs #38796's and the publish pre-flight line to publish: accept basic auth credentials from .npmrc and bunfig.toml #38776's, so those hunks rebase to nothing whichever lands first; the from_api hunk and the tests are what this PR adds. Review caught that an intermediate per-field version let a :token@ URL displace a configured username/password; fixed, with both precedence directions now pinned by tests. Earlier revisions are summarized in the collapsed section of the description.
  • Tests in bun-install.test.ts (8), bun-publish.test.ts (1) and the updated redacted-config-logs.test.ts case fail on a main debug build and pass with the change; the rest of those files plus npmrc.test.ts, config-precedence.test.ts and bun-install-pathname-trailing-slash.test.ts pass with the debug build, as do clippy and the source lints on the touched crates. The branch merges cleanly with current main (including install: canonical registry URL in Scope; redact secrets in bun audit registry URLs #38183).
  • CI on the current head (build 97964, finished): 176 of 179 jobs passed. The one failed lane (debian 13 x64 ASAN) fails only on test/js/bun/spawn/spawn-stdin-readable-stream.test.ts, an intermittent ASAN failure in Bun.spawn stdin streaming that also hit the first revision, is unrelated to this diff and was reported separately. The two macOS 14 aarch64 test lanes expired without ever getting an agent (they have been short of agents all day), so they carry no result either way. Every lane that exercises the files changed here passed.

@github-actions

Copy link
Copy Markdown
Contributor

Found 1 issue this PR may fix:

  1. Bun Publish doesn't support token based login (Azure Devops) #17531 - bun publish with [install.scopes] { username, password = "$AZ_NPM_TOKEN", url } fails with error: missing authentication because the pre-flight check only accepted registry.token or URL userinfo; this PR adds registry.auth.is_empty() to that condition, letting the Basic-auth path through.

If this is helpful, copy the block below into the PR description to auto-close these issues on merge.

Fixes #17531

🤖 Generated with Claude Code

@robobun

robobun commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator Author

Re #17531: that report (and #18670) is the publish pre-flight check, which this PR only touches because stripping the userinfo out of the stored URL would otherwise make an env var registry trip it. The hunk is the same line as #38776, which has the tests for those configurations, so I have linked the issues from the description as related rather than adding a Fixes line here; #38776 closes them.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I reviewed this PR and the bug-hunting pass found no issues. The from_api change mirrors parse_registry_url_string_impl exactly and the publish NeedAuth guard update is the necessary consequence of stripping userinfo from Scope.url. Because this is credential-handling logic for bun install/bun publish and it overlaps with several open PRs (#38796, #38824, #38812), a human look would still be worthwhile.

What was reviewed

  • Verified the userinfo → token/username+password split matches parse_registry_url_string_impl (same empty-username-means-token rule, username-only left alone).
  • Traced needs_normalizehref_without_auth() so credentials are stripped from the stored URL even when explicit config wins.
  • Checked that env.get_auto on the extracted values is a no-op for non-$ strings, so behavior matches the literal-string path.
  • Confirmed the updated redacted-config-logs test still keeps the masking helper covered via the new verbose-fetch case.
Extended reasoning...

Overview

This PR fixes Scope::from_api in src/install/npm.rs so that when a registry URL arrives with userinfo still embedded (the $ENV_VAR case, the object form's url, and the registry env vars), the user:pass@ / :token@ is moved into registry.username/password or registry.token and the stored href is rebuilt via href_without_auth(). It also adds registry.auth to the publish() NeedAuth check, since that check previously passed only because the userinfo was left inside the URL. Tests cover install (four config forms × Basic, one Bearer, error-output redaction, explicit-token precedence), publish, and npm_config_registry via bun pm view.

Security risks

This is credential-handling code. The change moves secrets from a URL field (which is printed in error output and never sent as an Authorization header by Bun's HTTP client) into the token/auth fields that are actually sent — a strict improvement over the current leak-in-error-output behavior. Precedence ("credentials configured next to the URL win") matches both the existing path-suffix handling and the literal-string path. I checked the odd corner of userinfo and a :_authToken= path suffix in the same URL: after this change the $VAR form behaves identically to the literal form (userinfo wins, suffix stays in the path), so no new divergence is introduced. get_auto only expands $-prefixed strings, so extracted plain values like "alice" pass through unchanged.

Level of scrutiny

High — this is auth-bearing config plumbing for bun install and bun publish. The logic itself is small (~20 new lines in from_api, one-line guard in publish) and closely mirrors the existing parse_registry_url_string_impl, but the guidance is to not auto-approve auth/credential changes.

Other factors

  • The PR description is thorough and calls out three overlapping open PRs (#38796, #38824, #38812); a maintainer should coordinate merge order.
  • Test coverage is strong: each new test asserts the exact Authorization header the mock registry receives and that the secret does not appear in stderr; the modified redacted-config-logs test replaces a bug-certifying assertion with a real one and adds a separate verbose-fetch case so redacted_npm_url masking stays covered.
  • No prior human review comments to address; CI is still building.

Comment thread src/install/npm.rs Outdated
Comment thread src/install/npm.rs Outdated
Comment thread src/install/npm.rs Outdated
Comment thread src/install/npm.rs Outdated

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I reviewed this PR and didn't find any bugs. Because it changes how registry credentials are extracted and sent as Authorization headers, and it overlaps with several open sibling PRs (#38776, #38796, #38824), a human look would still be worthwhile.

What was reviewed:

  • The new userinfo-splitting block in Scope::from_api mirrors parse_registry_url_string_impl exactly (:pass@ → token, user:pass@ → username/password, username-only untouched); needs_normalize is set unconditionally so the stored href is stripped even when explicit config wins.
  • The added registry.auth.is_empty() check in publish() is required because this change moves userinfo out of registry.url into registry.auth, which the old check didn't accept.
  • The comment-cop warnings on npm.rs were addressed by later commits (the remaining comment is one line).
  • The redacted-config-logs.test.ts change replaces a test that certified the leaked-URL bug with one asserting Basic auth is sent and the URL is printed clean; a new verbose-fetch() test keeps redacted_npm_url masking covered.
Extended reasoning...

Overview

Two source hunks: (1) src/install/npm.rs — after $VAR expansion and URL::parse, Scope::from_api now pulls userinfo out of the parsed URL into registry.token or registry.username/password (only when none are already set) and sets needs_normalize = true so the stored href goes through href_without_auth(). (2) src/runtime/cli/publish_command.rs — the NeedAuth pre-flight check now also accepts a non-empty registry.auth, which is where the extracted user:pass lands after the first hunk. Three test files add coverage for install (string/object/scoped/env-var forms, Basic and Bearer, credential precedence, no-leak on error), publish (Basic auth PUT, printed URL clean), and rework a redaction test that was asserting the buggy masked-leak output.

Security risks

This is credential-handling code. The change starts sending Authorization headers for configurations that previously sent nothing, and rewrites the stored registry href. I verified the token-vs-basic rule matches the existing literal-string splitter (src/api/lib.rs:44-56), that explicitly configured token/username/password still take precedence (the URL creds are only adopted when all three are empty), and that needs_normalize is set regardless so the printed/stored URL never retains userinfo. The publish check is loosened only to accept registry.auth, which construct_publish_headers already knew how to send as Basic.

Level of scrutiny

High — this is auth header construction for the package manager and bun publish, and it composes with three other open PRs touching adjacent code paths. The diff itself is small and mechanical, but the merge-order interactions and the fact that an existing test's assertions were rewritten (correctly, as far as I can tell — the old assertion certified the bug) warrant a maintainer sign-off.

Other factors

CI's only failure is an unrelated ASAN issue in spawn-stdin-readable-stream.test.ts. The comment-cop bot flagged long comments on earlier commits; the current diff has a single one-line comment at the new block, so those look addressed. Tests are hermetic (local Bun.serve mocks, port: 0, per-test cache dir) and assert exact Authorization values plus absence of the secret in output.

Jarred-Sumner added a commit that referenced this pull request Aug 15, 2026
… registry URLs (#38183)

### Problem
- A registry configured as `http:host:port/path/` (scheme followed by a
single colon, which `new URL()` and npm accept) fails every resolution
before a request is made:
  ```
error: Invalid package name "react": manifest URL
"http://host:port/path/react" is not on registry "http:host:port/path/"
  error: InvalidURL
  ```
- Same failure for the other spellings the WHATWG parser rewrites (probe
against a local server on the released 1.4.0, full output in the details
below): a `..` segment in the path, an unencoded space in the path,
backslashes, surrounding whitespace. Three entries of the "Registry
URLs" table in `bun-install.test.ts` (`https:example.org`,
`https://////example.com///`, `http://點看`) hit it too; the table did not
notice because `failed to resolve` is also printed after this rejection.
- An upper-case scheme passes the manifest check (it compares
case-insensitively) but fails `for_tarball`'s same-origin comparison
(`src/install/NetworkTask.rs`, `send_auth`), which is case-sensitive, so
the tarball is requested without the `Authorization` header.
- Cause: `Scope::from_api` (`src/install/npm.rs`) and the `--registry`
branch of `Options::load`
(`src/install/PackageManager/PackageManagerOptions.rs`) store the
registry href as written. `NetworkTask::for_manifest` builds the
manifest URL with `bun_url::join`, which runs the href through the
WHATWG parser, and then compares the result against `URL::parse` of the
stored string. `URL::parse` is Bun's lenient scanner: it only recognizes
a scheme followed by `://`, does not resolve `..`, percent-encode, strip
whitespace or lower-case, so for these spellings the two sides describe
different URLs (`http:host:port/...` parses as hostname `http` with no
protocol). The same stored string feeds `for_tarball`'s origin
comparison, `extract_tarball::build_url`, `url_is_under_registry` in
`bun.lock.rs`, the DNS prefetch and the `@@<hostname>` cache folder
name.

### Fix
- Adds `Scope::set_url`: stores the WHATWG serialization of the
configured URL (`bun_url::URL::from_string`, the same parser `join`
uses) and derives `url_hash` from it. `Scope::from_api`, the
`--registry` branch and the `parseManifest` test helper
(`src/install_jsc/npm_jsc.rs`) all build the URL through it, so there is
one place that decides what a `Scope` holds.
- Correct because the stored href now equals the base `join` resolves
against, so every consumer that compares with, concatenates onto or
hashes the href agrees with the URL actually requested. The on-registry
check itself is unchanged and still rejects a name that joins outside
the registry directory (tested).
- Credentials cannot reach anything new. The manifest request URL is
unchanged (`join` parsed its base with the WHATWG parser before this
change too, so `join(as written, name)` and `join(normalized, name)` are
the same URL); only the value it is compared against changes. Both
checks compare against the stored href, so the only origin a tarball
request can now carry credentials to is the normalized registry origin,
which is where the manifest request (which always carries them) already
went. Before, a non-canonical spelling could only make the comparisons
fail. The one request URL that does change is the
`extract_tarball::build_url` fallback (manifest or `bun.lock` entry
without a tarball URL), which concatenates onto the href and now
produces a URL on that same origin instead of a string that failed the
`http(s)://` prefix check.
- A string the WHATWG parser rejects is stored as written, so the
`Failed to join registry "<as written>"` diagnostics for the invalid
entries of the table are unchanged (the table still asserts them).
- `set_url` runs after `from_api` has split the `/:_authToken=` style
credentials off the path, because the WHATWG parser would percent-encode
them. This is also why the normalization is not applied earlier, in the
config loaders: the `.npmrc` / bunfig string form extracts userinfo
credentials with the lenient parser first (an unencoded `#` in a
password is accepted there today).
- Already-canonical URLs serialize to themselves, so their `url_hash`,
manifest cache files and cache folder names are unchanged;
`https://registry.npmjs.org/` in particular still hashes to
`DEFAULT_URL_HASH`. The hash changes only for spellings the parser
rewrites; of those, only upper-case spellings worked before, and for
them the cost is one re-download of the cache.
- Not covered, on purpose: `.npmrc` credential lines
(`//host/path/:_authToken=`) are matched against the registry URL in
`src/ini/lib.rs` before a `Scope` exists, still by lenient parse of the
string as written, so a registry spelled `https:host/path/` gets its
requests but not its `.npmrc` token. That matching is being reworked in
#33869; filed separately. Spellings where the lenient parser takes the
port for a `:key=value` credential suffix (`http:/host:port/`,
`http:////host:port/`) are still mangled by the credential stripping
that runs before `set_url`; without a port they work.
- Verified: `test/cli/install/bun-install.test.ts` ("Registry URLs"):
new `spellings the WHATWG parser rewrites` block (bunfig registry object
with a token for each spelling, asserting the paths and `Authorization`
header of the manifest and tarball requests plus the cache folder name;
`.npmrc registry=`; `--registry`; the rejection message for a name that
joins outside the registry), and the table's handled entries now also
assert the rejection did not happen. 12 tests fail on the released build
(9 with the error above, the upper-case one with `authorization: null`
on the tarball, the rejection test because the message quoted the raw
spelling), all pass with this change.
- Also run with the change: the rest of `bun-install.test.ts` (remaining
failures are the bitbucket/gitlab/`some.url` network tests and
`--registry CLI flag`, which fail identically on the released build in
this container), `npmrc.test.ts`, the registry/whoami/manifest-cache
tests of `bun-install-registry.test.ts`,
`bun-install-pathname-trailing-slash.test.ts`, `cargo clippy` on
`bun_install` and `bun_install_jsc`, and the source lints.

### Background
- `npm::registry::Scope` is the package manager's record of one registry
(the default one or an `[install.scopes]` entry): its URL, credentials
and `url_hash`. `url_hash` keys the manifest cache files and tells
whether the default registry was overridden, which switches cache folder
names from `name@version` to `name@version@@<hostname>`.
- Bun has two URL parsers. `bun_url::URL::parse` is a lenient,
allocation-free scanner over the input bytes that the HTTP client and
the package manager use to read components out of a URL they already
hold. `bun_url::join` / `URL::from_string` call WTF::URL, the WHATWG
parser behind `new URL()`, which normalizes (scheme and host case,
missing slashes, `.`/`..` segments, percent-encoding, IDN) and rejects
what it cannot parse. The lenient scanner gives correct answers on
WHATWG output; the bug was feeding it the user's input instead.
- The "is not on registry" check exists so that a dependency name that
joins to another origin (an alias like `npm:\\other-host\pkg`) cannot
make Bun send the scope's credentials there; the tarball check in
`for_tarball` does the same for `dist.tarball` URLs returned by the
registry. Both are same-origin comparisons of a request URL against the
stored registry URL.

<details>
<summary>Probe: registry spellings against a local server (released
1.4.0 vs this branch)</summary>

Each row configures the spelling in `bunfig.toml` with one dependency
and records what reaches the server.

| registry as written | 1.4.0 | this branch |
| --- | --- | --- |
| `http://localhost:PORT/some/path/` | `GET /some/path/react` | same |
| `http:localhost:PORT/some/path/` | no request, `is not on registry` |
`GET /some/path/react` |
| `http:\\localhost:PORT\some\path\` | no request, `is not on registry`
| `GET /some/path/react` |
| `http://localhost:PORT/some/x/../path/` | no request, `is not on
registry` | `GET /some/path/react` |
| `http://localhost:PORT/some path/` | no request, `is not on registry`
| `GET /some%20path/react` |
| ` http://localhost:PORT/some/path/` (leading space) | no request, `is
not on registry` | `GET /some/path/react` |
| `HTTP://LOCALHOST:PORT/some/path/` | `GET /some/path/react` (tarball
would be sent without `Authorization`) | same request, tarball
authorized (covered by the test) |
| `http:/localhost:PORT/some/path/` | stored as `http://http/localhost/`
by the credential-suffix stripping | unchanged (see Fix) |
| `http:////localhost:PORT/some/path/` | stored as
`http://localhost/localhost/` by the credential-suffix stripping |
unchanged (see Fix) |

</details>

---

## Also folded in: audit — redact secrets in the registry URLs printed
by `bun audit` / `bun audit fix` (from #38844)

#### Problem
- With a registry URL that carries a secret, `bun audit` and `bun audit
fix` print it. With
`npm_config_registry=http://alice:s3cret@127.0.0.1:PORT/` and a registry
answering 404, stderr is `error: POST
http://alice:s3cret@127.0.0.1:PORT/-/npm/v1/security/advisories/bulk -
404`. A token in the registry path (`http://host/npm_.../`) is printed
the same way, and reaches these lines from every config source,
including `.npmrc`.
- Four outputs format the registry href verbatim (`BStr::new`), where
the rest of the package manager formats such URLs with
`bun_core::fmt::redacted_npm_url` (`Npm::response_error` in
`src/install/npm.rs`, the verbose request trace in `src/http/lib.rs`,
`bun pm whoami`):
- `src/runtime/cli/audit_command.rs` `send_audit_request`: the `POST
<url> - <status or error>` line (the repro above).
- `src/runtime/cli/audit_command.rs` `report_non_json_response`:
`<registry> returned a non-JSON audit response`, reached from the
report, `--json` and `audit fix` paths.
- `src/install/audit_fix.rs` `print_unaudited`: `warn: <registry> did
not answer the audit request (<reason>); skipped <packages>`, printed by
both commands for a scoped registry that failed.
- `src/install/audit_fix/json.rs`: the `registry` field of each
`unaudited` entry in `bun audit fix --json`.
- The href is `scope.url.href()` as configured
(`AuditRegistry::from_scope`; `unaudited()` copies it into the
`UnauditedRegistry` record behind the last two outputs). `.npmrc` and
bunfig registry strings move `user:password@` out of it while loading,
the bunfig object form, the registry env vars and `--registry` keep it
(#38796 and #38834 change the latter two), and a token in the path stays
in it in every case.
- The 1.3.x binaries print `audit request failed (status N)` without a
URL; the URL in these lines came with the audit rewrite in #38333, so
this has not shipped in a release.

#### Fix
- The `POST` line and `report_non_json_response` format the URL with
`redacted_npm_url`: these quote the request URL, so they get the same
masked form as the manifest and tarball error lines
(`http://alice:******@host/...`, path token as `***`).
`AuditRegistry.href` itself stays raw because it is also what the
request is sent to.
- The skipped-registry record names a registry rather than quoting a
request, so `unaudited()` builds it from `href_without_auth()` (trailing
slash stripped), the same form `bun publish` prints as its registry:
credentials written into the URL are left out instead of masked, and the
record reads the same whichever config source the scope came from
(`http://host:PORT`, matching what `.npmrc` scopes already produced).
Its two emitters, the warning and the `--json` field, format it with
`redacted_npm_url` for tokens in the path (`http://host:PORT/***`); the
`--json` value is rendered into a buffer first because the JSON string
writer takes bytes. The `unaudited` array is new in #38333, so nothing
depends on the raw form.
- For a URL without a password, UUID or `npm_` token the output is byte
for byte unchanged; the 177 existing tests in `bun-audit.test.ts`, many
of which assert these exact lines with plain URLs, still pass.
- Not touched, same class elsewhere: `bun audit fix`'s `was not checked
for updates` lines repeat the install log's `GET <url> - <status>` text,
which #38817 redacts at its source; the registry URL lines in
`src/install/NetworkTask.rs` (`Failed to join registry ...`, `... is not
on registry ...`) and `src/install/pnpm.rs` (`fetching pnpm registry ...
from <url>`) are install-side and have been filed separately. This PR
and #38817 touch disjoint files.
- Tests: `test/cli/install/bun-audit.test.ts`, new `bun audit with a
secret in the registry URL` block, one test per output: the `POST` line,
the non-JSON line from the response check, the non-JSON line from the
parse step in report, `--json` and `fix` mode, the skipped-registry
warning and `unaudited[].registry` with a token in the registry path
(`.npmrc` scope), and the same two outputs with `user:password@` in the
URL (bunfig object-form scope, which keeps it), asserting the
credential-free form. The path-token cases use a token rather than a
password because a token reaches the audit command from every config
source; the credential case asserts the stripped form, which stays true
once #38796 / #38834 strip credentials earlier, so this PR does not
depend on their landing order. All five fail on this branch with `src/`
stashed (the token or password is printed); the credential case also
fails with only the `unaudited()` hunk removed (it then prints
`alice:******@`), and the `--json` case with only the `json.rs` hunk
removed; all 182 tests in the file pass with the change.
- Also ran `cargo clippy -p bun_install -p bun_runtime`, `cargo fmt
--check` on both crates, and `test/internal/source-lints`.

#### Background
- `bun audit` POSTs the lockfile's package versions to
`<registry>/-/npm/v1/security/advisories/bulk`. Packages whose scope
(`@foo/*`) is configured with its own registry are sent to that registry
instead; when a non-default registry fails to answer (HTTP error,
connection error, non-JSON body) its packages are reported as skipped
(one `UnauditedRegistry` record per registry, printed as the warning
and, by `audit fix --json`, as the `unaudited` entries) rather than
failing the command, while a failure from the default registry fails the
command with the `POST` or non-JSON line.
- `redacted_npm_url` (`src/bun_core/fmt.rs`) is a `Display` adapter over
URL bytes: the password of `scheme://user:password@host` is written as
one `*` per byte (the per-byte form is shared with the config-excerpt
redactor, which needs column alignment), and any UUID or `npm_`/`npms_`
token anywhere in the string as `***`; everything else is written
through unchanged. `Output::err_generic`, `warn!` and `pretty_errorln!`
take any `Display` argument, so it is a drop-in replacement for
`BStr::new` (`err_generic`'s `{s}`/`{f}` placeholder letters are
cosmetic; `{f}` is what the other `redacted_npm_url` call site uses).
- `URL::href_without_auth()` (`src/url/lib.rs`) rebuilds
`scheme://host[:port]/path/` from the parsed URL, dropping any userinfo;
it is what the config loaders use to store a registry URL whose
credentials were split out, and what `bun publish` prints as
`Registry:`. It currently yields `http://host//` for a root-path
registry (#38812 changes that to one slash); `unaudited()` strips
trailing slashes afterwards, so the record is `http://host:PORT` either
way. Tokens that are part of the path survive it, which is why the
record's emitters still go through `redacted_npm_url`.

<details>
<summary>Before / after on a debug build (404 registry, non-JSON
registry, scoped registry with a path token, scoped registry configured
as <code>{ url = "http://alice:s3cret@..." }</code>)</summary>

Before:

```
error: POST http://alice:s3cret@127.0.0.1:PORT/npm_a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8/-/npm/v1/security/advisories/bulk - 404
error: http://alice:s3cret@127.0.0.1:PORT/npm_a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8 returned a non-JSON audit response
warn: http://127.0.0.1:PORT/npm_a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8 did not answer the audit request (404); skipped @foo/bar
{"dryRun":false,...,"unaudited":[{"registry":"http://127.0.0.1:PORT/npm_a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8","packages":["@foo/bar"],"reason":"404"}],...}
warn: http://alice:s3cret@localhost:PORT did not answer the audit request (404); skipped @foo/bar
{"dryRun":false,...,"unaudited":[{"registry":"http://alice:s3cret@localhost:PORT","packages":["@foo/bar"],"reason":"404"}],...}
```

After:

```
error: POST http://alice:******@127.0.0.1:PORT/***/-/npm/v1/security/advisories/bulk - 404
error: http://alice:******@127.0.0.1:PORT/*** returned a non-JSON audit response
warn: http://127.0.0.1:PORT/*** did not answer the audit request (404); skipped @foo/bar
{"dryRun":false,...,"unaudited":[{"registry":"http://127.0.0.1:PORT/***","packages":["@foo/bar"],"reason":"404"}],...}
warn: http://localhost:PORT did not answer the audit request (404); skipped @foo/bar
{"dryRun":false,...,"unaudited":[{"registry":"http://localhost:PORT","packages":["@foo/bar"],"reason":"404"}],...}
```

</details>

<details>
<summary>Earlier revision of this PR</summary>

The first revision applied `redacted_npm_url` to the `UnauditedRegistry`
record as well, so for the config sources that keep credentials in the
URL the warning and the `--json` field came out as
`http://alice:******@host` (username and password length, and a
different shape from the `.npmrc` case, which had no userinfo to begin
with). Review pointed out that every other place bun emits a registry
URL as data drops the credentials instead; the record is now built with
`href_without_auth()` and the per-byte masking is confined to the two
lines that quote the request URL.

</details>

Closes #38844

<!-- robobun:evidence:begin -->

---

**no test proof** · iteration 0 · Platform-specific test(s) that do not
run on this machine. Deferring to CI, which covers all platforms:
test/cli/install/bun-install.test.ts

<!-- robobun:evidence:end -->

---------

Co-authored-by: Jarred Sumner <jarred@jarredsumner.com>
…m_url

Move the userinfo splitter from bun_api's Parser to NpmRegistry::from_url
(with has_credentials next to it, used by the .npmrc loader) and call it
from Scope::from_api on the expanded $ENV_VAR URL, before the URL is
parsed, instead of splitting inline from the parsed URL. Explicitly
configured credential fields take precedence field by field, matching the
object form. Drops the object-literal test case (covered by the bunfig
side) and adds a case where the variable comes from the project's .env.
Comment thread src/install/npm.rs Outdated
Comment thread src/install/npm.rs
Filling token, username and password from the URL one field at a time let
a :token@ URL displace an explicitly configured username/password pair,
since a token is sent in preference to the pair. Take the URL's
credentials only when the registry has none, and pin both precedence
directions.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The precedence issue I flagged earlier is fixed in 0fa97f6 — the URL-derived credentials are now gated on !registry.has_credentials(), and the explicitWins test matrix covers both directions (config token vs URL user:pass, and config user/pass vs URL :token@). No further issues found this run. Since this is credential handling (what Authorization header the package manager sends, what gets stripped from printed URLs), a human look is still worthwhile.

Reviewed: traced the config-username/password + URL-:token@ case through the new from_api block and confirmed Basic auth is now built at lines 482–497; checked that registry.url = from_url.url unconditionally strips userinfo so error output stays clean even when configured credentials win; confirmed NpmRegistry::from_url is a byte-for-byte move of the existing splitter and is idempotent for URLs already stripped by the config loaders.

Extended reasoning...

Overview

This PR fixes registry = "$VAR" in bunfig.toml when the env var contains user:pass@ or :token@ userinfo — previously the credentials were silently dropped (no Authorization header sent) and the secret was printed in error output. The fix runs the expanded URL through the same NpmRegistry::from_url splitter that literal strings already use, then fills in the credentials only when none were explicitly configured. Supporting changes: the splitter is moved from src/api/lib.rs to src/options_types/schema.rs as NpmRegistry::from_url (with src/api and src/ini delegating to it), a has_credentials() helper is added, and bun publish's pre-flight check now accepts registry.auth so Basic-auth registries don't report "missing authentication". Nine new tests cover the string form, object form, scoped registries, .env-sourced variables, Bearer tokens, credential redaction in error output, both precedence directions, and publish.

Follow-up on my previous review

My inline comment at 10:29 UTC identified that the earlier per-field fill (if registry.token.is_empty() { registry.token = from_url.token } etc.) let a URL-embedded :token@ short-circuit the 'outer block and displace an explicitly configured username/password pair. Commit 0fa97f63 replaces the per-field fill with a single if !registry.has_credentials() gate around all three assignments, which is the fix I suggested, and adds the reverse-direction precedence test. I re-traced the scenario through the current code (npm.rs:354–361, then 376–498) and confirmed the configured pair now reaches the base64-encode block and produces Basic auth. The URL is still unconditionally stripped (line 356), so the redaction test's stderr not containing s3cret assertion holds even when the URL credentials are discarded.

Security risks

This is squarely security-sensitive: it changes which credentials are sent as Authorization to package registries and what appears in error output. The direction of the change is safe (credentials that were previously dropped are now sent to the registry the user configured; secrets are removed from printed URLs rather than added), and the precedence rule ("explicit config wins over URL userinfo") is now correctly enforced and tested in both directions. I did not find a path where credentials leak to a different host or where a weaker credential silently replaces a stronger one.

Level of scrutiny

High — auth code for the package manager, with interactions across several sibling PRs (#38776, #38796, #38812, #38817, #38824) that share hunks. The PR description carefully documents which hunks are shared and why, but the coordination itself warrants a human confirming the merge order and that the shared hunks are indeed byte-identical.

Other factors

CI was green on 8e59c20 per robobun; three commits landed after that (the rework to use the shared splitter, the comment trim, and the precedence fix). The comment-cop bot thread from 10:05 UTC targets a version before 7d6036d reduced the comment to one line, so it's stale in practice even though the thread isn't marked resolved. Test coverage is thorough and each new test is stated to fail on main. Given the auth-sensitive scope and the multi-PR coordination, I'm deferring rather than auto-approving.

@robobun

robobun commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator Author

Heads-up from #39063: that PR adds NpmRegistry.credentials_from_url, set by from_url whenever it takes credentials out of the URL, and Scope::from_api uses it to send http://user@host/ as Basic auth with an empty password. The from_api hunk here copies token / username / password out of the from_url result field by field; whichever of the two lands second should copy credentials_from_url in that block as well, otherwise registry = "$VAR" with a user@ URL would keep dropping the credentials.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant