install: send credentials embedded in a registry URL that comes from an env var - #38834
install: send credentials embedded in a registry URL that comes from an env var#38834robobun wants to merge 7 commits into
Conversation
…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.
|
Warning Review limit reached
Next review available in: 3 minutes Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available. How can I continue?After more reviews become available, a review can be triggered using the 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 configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Pro Run ID: ⛔ Files ignored due to path filters (1)
📒 Files selected for processing (9)
Comment |
|
Updated 4:41 AM PT - Aug 15th, 2026
⏳ @robobun, your commit 0fa97f6 is still building in
|
|
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.
|
|
Found 1 issue this PR may fix:
🤖 Generated with Claude Code |
|
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 |
There was a problem hiding this comment.
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+passwordsplit matchesparse_registry_url_string_impl(same empty-username-means-token rule, username-only left alone). - Traced
needs_normalize→href_without_auth()so credentials are stripped from the stored URL even when explicit config wins. - Checked that
env.get_autoon the extracted values is a no-op for non-$strings, so behavior matches the literal-string path. - Confirmed the updated
redacted-config-logstest 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
Authorizationheader the mock registry receives and that the secret does not appear in stderr; the modifiedredacted-config-logstest replaces a bug-certifying assertion with a real one and adds a separate verbose-fetch case soredacted_npm_urlmasking stays covered. - No prior human review comments to address; CI is still building.
There was a problem hiding this comment.
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_apimirrorsparse_registry_url_string_implexactly (:pass@→ token,user:pass@→ username/password, username-only untouched);needs_normalizeis set unconditionally so the stored href is stripped even when explicit config wins. - The added
registry.auth.is_empty()check inpublish()is required because this change moves userinfo out ofregistry.urlintoregistry.auth, which the old check didn't accept. - The comment-cop warnings on
npm.rswere addressed by later commits (the remaining comment is one line). - The
redacted-config-logs.test.tschange 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 keepsredacted_npm_urlmasking 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.
… 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.
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.
There was a problem hiding this comment.
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.
|
Heads-up from #39063: that PR adds |
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, withMY_REG=http://alice:s3cret@127.0.0.1:PORT/, sends noAuthorizationheader and prints the secret on failure:error: GET http://alice:s3cret@127.0.0.1:PORT/no-deps - 404(bun 1.4.0 andmain). Same forregistry = { url = "$MY_REG" }and for[install.scopes]entries, and the:token@form is dropped the same way.bun publishagainst such a registry sends its PUT with no header..npmrcloaders splituser:pass@/:token@out of literal registry strings (parse_registry_url_string_impl,src/api/lib.rs) intoNpmRegistry.username/passwordortoken.$VARvalue has no userinfo when the config is parsed, so that split is a no-op. The variable is expanded later, inScope::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 insideScope.url, which requests never read for credentials (NetworkTaskonly sendsScope.token/Scope.auth) and which error output prints..npmrcis not affected: its${VAR}syntax is expanded while the file is parsed, before the split.Fix
Scope::from_api, right after the$VARexpansion, 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 offrom_apiis unchanged and sees exactly theNpmRegistrya literal string would have produced, soregistry = "$VAR"now behaves the same as pasting the variable's value. URLs without userinfo are unaffected (same href, sameurl_hash, same manifest cache names).token, orusername/password, next tourl = "$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_apisends 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 literalurl, where explicit keys replace the URL's credentials as a set, so{ url = "$VAR", ... }and the literal object agree; and it is the samehas_credentialsgate 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 handsfrom_apian 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, withsrc/api/lib.rsandsrc/ini/lib.rsdelegating to them) are taken byte-for-byte from install: send credentials embedded in --registry and registry env var URLs #38796, and thepublish()pre-flight change (src/runtime/cli/publish_command.rs, acceptScope.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_apineeds the shared splitter to exist, and stripping the userinfo out ofScope.urlwould otherwise makebun publishreport "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 thefrom_apihunk and the tests are specific to this PR.from_apirather than moving up to config parsing: bunfig.toml is parsed beforePackageManager::initloads the project's.envinto the loaderfrom_apiexpands against, and a variable defined in.envis 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.from_apibelow this point (the suffix parsing); this hunk operates on theNpmRegistrybefore 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.main, passes with this change; checked against a debug build ofmainwithsrc/stashed):test/cli/install/bun-install.test.ts, newdescribenext to the existing tarball auth test: a mock registry serving a manifest and tarball records theAuthorizationit 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 configuredtokenbeatsuser:pass@in the URL and a configured username/password beats:token@in the URL (the headers these two send are the onesmainsends today, so they pin that nothing regresses; onmainthey 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: itsnpm_config_registrytest 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 verbosefetch()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.bun-install.test.ts, all ofbun-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 inbun-install-registry.test.ts;cargo clippyon the touched crates; the source lints.urlat config time, which a$VARurl cannot go through, so the object form withurl = "$VAR"is covered here; url: emit a single slash in href_without_auth() for root-path registry URLs #38812 fixeshref_without_authproducinghttp://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::NpmRegistryis the parsed config for one registry (urlplus optionalusername/password/token), produced by the bunfig and.npmrcloaders.Scopeis what the package manager uses per registry: the URL,token(sent asAuthorization: Bearer, and preferred when both are set) andauth(base64user:password, sent asAuthorization: Basic).Scope::from_apiconverts one into the other and is called byOptions::loadfor the default registry, each scoped registry and the registry env vars.$VARregistry values: bunfig registry URLs (like theusername/password/tokenfields) may name an environment variable, e.g.url = "$NPM_CONFIG_REGISTRY"in the Artifactory guide;from_apiresolves them against the package manager's env loader, which includes the project's.env.NpmRegistry::from_urlis 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_credentialsis true when any oftoken/username/passwordis set; the.npmrcloader already used that predicate to decide whether//host/lines apply to a registry.Earlier revisions of this PR (superseded)
from_apifrom the already-parsedbun_url::URL(a second copy of the token-vs-basic rule) and also claimed the object form's literalurl. Self-review pointed out that this duplicated the splitter install: send credentials embedded in --registry and registry env var URLs #38796 consolidates and the object-form fix in bunfig: send credentials written into the url of a registry object #38824, so it was reworked to call the shared splitter on theNpmRegistrybeforefrom_apiparses anything, and the object-literal test case was dropped in favor of bunfig: send credentials written into the url of a registry object #38824's.token/username/passwordfrom the URL one field at a time; review caught that a:token@URL then displaced an explicitly configured username/password pair (Bearer sent where Basic used to be), which the all-or-nothing gate and the second precedence test now cover.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