From 364d842020f4770f470ad16365a70b7ee7726efb Mon Sep 17 00:00:00 2001 From: Alistair Smith Date: Mon, 24 Aug 2026 23:43:38 -0700 Subject: [PATCH 01/15] install: send .npmrc _auth verbatim and never leave a registry URL credential in the request path --- src/ini/lib.rs | 119 ++------ src/install/npm.rs | 286 ++++++++++-------- src/install_jsc/ini_jsc.rs | 11 +- src/options_types/schema.rs | 8 +- src/runtime/cli/audit_command.rs | 26 +- src/runtime/cli/pm_view_command.rs | 21 +- src/runtime/cli/publish_command.rs | 52 +--- test/cli/install/bun-info.test.ts | 58 ++++ test/cli/install/bun-install.test.ts | 94 ++++++ test/cli/install/bun-publish.test.ts | 43 +++ test/cli/install/npmrc.test.ts | 107 +++++-- test/cli/install/redacted-config-logs.test.ts | 56 +++- 12 files changed, 550 insertions(+), 331 deletions(-) diff --git a/src/ini/lib.rs b/src/ini/lib.rs index a9de2636cb6b..00d01bd5c042 100644 --- a/src/ini/lib.rs +++ b/src/ini/lib.rs @@ -1087,12 +1087,10 @@ mod draft { enum RegistryCredential { Token(Box<[u8]>), + /// `_auth`, verbatim: npm sends it as `Basic ` without decoding it. + Auth(Box<[u8]>), Username(Box<[u8]>), Password(Box<[u8]>), - UsernamePassword { - username: Box<[u8]>, - password: Box<[u8]>, - }, Email(Box<[u8]>), } @@ -1113,34 +1111,28 @@ mod draft { ConfigOpt::_AuthToken => RegistryCredential::Token(value), ConfigOpt::Username => RegistryCredential::Username(value), ConfigOpt::Email => RegistryCredential::Email(value), + // npm's `Buffer.from(value, "base64")` never fails: invalid bytes are + // skipped and as much as possible is decoded. ConfigOpt::_Password => { - if value.is_empty() { - RegistryCredential::Password(Box::default()) - } else { - let mut decoded = vec![0u8; bun_base64::decode_len(&value)]; - let result = bun_base64::decode(&mut decoded[..], &value); - if !result.is_successful() { - log.add_error_fmt_opts( - format_args!( - "{} is not valid base64", - <&'static str>::from(optname) - ), - bun_ast::AddErrorOptions { - source: Some(source), - loc, - redact_sensitive_information: true, - ..Default::default() - }, - ); - return None; - } - decoded.truncate(result.count); - RegistryCredential::Password(decoded.into_boxed_slice()) - } + let mut decoded = vec![0u8; bun_base64::decode_lenient_len(value.len())]; + let count = bun_base64::decode_lenient(&mut decoded[..], &value, false); + decoded.truncate(count); + RegistryCredential::Password(decoded.into_boxed_slice()) } ConfigOpt::_Auth => { - let (username, password) = parse_auth(&value, loc, log, source)?; - RegistryCredential::UsernamePassword { username, password } + if value.is_empty() { + log.add_error_opts( + b"empty _auth value: this line supplies no credentials", + bun_ast::AddErrorOptions { + source: Some(source), + loc, + redact_sensitive_information: true, + ..Default::default() + }, + ); + return None; + } + RegistryCredential::Auth(value) } ConfigOpt::Certfile | ConfigOpt::Keyfile => return None, }; @@ -1161,12 +1153,9 @@ mod draft { pub(crate) fn apply_to(&self, registry: &mut NpmRegistry) { match &self.credential { RegistryCredential::Token(token) => registry.token.clone_from(token), + RegistryCredential::Auth(auth) => registry.auth.clone_from(auth), RegistryCredential::Username(username) => registry.username.clone_from(username), RegistryCredential::Password(password) => registry.password.clone_from(password), - RegistryCredential::UsernamePassword { username, password } => { - registry.username.clone_from(username); - registry.password.clone_from(password); - } RegistryCredential::Email(email) => registry.email.clone_from(email), } } @@ -1696,68 +1685,4 @@ mod draft { behavior, }) } - - fn parse_auth( - value: &[u8], - loc: Loc, - log: &mut Log, - source: &Source, - ) -> Option<(Box<[u8]>, Box<[u8]>)> { - if value.is_empty() { - log.add_error_opts( - b"invalid _auth value, expected base64 encoded \":\", received an empty string", - bun_ast::AddErrorOptions { - source: Some(source), - loc, - redact_sensitive_information: true, - ..Default::default() - }, - ); - return None; - } - let mut decoded = vec![0u8; bun_base64::decode_len(value)]; - let result = bun_base64::decode(&mut decoded[..], value); - if !result.is_successful() { - log.add_error_opts( - b"invalid _auth value, expected valid base64", - bun_ast::AddErrorOptions { - source: Some(source), - loc, - redact_sensitive_information: true, - ..Default::default() - }, - ); - return None; - } - let username_password = &decoded[..result.count]; - let Some(colon_idx) = bun_core::strings::index_of_char_usize(username_password, b':') - else { - log.add_error_opts( - b"invalid _auth value, expected base64 encoded \":\"", - bun_ast::AddErrorOptions { - source: Some(source), - loc, - redact_sensitive_information: true, - ..Default::default() - }, - ); - return None; - }; - if colon_idx + 1 >= username_password.len() { - log.add_error_opts( - b"invalid _auth value, expected base64 encoded \":\"", - bun_ast::AddErrorOptions { - source: Some(source), - loc, - redact_sensitive_information: true, - ..Default::default() - }, - ); - return None; - } - Some(( - username_password[..colon_idx].into(), - username_password[colon_idx + 1..].into(), - )) - } } // mod draft diff --git a/src/install/npm.rs b/src/install/npm.rs index 910f39f821f2..f26a19db589a 100644 --- a/src/install/npm.rs +++ b/src/install/npm.rs @@ -54,6 +54,7 @@ pub fn whoami(manager: &mut PackageManager) -> Result, WhoamiError> { if registry.token.is_empty() { return Err(WhoamiError::NeedAuth); } + let authorization = registry.authorization().expect("token is set"); let auth_type: &[u8] = match &manager.options.publish_config.auth_type { Some(auth_type) => auth_type.as_str().as_bytes(), @@ -68,15 +69,7 @@ pub fn whoami(manager: &mut PackageManager) -> Result, WhoamiError> { { headers.count("accept", "*/*"); headers.count("accept-encoding", "gzip,deflate"); - - write!( - &mut print_buf, - "Bearer {}", - bstr::BStr::new(®istry.token) - ) - .expect("infallible: in-memory write"); - headers.count("authorization", &print_buf); - print_buf.clear(); + headers.count("authorization", &authorization); // no otp needed, just use auth-type from options headers.count("npm-auth-type", auth_type); @@ -104,15 +97,7 @@ pub fn whoami(manager: &mut PackageManager) -> Result, WhoamiError> { { headers.append("accept", "*/*"); headers.append("accept-encoding", "gzip/deflate"); - - write!( - &mut print_buf, - "Bearer {}", - bstr::BStr::new(®istry.token) - ) - .expect("infallible: in-memory write"); - headers.append("authorization", &print_buf); - print_buf.clear(); + headers.append("authorization", &authorization); headers.append("npm-auth-type", auth_type); headers.append("npm-command", "whoami"); @@ -302,13 +287,11 @@ pub mod registry { pub struct Scope { pub name: Box<[u8]>, // https://github.com/npm/npm-registry-fetch/blob/main/lib/auth.js#L96 - // base64("${username}:${password}") + // Sent verbatim as `Basic `. Usually base64("${username}:${password}"), + // but npm forwards whatever `_auth` holds — do not assume it decodes. pub auth: Box<[u8]>, - // URL may contain these special suffixes in the pathname: - // :_authToken - // :username - // :_password - // :_auth + // Registry href; yarn-style `:_authToken`/`:username`/`:_password`/`:_auth` + // pathname suffixes are always stripped by `parse_embedded_auth`. pub url: OwnedURL, pub url_hash: u64, pub token: Box<[u8]>, @@ -317,11 +300,127 @@ pub mod registry { pub user: Box<[u8]>, } + /// yarn-style credentials embedded in a registry URL's pathname, e.g. + /// `https://host/api/:_authToken=TOKEN`. + #[derive(Default)] + struct EmbeddedAuth<'a> { + token: Option<&'a [u8]>, + auth: Option<&'a [u8]>, + username: Option<&'a [u8]>, + password: Option<&'a [u8]>, + /// The scan hit a credential that ends it; nothing after it is read. + terminal: bool, + } + + #[derive(Clone, Copy)] + enum EmbeddedOpt { + Token, + Auth, + Username, + Password, + } + + impl<'a> EmbeddedAuth<'a> { + fn slot(&mut self, opt: EmbeddedOpt) -> &mut Option<&'a [u8]> { + match opt { + EmbeddedOpt::Token => &mut self.token, + EmbeddedOpt::Auth => &mut self.auth, + EmbeddedOpt::Username => &mut self.username, + EmbeddedOpt::Password => &mut self.password, + } + } + } + + const EMBEDDED_MARKERS: [(&[u8], EmbeddedOpt); 4] = [ + (b":_authToken=", EmbeddedOpt::Token), + (b":_auth=", EmbeddedOpt::Auth), + (b":username=", EmbeddedOpt::Username), + (b":_password=", EmbeddedOpt::Password), + ]; + + /// The rightmost `:=` credential marker in `pathname`, as `(colon, marker)`. + /// Anchoring on the marker rather than on any `:` keeps a colon inside the value from + /// ending the scan, and leaves a colon that merely belongs to the path alone. + fn last_embedded_marker(pathname: &[u8]) -> Option<(usize, &'static [u8], EmbeddedOpt)> { + EMBEDDED_MARKERS + .iter() + .filter_map(|&(marker, opt)| { + bun_core::last_index_of(pathname, marker).map(|i| (i, marker, opt)) + }) + .max_by_key(|&(i, _, _)| i) + } + + /// Strip trailing yarn-style credential segments out of `url.pathname`. Runs before + /// the registry's own credentials are consulted: the pathname must be sanitized + /// whether or not the credential is adopted, or the secret ships in the request path. + /// + /// + fn parse_embedded_auth<'a>(url: &mut URL<'a>) -> EmbeddedAuth<'a> { + let mut out = EmbeddedAuth::default(); + let mut pathname: &'a [u8] = url.pathname; + let mut needs_to_check_slash = true; + + // Right to left: the credentials are appended after the path. + while let Some((colon, marker, opt)) = last_embedded_marker(pathname) { + let value = &pathname[colon + marker.len()..]; + // An empty marker supplies no credential; it must not end the scan or shadow + // a credential from `.npmrc`. + if !out.terminal && !value.is_empty() { + *out.slot(opt) = Some(value); + out.terminal = matches!(opt, EmbeddedOpt::Token | EmbeddedOpt::Auth); + } + + pathname = &pathname[..colon]; + needs_to_check_slash = false; + if pathname.len() > 1 && pathname[pathname.len() - 1] == b'/' { + pathname = &pathname[..pathname.len() - 1]; + } + } + + // In this case, there is only one. + if needs_to_check_slash { + if let Some(last_slash) = strings::last_index_of_char(pathname, b'/') { + let remain = &pathname[last_slash + 1..]; + if let Some(eql_i) = strings::index_of_char_usize(remain, b'=') { + let segment = &remain[..eql_i]; + let value = &remain[eql_i + 1..]; + let opt = EMBEDDED_MARKERS + .iter() + .find(|(marker, _)| &marker[1..marker.len() - 1] == segment) + .map(|&(_, opt)| opt); + if let Some(opt) = opt { + pathname = &pathname[..last_slash + 1]; + if !value.is_empty() { + *out.slot(opt) = Some(value); + out.terminal = true; + } + } + } + } + } + + url.pathname = pathname; + url.path = pathname; + out + } + impl Scope { pub fn hash(str: &[u8]) -> u64 { bun_semver::semver_string::Builder::string_hash(str) } + /// The `Authorization` value for this registry, or `None` when it has no + /// credentials. Raw bytes: a credential need not be UTF-8. + pub fn authorization(&self) -> Option> { + if !self.token.is_empty() { + Some([b"Bearer ".as_slice(), &self.token].concat()) + } else if !self.auth.is_empty() { + Some([b"Basic ".as_slice(), &self.auth].concat()) + } else { + None + } + } + /// Stores the WHATWG serialization (the base `bun_url::join` resolves against) so same-origin checks, concatenated tarball URLs and `url_hash` agree with the requests; credentials must already be split off. pub fn set_url(&mut self, href: Box<[u8]>) { self.url = URL::from_string(&bun_core::String::borrow_utf8(&href)) @@ -362,124 +461,64 @@ pub mod registry { // of parsing. The final href is moved into `Scope.url: OwnedURL` // (owned `Box<[u8]>`). let registry_url: Box<[u8]> = core::mem::take(&mut registry.url); + let registry_auth: Box<[u8]> = core::mem::take(&mut registry.auth); let mut url = URL::parse(®istry_url); let mut auth: &[u8] = b""; let mut user: &mut [u8] = &mut []; - let mut needs_normalize = false; // Backing storage for `user`/`auth` when synthesized from // username:password. let mut output_buf_owned: Box<[u8]> = Box::default(); + let original_pathname_len = url.pathname.len(); + let embedded = parse_embedded_auth(&mut url); + let needs_normalize = url.pathname.len() != original_pathname_len; + if registry.token.is_empty() { 'outer: { - if registry.password.is_empty() { - let mut pathname: &[u8] = url.pathname; - let mut needs_to_check_slash = true; - while let Some(colon) = strings::last_index_of_char(pathname, b':') { - let mut segment = &pathname[colon + 1..]; - pathname = &pathname[..colon]; - needs_to_check_slash = false; - needs_normalize = true; - if pathname.len() > 1 && pathname[pathname.len() - 1] == b'/' { - pathname = &pathname[..pathname.len() - 1]; - } - - let Some(eql_i) = strings::index_of_char(segment, b'=') else { - continue; - }; - let value = &segment[eql_i as usize + 1..]; - segment = &segment[..eql_i as usize]; - - // https://github.com/yarnpkg/yarn/blob/6db39cf0ff684ce4e7de29669046afb8103fce3d/src/registries/npm-registry.js#L364 - // Bearer Token - if segment == b"_authToken" { - registry.token = value.into(); - url.pathname = pathname; - url.path = pathname; - break 'outer; - } - - if segment == b"_auth" { - auth = value; - url.pathname = pathname; - url.path = pathname; - break 'outer; - } - - if segment == b"username" { - registry.username = value.into(); - continue; - } - - if segment == b"_password" { - registry.password = value.into(); - continue; - } + if registry.password.is_empty() && registry_auth.is_empty() { + if let Some(token) = embedded.token { + registry.token = token.into(); } + if let Some(embedded_auth) = embedded.auth { + auth = embedded_auth; + } + if let Some(username) = embedded.username { + registry.username = username.into(); + } + if let Some(password) = embedded.password { + registry.password = password.into(); + } + if embedded.terminal { + break 'outer; + } + } - // In this case, there is only one. - if needs_to_check_slash { - if let Some(last_slash) = strings::last_index_of_char(pathname, b'/') { - let remain = &pathname[last_slash + 1..]; - if let Some(eql_i) = strings::index_of_char(remain, b'=') { - let segment = &remain[..eql_i as usize]; - let value = &remain[eql_i as usize + 1..]; - - // https://github.com/yarnpkg/yarn/blob/6db39cf0ff684ce4e7de29669046afb8103fce3d/src/registries/npm-registry.js#L364 - // Bearer Token - if segment == b"_authToken" { - registry.token = value.into(); - pathname = &pathname[..last_slash + 1]; - needs_normalize = true; - url.pathname = pathname; - url.path = pathname; - break 'outer; - } - - if segment == b"_auth" { - auth = value; - pathname = &pathname[..last_slash + 1]; - needs_normalize = true; - url.pathname = pathname; - url.path = pathname; - break 'outer; - } - - if segment == b"username" { - registry.username = value.into(); - pathname = &pathname[..last_slash + 1]; - needs_normalize = true; - url.pathname = pathname; - url.path = pathname; - break 'outer; - } - - if segment == b"_password" { - registry.password = value.into(); - pathname = &pathname[..last_slash + 1]; - needs_normalize = true; - url.pathname = pathname; - url.path = pathname; - break 'outer; - } + // npm forwards `_auth` verbatim; the decode only derives `user` for `bun pm whoami`. + if !registry_auth.is_empty() { + auth = ®istry_auth; + let decode_len = bun_base64::decode_len(®istry_auth); + let mut decoded = vec![0u8; decode_len].into_boxed_slice(); + let result = bun_base64::decode(&mut decoded[..], ®istry_auth); + if result.is_successful() { + let count = result.count; + // A blank username or password is a real pattern: no identity then. + if let Some(colon_idx) = + strings::index_of_char_usize(&decoded[..count], b':') + { + if colon_idx > 0 && colon_idx + 1 < count { + output_buf_owned = decoded; + user = &mut output_buf_owned[..count]; } } } - - // The pathname write-back is applied at every `break 'outer` - // above and once more here at fallthrough. - url.pathname = pathname; - url.path = pathname; + break 'outer; } registry.username = env.get_auto(®istry.username).into(); registry.password = env.get_auto(®istry.password).into(); - if !registry.username.is_empty() - && !registry.password.is_empty() - && auth.is_empty() - { + if !registry.username.is_empty() && !registry.password.is_empty() { let combo_len = registry.username.len() + registry.password.len() + 1; let total = combo_len + bun_core::base64::standard_encoder_calc_size(combo_len); @@ -508,9 +547,6 @@ pub mod registry { let final_href: Box<[u8]> = if needs_normalize { url.href_without_auth() } else { - // reshaped for borrowck — `url` (borrowing - // `registry_url`) is dead on this branch (every path that - // mutated `url.pathname` also set `needs_normalize = true`). registry_url }; diff --git a/src/install_jsc/ini_jsc.rs b/src/install_jsc/ini_jsc.rs index 2bcce6c2e184..653443844dbf 100644 --- a/src/install_jsc/ini_jsc.rs +++ b/src/install_jsc/ini_jsc.rs @@ -91,6 +91,7 @@ impl IniTestingAPIs { default_registry_username, default_registry_password, default_registry_email, + default_registry_auth, ) = 'brk: { let Some(default_registry) = install.default_registry.as_ref() else { break 'brk ( @@ -99,6 +100,7 @@ impl IniTestingAPIs { BunString::EMPTY, BunString::EMPTY, BunString::EMPTY, + BunString::EMPTY, ); }; @@ -108,6 +110,7 @@ impl IniTestingAPIs { BunString::from_bytes(&default_registry.username), BunString::from_bytes(&default_registry.password), BunString::from_bytes(&default_registry.email), + BunString::from_bytes(&default_registry.auth), ) }; // Rust has no field reflection; mirror struct-literal object creation with @@ -120,9 +123,10 @@ impl IniTestingAPIs { default_registry_username: BunString, default_registry_password: BunString, default_registry_email: BunString, + default_registry_auth: BunString, } impl bun_jsc::js_object::PojoFields for Pojo { - const FIELD_COUNT: usize = 5; + const FIELD_COUNT: usize = 6; fn put_fields( &self, global: &JSGlobalObject, @@ -148,6 +152,10 @@ impl IniTestingAPIs { b"default_registry_email", self.default_registry_email.to_js(global)?, )?; + put( + b"default_registry_auth", + self.default_registry_auth.to_js(global)?, + )?; Ok(()) } } @@ -157,6 +165,7 @@ impl IniTestingAPIs { default_registry_username, default_registry_password, default_registry_email, + default_registry_auth, }; Ok(bun_jsc::JSObject::create(&pojo, global)?.to_js()) } diff --git a/src/options_types/schema.rs b/src/options_types/schema.rs index d2cbff83ebda..f1fe7c388730 100644 --- a/src/options_types/schema.rs +++ b/src/options_types/schema.rs @@ -148,6 +148,9 @@ pub mod api { pub password: Box<[u8]>, /// token pub token: Box<[u8]>, + /// `.npmrc`'s `_auth`, verbatim. npm never decodes it, so neither may we. + /// Not read from `bunfig.toml`; it only carries the value to `Scope::from_api`. + pub auth: Box<[u8]>, /// email pub email: Box<[u8]>, } @@ -173,7 +176,10 @@ pub mod api { } pub fn has_credentials(&self) -> bool { - !self.token.is_empty() || !self.username.is_empty() || !self.password.is_empty() + !self.token.is_empty() + || !self.auth.is_empty() + || !self.username.is_empty() + || !self.password.is_empty() } } diff --git a/src/runtime/cli/audit_command.rs b/src/runtime/cli/audit_command.rs index a0f360902fe9..199e1b5e72d8 100644 --- a/src/runtime/cli/audit_command.rs +++ b/src/runtime/cli/audit_command.rs @@ -342,8 +342,7 @@ fn build_dependency_tree( struct AuditRegistry { href: Box<[u8]>, url_hash: u64, - token: Box<[u8]>, - auth: Box<[u8]>, + authorization: Option>, is_default: bool, } @@ -352,8 +351,7 @@ impl AuditRegistry { AuditRegistry { href: Box::<[u8]>::from(strings::without_trailing_slash(scope.url.href())), url_hash: scope.url_hash, - token: scope.token.clone(), - auth: scope.auth.clone(), + authorization: scope.authorization(), is_default, } } @@ -706,27 +704,15 @@ fn send_audit_request( headers.count(b"accept", b"application/json"); headers.count(b"content-type", b"application/json"); headers.count(b"content-encoding", b"gzip"); - if !registry.token.is_empty() { - headers.count(b"authorization", b""); - headers.content.cap += b"Bearer ".len() + registry.token.len(); - } else if !registry.auth.is_empty() { - headers.count(b"authorization", b""); - headers.content.cap += b"Basic ".len() + registry.auth.len(); + if let Some(authorization) = ®istry.authorization { + headers.count(b"authorization", authorization); } headers.allocate()?; headers.append(b"accept", b"application/json"); headers.append(b"content-type", b"application/json"); headers.append(b"content-encoding", b"gzip"); - if !registry.token.is_empty() { - headers.append_fmt( - b"authorization", - format_args!("Bearer {}", BStr::new(®istry.token)), - ); - } else if !registry.auth.is_empty() { - headers.append_fmt( - b"authorization", - format_args!("Basic {}", BStr::new(®istry.auth)), - ); + if let Some(authorization) = ®istry.authorization { + headers.append(b"authorization", authorization); } let mut url_str: Vec = Vec::new(); diff --git a/src/runtime/cli/pm_view_command.rs b/src/runtime/cli/pm_view_command.rs index 4eed154058ea..1c645563c79c 100644 --- a/src/runtime/cli/pm_view_command.rs +++ b/src/runtime/cli/pm_view_command.rs @@ -96,27 +96,16 @@ pub(crate) fn view( ); let url = URL::parse(url_slice); + let authorization = scope.authorization(); let mut headers = http::HeaderBuilder::default(); headers.count(b"Accept", b"application/json"); - if !scope.token.is_empty() { - headers.count(b"Authorization", b""); - headers.content.cap += b"Bearer ".len() + scope.token.len(); - } else if !scope.auth.is_empty() { - headers.count(b"Authorization", b""); - headers.content.cap += b"Basic ".len() + scope.auth.len(); + if let Some(authorization) = &authorization { + headers.count(b"Authorization", authorization); } headers.allocate()?; headers.append(b"Accept", b"application/json"); - if !scope.token.is_empty() { - headers.append_fmt( - b"Authorization", - format_args!("Bearer {}", BStr::new(&*scope.token)), - ); - } else if !scope.auth.is_empty() { - headers.append_fmt( - b"Authorization", - format_args!("Basic {}", BStr::new(&*scope.auth)), - ); + if let Some(authorization) = &authorization { + headers.append(b"Authorization", authorization); } let mut response_buf = MutableString::init(2048)?; diff --git a/src/runtime/cli/publish_command.rs b/src/runtime/cli/publish_command.rs index c33af8ff56fc..cca0ff54b19d 100644 --- a/src/runtime/cli/publish_command.rs +++ b/src/runtime/cli/publish_command.rs @@ -771,40 +771,19 @@ impl PublishCommand { return false; }; + let authorization = registry.authorization(); let mut headers = http::HeaderBuilder::default(); headers.count(b"accept", b"application/json"); - - let mut auth_buf: Vec = Vec::new(); - - if !registry.token.is_empty() { - if write!(&mut auth_buf, "Bearer {}", bstr::BStr::new(®istry.token)).is_err() { - return false; - } - headers.count(b"authorization", &auth_buf); - } else if !registry.auth.is_empty() { - if write!(&mut auth_buf, "Basic {}", bstr::BStr::new(®istry.auth)).is_err() { - return false; - } - headers.count(b"authorization", &auth_buf); + if let Some(authorization) = &authorization { + headers.count(b"authorization", authorization); } if headers.allocate().is_err() { return false; } headers.append(b"accept", b"application/json"); - - if !registry.token.is_empty() { - auth_buf.clear(); - if write!(&mut auth_buf, "Bearer {}", bstr::BStr::new(®istry.token)).is_err() { - return false; - } - headers.append(b"authorization", &auth_buf); - } else if !registry.auth.is_empty() { - auth_buf.clear(); - if write!(&mut auth_buf, "Basic {}", bstr::BStr::new(®istry.auth)).is_err() { - return false; - } - headers.append(b"authorization", &auth_buf); + if let Some(authorization) = &authorization { + headers.append(b"authorization", authorization); } let mut req = http::AsyncHTTP::init_sync( @@ -1887,19 +1866,14 @@ impl PublishCommand { b"legacy" }; let ci_name = ci::detect_ci_name(); + let authorization = registry.authorization(); { headers.count(b"accept", b"*/*"); headers.count(b"accept-encoding", b"gzip,deflate"); - if !registry.token.is_empty() { - let _ = write!(print_buf, "Bearer {}", bstr::BStr::new(®istry.token)); - headers.count(b"authorization", &**print_buf); - print_buf.clear(); - } else if !registry.auth.is_empty() { - let _ = write!(print_buf, "Basic {}", bstr::BStr::new(®istry.auth)); - headers.count(b"authorization", &**print_buf); - print_buf.clear(); + if let Some(authorization) = &authorization { + headers.count(b"authorization", authorization); } if maybe_json_len.is_some() { @@ -1943,14 +1917,8 @@ impl PublishCommand { headers.append(b"accept", b"*/*"); headers.append(b"accept-encoding", b"gzip,deflate"); - if !registry.token.is_empty() { - let _ = write!(print_buf, "Bearer {}", bstr::BStr::new(®istry.token)); - headers.append(b"authorization", &**print_buf); - print_buf.clear(); - } else if !registry.auth.is_empty() { - let _ = write!(print_buf, "Basic {}", bstr::BStr::new(®istry.auth)); - headers.append(b"authorization", &**print_buf); - print_buf.clear(); + if let Some(authorization) = &authorization { + headers.append(b"authorization", authorization); } if maybe_json_len.is_some() { diff --git a/test/cli/install/bun-info.test.ts b/test/cli/install/bun-info.test.ts index 04b1d9fe80b7..0a191a576b48 100644 --- a/test/cli/install/bun-info.test.ts +++ b/test/cli/install/bun-info.test.ts @@ -342,6 +342,64 @@ describe.concurrent("bun info", () => { expect(code).toBe(0); }); + it("sends Basic <_auth> on the manifest request when only .npmrc _auth is configured", async () => { + const basic = Buffer.from("alice:hunter2").toString("base64"); + const registryPath = "/"; + const paths: string[] = []; + const authorizations: (string | null)[] = []; + + await using registry = Bun.serve({ + port: 0, + hostname: "127.0.0.1", + fetch(req, server) { + paths.push(new URL(req.url).pathname); + authorizations.push(req.headers.get("authorization")); + return Response.json({ + "name": "pkg", + "dist-tags": { latest: "1.0.0" }, + "versions": { + "1.0.0": { + name: "pkg", + version: "1.0.0", + dist: { + tarball: `http://127.0.0.1:${server.port}${registryPath}pkg/-/pkg-1.0.0.tgz`, + shasum: "0000000000000000000000000000000000000000", + }, + }, + }, + }); + }, + }); + + const host = `127.0.0.1:${registry.port}`; + const testDir = tempDirWithFiles("view-auth", { + ".npmrc": `registry=http://${host}${registryPath}\n//${host}/:_auth=${basic}\n`, + "package.json": JSON.stringify({ name: "probe", version: "0.0.0" }), + // An empty home: the developer's own `.npmrc` declares a `registry=` that + // would replace the one under test. + "home/.gitkeep": "", + }); + const home = join(testDir, "home"); + + const { stdout, stderr, exited } = spawn({ + cmd: [bunExe(), "pm", "view", "pkg", "version"], + cwd: testDir, + env: { ...bunEnv, HOME: home, USERPROFILE: home, XDG_CONFIG_HOME: home }, + stdout: "pipe", + stdin: "ignore", + stderr: "pipe", + }); + const [output, error, code] = await Promise.all([stdout.text(), stderr.text(), exited]); + + expect({ paths, authorizations, output, error }).toEqual({ + paths: [`${registryPath}pkg`], + authorizations: [`Basic ${basic}`], + output: "1.0.0\n", + error: "", + }); + expect(code).toBe(0); + }); + it("should handle dist-tags like latest", async () => { const testDir = await setupTest(); const { output, error, code } = await runCommand([bunExe(), "pm", "view", "fs@latest"], testDir); diff --git a/test/cli/install/bun-install.test.ts b/test/cli/install/bun-install.test.ts index d41a2311f467..a793217485b1 100644 --- a/test/cli/install/bun-install.test.ts +++ b/test/cli/install/bun-install.test.ts @@ -749,6 +749,100 @@ describe.concurrent("bun-install", () => { }); }); + // A yarn-style credential inside the registry URL is stripped from the path before + // the request goes out, whether or not `.npmrc` also supplies one. Otherwise the + // secret ships in the request path, where proxies and logs can see it. + describe("credentials embedded in the registry URL", () => { + // Returns the Authorization header and the request path the registry saw. + async function probeEmbedded(registryPath: string, authLines: (host: string) => string) { + const seen: Array<{ path: string; auth: string | null }> = []; + await using registry = Bun.serve({ + port: 0, + hostname: "127.0.0.1", + fetch(req) { + seen.push({ path: new URL(req.url).pathname, auth: req.headers.get("authorization") }); + return new Response("not found", { status: 404 }); + }, + }); + const host = `127.0.0.1:${registry.port}`; + using dir = tempDir("npmrc-embedded-auth", { + ".npmrc": `@myorg:registry=http://${host}${registryPath}\n${authLines(host)}\n`, + "package.json": JSON.stringify({ name: "probe", version: "0.0.0", dependencies: { "@myorg/pkg": "1.0.0" } }), + "home/.gitkeep": "", + }); + const home = join(String(dir), "home"); + await using proc = Bun.spawn({ + cmd: [bunExe(), "install", "--no-cache"], + cwd: String(dir), + env: { ...env, HOME: home, USERPROFILE: home, XDG_CONFIG_HOME: home }, + stdout: "pipe", + stderr: "pipe", + stdin: "ignore", + }); + const [, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ requests: seen.length, exitCode, stderr }).toEqual({ + requests: 1, + exitCode: 1, + stderr: expect.any(String), + }); + return seen[0]!; + } + + it("strips an embedded _authToken from the path and uses it when .npmrc has none", async () => { + const seen = await probeEmbedded("/api/:_authToken=EMBEDDED", () => ""); + expect(seen).toEqual({ path: "/api/@myorg%2fpkg", auth: "Bearer EMBEDDED" }); + }); + + // A bare `:` belongs to the path. Only `name=value` segments naming a credential + // are stripped, so a registry mounted under `/a:b/c/` keeps its path. + it("leaves a plain colon in the registry path alone", async () => { + const seen = await probeEmbedded("/a:b/c/", host => `//${host}/a:b/c/:_authToken=TOK`); + expect(seen).toEqual({ path: "/a:b/c/@myorg%2fpkg", auth: "Bearer TOK" }); + }); + + it("leaves a plain colon in the registry path alone when no credential is configured", async () => { + const seen = await probeEmbedded("/a:b/c/", () => ""); + expect(seen).toEqual({ path: "/a:b/c/@myorg%2fpkg", auth: null }); + }); + + // The scan anchors on the `:=` marker, not on any `:`, so a colon inside the + // value neither ends it nor splits it. + it("strips an embedded _authToken whose value contains a colon", async () => { + const seen = await probeEmbedded("/api/:_authToken=aa:bb", () => ""); + expect(seen).toEqual({ path: "/api/@myorg%2fpkg", auth: "Bearer aa:bb" }); + }); + + // An embedded `_password` is used verbatim, unlike an `.npmrc` one, which is base64. + it("strips an embedded username and _password whose value contains a colon", async () => { + const seen = await probeEmbedded("/api/:username=u/:_password=p:q", () => ""); + expect(seen).toEqual({ path: "/api/@myorg%2fpkg", auth: `Basic ${Buffer.from("u:p:q").toString("base64")}` }); + }); + + // `_authToken` ends what is read, not what is stripped: a segment to its left is + // still a secret, and leaving it behind puts it in the request path. + it("strips a _password written to the left of the _authToken it loses to", async () => { + const seen = await probeEmbedded("/api/:_password=cA==:_authToken=T", () => ""); + expect(seen).toEqual({ path: "/api/@myorg%2fpkg", auth: "Bearer T" }); + }); + + it("strips a username written to the left of the _authToken it loses to", async () => { + const seen = await probeEmbedded("/api/:username=u:_authToken=T", () => ""); + expect(seen).toEqual({ path: "/api/@myorg%2fpkg", auth: "Bearer T" }); + }); + + // The rightmost terminal marker is the one that is read; a second one to its left + // is stripped but never recorded, so it cannot outrank the first. + it("reads the rightmost terminal marker, not the leftmost", async () => { + const seen = await probeEmbedded("/api/:_authToken=T:_auth=WDpZ", () => ""); + expect(seen).toEqual({ path: "/api/@myorg%2fpkg", auth: "Basic WDpZ" }); + }); + + it("reads the leftmost of two duplicate non-terminal markers", async () => { + const seen = await probeEmbedded("/api/:username=a:username=b:_password=p", () => ""); + expect(seen).toEqual({ path: "/api/@myorg%2fpkg", auth: `Basic ${Buffer.from("a:p").toString("base64")}` }); + }); + }); + // The Rust port adds a same-origin guard in `NetworkTask::for_tarball` so a // malicious registry can't point `dist.tarball` at a third-party host and // harvest the scope's `Authorization` header. The guard must compare diff --git a/test/cli/install/bun-publish.test.ts b/test/cli/install/bun-publish.test.ts index c701b322806c..4b95ca93e068 100644 --- a/test/cli/install/bun-publish.test.ts +++ b/test/cli/install/bun-publish.test.ts @@ -510,6 +510,49 @@ test("can publish a package then install it", async () => { await runBunInstall(env, packageDir); expect(await exists(join(packageDir, "node_modules", "publish-pkg-1", "package.json"))).toBeTrue(); }); + +describe("can publish with only _auth from .npmrc", () => { + // npm forwards whatever `_auth` holds as `Basic `, decodable or not. + const cases: Array<[name: string, blob: string]> = [ + ["decodable base64", Buffer.from("alice:s3cret").toString("base64")], + ["opaque non-base64", "!!not-base64!!"], + ]; + + test.each(cases)("%s reaches the registry verbatim", async (_name, blob) => { + const { promise: sawPublish, resolve: onPublish, reject: onUnexpected } = Promise.withResolvers(); + using mockRegistry = Bun.serve({ + port: 0, + fetch(req) { + const pathname = new URL(req.url).pathname; + if (req.method === "PUT" && pathname === "/npmrc-auth-pkg") { + onPublish(req.headers.get("authorization")); + return new Response("OK", { status: 200 }); + } + onUnexpected(new Error(`unexpected request: ${req.method} ${pathname}`)); + return new Response("not found", { status: 404 }); + }, + }); + + const host = `localhost:${mockRegistry.port}`; + using dir = tempDir("publish-npmrc-auth", { + "package.json": JSON.stringify({ name: "npmrc-auth-pkg", version: "1.0.0" }), + ".npmrc": `registry=http://${host}/\n//${host}/:_auth=${blob}\n`, + // An empty home so the developer's own `.npmrc`/global bunfig can't leak in. + "home/.gitkeep": "", + }); + const home = join(String(dir), "home"); + + const { out, err, exitCode } = await publish( + { ...env, HOME: home, USERPROFILE: home, XDG_CONFIG_HOME: home }, + String(dir), + ); + expect(err).not.toContain("error:"); + expect(out).toContain("+ npmrc-auth-pkg@1.0.0"); + expect(await sawPublish).toBe(`Basic ${blob}`); + expect(exitCode).toBe(0); + }); +}); + test("can publish from a tarball", async () => { const { packageDir, packageJson } = await registry.createTestDir(); const bunfig = await registry.authBunfig("tarball"); diff --git a/test/cli/install/npmrc.test.ts b/test/cli/install/npmrc.test.ts index ec2e6adfcaee..0c8d5fdbaf54 100644 --- a/test/cli/install/npmrc.test.ts +++ b/test/cli/install/npmrc.test.ts @@ -498,7 +498,7 @@ ${Object.keys(opts) dotEnv: { SECRET_AUTH: "" }, }, (stdout: string, stderr: string) => { - expect(stderr).toContain("received an empty string"); + expect(stderr).toContain("empty _auth value"); }, ); @@ -551,14 +551,15 @@ registry=https://somehost.com/org1/npm/registry/ }); describe("credentials keyed to a bracketed IPv6 host", () => { - // The `//` is stripped off the key before it is parsed as a URL, leaving - // `[::1]:4873/`. A leading `[` used to parse to an empty host, so these keys - // never matched the registry they were written for. + // Config keys are matched literally against the keys walked up from the registry + // URL, so the bracketed authority only has to survive the registry side's parse. test.each([ ["loopback with a port", "http://[::1]:4873/", "//[::1]:4873/"], ["loopback without a port", "http://[::1]/", "//[::1]/"], ["full address with a path", "http://[2001:db8::1]:4873/npm/registry/", "//[2001:db8::1]:4873/npm/registry/"], ["key without the trailing slash", "http://[::1]:4873/", "//[::1]:4873"], + // The address ends in the scheme's default port digits; they are not a port. + ["address whose last group spells the default port", "http://[::80]/", "//[::80]/"], ])("_authToken is applied: %s", (_, registryUrl, key) => { const result = loadNpmrc(`registry=${registryUrl}\n${key}:_authToken=v6-token\n`); expect(result).toEqual({ @@ -567,6 +568,7 @@ registry=https://somehost.com/org1/npm/registry/ default_registry_username: "", default_registry_password: "", default_registry_email: "", + default_registry_auth: "", }); }); @@ -580,15 +582,17 @@ registry=https://somehost.com/org1/npm/registry/ default_registry_username: "v6-user", default_registry_password: "v6-password", default_registry_email: "", + default_registry_auth: "", }); const auth = Buffer.from("v6-user:v6-password").toString("base64"); expect(loadNpmrc(`registry=http://[::1]:4873/\n//[::1]:4873/:_auth=${auth}\n`)).toEqual({ default_registry_url: "http://[::1]:4873/", default_registry_token: "", - default_registry_username: "v6-user", - default_registry_password: "v6-password", + default_registry_username: "", + default_registry_password: "", default_registry_email: "", + default_registry_auth: auth, }); }); @@ -603,26 +607,85 @@ registry=https://somehost.com/org1/npm/registry/ }); }); - it("does not print an undecodable _password value", async () => { - const secret = "s!ecret!pass"; - using dir = tempDir("npmrc-password-decode", { - ".npmrc": `//registry.npmjs.org/:_password=${secret}\n`, - "package.json": JSON.stringify({ name: "foo", version: "1.0.0" }), + describe("bun pm whoami derives the username from _auth", () => { + async function whoamiWith(files: Record) { + using dir = tempDir("npmrc-whoami-auth", { + "home/.gitkeep": "", + "package.json": JSON.stringify({ name: "foo", version: "1.0.0" }), + ...files, + }); + const homeDir = join(String(dir), "home"); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "pm", "whoami"], + cwd: String(dir), + env: { ...env, HOME: homeDir, USERPROFILE: homeDir, XDG_CONFIG_HOME: homeDir }, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + return { stdout, stderr, exitCode }; + } + + function whoami(authValue: string) { + return whoamiWith({ + ".npmrc": `registry=https://somehost.com/\n//somehost.com/:_auth=${authValue}\n`, + }); + } + + test("a decodable _auth prints its username", async () => { + const { stdout, exitCode } = await whoami(Buffer.from("alice:s3cret").toString("base64")); + expect(stdout).toBe("alice\n"); + expect(exitCode).toBe(0); }); - await using proc = Bun.spawn({ - cmd: [bunExe(), "install"], - cwd: String(dir), - env: { ...env, NO_COLOR: "1" }, - stdout: "pipe", - stderr: "pipe", + test("a non-decodable _auth carries no identity", async () => { + const { stdout, stderr, exitCode } = await whoami("!!not-base64!!"); + expect(stdout).toBe(""); + expect(stderr).toContain("missing authentication"); + expect(exitCode).toBe(1); }); - const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); - expect(stderr).toContain("_password is not valid base64"); - expect(stderr).toContain("_password=" + Buffer.alloc(secret.length, "*").toString()); - expect(stderr).not.toContain(secret); - expect(exitCode).toBe(0); + test("an _auth with a blank username carries no identity", async () => { + const { stdout, stderr, exitCode } = await whoami(Buffer.from(":s3cret").toString("base64")); + expect(stdout).toBe(""); + expect(stderr).toContain("missing authentication"); + expect(exitCode).toBe(1); + }); + + test("an _auth with a blank password carries no identity", async () => { + const { stdout, stderr, exitCode } = await whoami(Buffer.from("tok:").toString("base64")); + expect(stdout).toBe(""); + expect(stderr).toContain("missing authentication"); + expect(exitCode).toBe(1); + }); + + // The wire sends `Basic <_auth>` here (auth beats the username + password the + // registry URL's userinfo stored), so whoami must not report that username: it + // would be an identity from a credential never sent. + test.each([ + ["opaque", "!!not-base64!!"], + ["blank-password", Buffer.from("tok:").toString("base64")], + ])("the registry URL's username/password do not leak an identity past _auth (%s)", async (_name, authValue) => { + const { stdout, stderr, exitCode } = await whoamiWith({ + ".npmrc": `registry=https://url-user:url-pass@somehost.com/\n//somehost.com/:_auth=${authValue}\n`, + }); + expect(stdout).toBe(""); + expect(stderr).toContain("missing authentication"); + expect(exitCode).toBe(1); + }); + + // Credentials declared in bunfig.toml beat every .npmrc line for that registry, + // so whoami reports the bunfig identity and the _auth line is never consulted. + test("bunfig.toml username/password are the identity when bunfig declares the registry", async () => { + const { stdout, exitCode } = await whoamiWith({ + "bunfig.toml": `[install.registry]\nurl = "https://somehost.com/"\nusername = "bunfig-user"\npassword = "bunfig-pass"\n`, + ".npmrc": `//somehost.com/:_auth=!!not-base64!!\n`, + }); + expect(stdout).toBe("bunfig-user\n"); + expect(exitCode).toBe(0); + }); }); }); diff --git a/test/cli/install/redacted-config-logs.test.ts b/test/cli/install/redacted-config-logs.test.ts index 6c49d43123b4..478e6262b627 100644 --- a/test/cli/install/redacted-config-logs.test.ts +++ b/test/cli/install/redacted-config-logs.test.ts @@ -1,4 +1,5 @@ import { write } from "bun"; +import { iniInternals } from "bun:internal-for-testing"; import { describe, expect, test } from "bun:test"; import { bunEnv, bunExe, tempDir, tmpdirSync } from "harness"; import { join } from "path"; @@ -148,6 +149,15 @@ describe.concurrent("redact", async () => { bunfig: `install.registry = "https://user:pass@registry.org`, expected: `"https://user:****@registry.org`, }, + { + // A userinfo password that happens to start with a redacted keyword must not + // arm value redaction for the rest of the line; the quoted-key scan runs only + // on the fall-through path after the URL/UUID/npm-secret redactors. + title: "url password starting with a redacted keyword", + bunfig: `install.scopes.x = { url = "https://user:token123@registry.org/", username = "alice" };bad`, + expected: `, username = `, + secret: "token123", + }, { title: "empty url password", bunfig: `install.registry = "https://user:@registry.org`, @@ -184,28 +194,42 @@ describe.concurrent("redact", async () => { expected: "*", }, { + // npm forwards these verbatim, so there is no diagnostic to redact: the + // assertion is that neither an error nor the value reaches stderr. title: "invalid _auth", npmrc: "//registry.npmjs.org/:_auth = does-not-decode", - expected: "****************", + expected: "", + secret: "does-not-decode", }, { title: "unexpected _auth", npmrc: "//registry.npmjs.org/:_auth=:secret", - expected: "*******", + expected: "", + secret: ":secret", }, { title: "_auth zero length", npmrc: "//registry.npmjs.org/:_auth=", - expected: "received an empty string", + expected: "supplies no credentials", }, { title: "_auth one length", npmrc: "//registry.npmjs.org/:_auth=1", - expected: "*", + expected: "", + }, + { + // The most common .npmrc authoring mistake, and the value is always a live secret. + // npm decodes _password with Buffer.from(v, "base64"), which never throws — it + // skips invalid bytes — so there is no diagnostic and nothing may reach stderr. + title: "plaintext _password", + npmrc: "//registry.npmjs.org/:username=alice\n//registry.npmjs.org/:_password=p@ssw0rd!", + expected: "", + secret: "p@ssw0rd!", + forbidden: "is not valid base64", }, ]; - for (const { title, bunfig, npmrc, expected } of tests) { + for (const { title, bunfig, npmrc, expected, secret, forbidden } of tests) { test(title + (bunfig ? " (bunfig)" : " (npmrc)"), async () => { const testDir = tmpdirSync(); await Promise.all([ @@ -225,7 +249,9 @@ describe.concurrent("redact", async () => { const [out1, err1, exitCode1] = await Promise.all([proc1.stdout.text(), proc1.stderr.text(), proc1.exited]); expect(exitCode1).toBe(+!!bunfig); - expect(err1).toContain(expected || "*"); + if (expected) expect(err1).toContain(expected); + if (secret) expect(err1).not.toContain(secret); + if (forbidden) expect(err1).not.toContain(forbidden); // once with color await using proc2 = Bun.spawn({ @@ -239,7 +265,23 @@ describe.concurrent("redact", async () => { const [out2, err2, exitCode2] = await Promise.all([proc2.stdout.text(), proc2.stderr.text(), proc2.exited]); expect(exitCode2).toBe(+!!bunfig); - expect(err2).toContain(expected || "*"); + if (expected) expect(err2).toContain(expected); + if (secret) expect(err2).not.toContain(secret); + if (forbidden) expect(err2).not.toContain(forbidden); }); } }); + +// The retention half of the "plaintext _password" case above: Buffer.from(v, "base64") +// parity means an invalid-base64 _password is decoded leniently (invalid bytes skipped), +// not dropped — "aGVsbG8*!" must yield the same credential npm derives: "hello". +test("invalid base64 _password keeps the lenient-decoded credential", () => { + const result = iniInternals.loadNpmrc( + "registry=https://registry.npmjs.org/\n" + + "//registry.npmjs.org/:username=alice\n" + + "//registry.npmjs.org/:_password=aGVsbG8*!", + ); + expect(result.default_registry_username).toBe("alice"); + expect(result.default_registry_password).toBe(Buffer.from("aGVsbG8*!", "base64").toString()); + expect(result.default_registry_password).toBe("hello"); +}); From f411aecb9f37bde8b692bd4467854a9f2c7db9b3 Mon Sep 17 00:00:00 2001 From: Alistair Smith Date: Tue, 25 Aug 2026 00:46:33 -0700 Subject: [PATCH 02/15] install: keep .npmrc _auth across a same-host env registry override; tighten the new tests --- .../PackageManager/PackageManagerOptions.rs | 1 + test/cli/install/bun-install.test.ts | 11 +++- test/cli/install/bun-publish.test.ts | 3 +- test/cli/install/npmrc.test.ts | 50 +++++++++++++++++-- test/cli/install/redacted-config-logs.test.ts | 7 +-- 5 files changed, 60 insertions(+), 12 deletions(-) diff --git a/src/install/PackageManager/PackageManagerOptions.rs b/src/install/PackageManager/PackageManagerOptions.rs index 4c9e10b75be7..907a55d8bd0e 100644 --- a/src/install/PackageManager/PackageManagerOptions.rs +++ b/src/install/PackageManager/PackageManagerOptions.rs @@ -639,6 +639,7 @@ impl Options { && (new_url.is_https() || !prev_url.is_https()) { api_registry.token = core::mem::take(&mut self.scope.token); + api_registry.auth = core::mem::take(&mut self.scope.auth); } } self.scope = Npm::registry::Scope::from_api(b"", api_registry, env)?; diff --git a/test/cli/install/bun-install.test.ts b/test/cli/install/bun-install.test.ts index a793217485b1..4dc21209b5bf 100644 --- a/test/cli/install/bun-install.test.ts +++ b/test/cli/install/bun-install.test.ts @@ -754,7 +754,7 @@ describe.concurrent("bun-install", () => { // secret ships in the request path, where proxies and logs can see it. describe("credentials embedded in the registry URL", () => { // Returns the Authorization header and the request path the registry saw. - async function probeEmbedded(registryPath: string, authLines: (host: string) => string) { + async function probeEmbedded(registryPath: string, authLines: (host: string) => string, userinfo = "") { const seen: Array<{ path: string; auth: string | null }> = []; await using registry = Bun.serve({ port: 0, @@ -766,7 +766,7 @@ describe.concurrent("bun-install", () => { }); const host = `127.0.0.1:${registry.port}`; using dir = tempDir("npmrc-embedded-auth", { - ".npmrc": `@myorg:registry=http://${host}${registryPath}\n${authLines(host)}\n`, + ".npmrc": `@myorg:registry=http://${userinfo}${host}${registryPath}\n${authLines(host)}\n`, "package.json": JSON.stringify({ name: "probe", version: "0.0.0", dependencies: { "@myorg/pkg": "1.0.0" } }), "home/.gitkeep": "", }); @@ -793,6 +793,13 @@ describe.concurrent("bun-install", () => { expect(seen).toEqual({ path: "/api/@myorg%2fpkg", auth: "Bearer EMBEDDED" }); }); + // main only stripped the marker while looking for a credential to adopt; with + // userinfo already in the URL it skipped the scan and the token shipped in the path. + it("strips an embedded _authToken from the path when the URL also carries userinfo", async () => { + const seen = await probeEmbedded("/api/:_authToken=EMBEDDED", () => "", "u:p@"); + expect(seen).toEqual({ path: "/api/@myorg%2fpkg", auth: `Basic ${btoa("u:p")}` }); + }); + // A bare `:` belongs to the path. Only `name=value` segments naming a credential // are stripped, so a registry mounted under `/a:b/c/` keeps its path. it("leaves a plain colon in the registry path alone", async () => { diff --git a/test/cli/install/bun-publish.test.ts b/test/cli/install/bun-publish.test.ts index 4b95ca93e068..b48a5a2a5a4b 100644 --- a/test/cli/install/bun-publish.test.ts +++ b/test/cli/install/bun-publish.test.ts @@ -522,6 +522,7 @@ describe("can publish with only _auth from .npmrc", () => { const { promise: sawPublish, resolve: onPublish, reject: onUnexpected } = Promise.withResolvers(); using mockRegistry = Bun.serve({ port: 0, + hostname: "127.0.0.1", fetch(req) { const pathname = new URL(req.url).pathname; if (req.method === "PUT" && pathname === "/npmrc-auth-pkg") { @@ -533,7 +534,7 @@ describe("can publish with only _auth from .npmrc", () => { }, }); - const host = `localhost:${mockRegistry.port}`; + const host = `127.0.0.1:${mockRegistry.port}`; using dir = tempDir("publish-npmrc-auth", { "package.json": JSON.stringify({ name: "npmrc-auth-pkg", version: "1.0.0" }), ".npmrc": `registry=http://${host}/\n//${host}/:_auth=${blob}\n`, diff --git a/test/cli/install/npmrc.test.ts b/test/cli/install/npmrc.test.ts index 0c8d5fdbaf54..2b8db5e054dc 100644 --- a/test/cli/install/npmrc.test.ts +++ b/test/cli/install/npmrc.test.ts @@ -607,6 +607,48 @@ registry=https://somehost.com/org1/npm/registry/ }); }); + // `$npm_config_registry` pointing at the host `.npmrc` already configured rebuilds the + // scope; the `.npmrc` `_auth` must survive that the way `_authToken` does. + test("an env registry on the same host keeps the .npmrc _auth", async () => { + const blob = Buffer.from("alice:hunter2").toString("base64"); + const authorizations: (string | null)[] = []; + await using registry = Bun.serve({ + port: 0, + hostname: "127.0.0.1", + fetch(req) { + authorizations.push(req.headers.get("authorization")); + return Response.json({ + name: "pkg", + "dist-tags": { latest: "1.0.0" }, + versions: { "1.0.0": { name: "pkg", version: "1.0.0" } }, + }); + }, + }); + const host = `127.0.0.1:${registry.port}`; + using dir = tempDir("npmrc-env-registry-auth", { + "home/.gitkeep": "", + "package.json": JSON.stringify({ name: "probe", version: "0.0.0" }), + ".npmrc": `registry=http://${host}/\n//${host}/:_auth=${blob}\n`, + }); + const homeDir = join(String(dir), "home"); + await using proc = Bun.spawn({ + cmd: [bunExe(), "pm", "view", "pkg", "version"], + cwd: String(dir), + env: { + ...env, + HOME: homeDir, + USERPROFILE: homeDir, + XDG_CONFIG_HOME: homeDir, + npm_config_registry: `http://${host}/`, + }, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, , exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ authorizations, stdout }).toEqual({ authorizations: [`Basic ${blob}`], stdout: "1.0.0\n" }); + expect(exitCode).toBe(0); + }); + describe("bun pm whoami derives the username from _auth", () => { async function whoamiWith(files: Record) { using dir = tempDir("npmrc-whoami-auth", { @@ -630,7 +672,7 @@ registry=https://somehost.com/org1/npm/registry/ function whoami(authValue: string) { return whoamiWith({ - ".npmrc": `registry=https://somehost.com/\n//somehost.com/:_auth=${authValue}\n`, + ".npmrc": `registry=https://registry.invalid/\n//registry.invalid/:_auth=${authValue}\n`, }); } @@ -669,7 +711,7 @@ registry=https://somehost.com/org1/npm/registry/ ["blank-password", Buffer.from("tok:").toString("base64")], ])("the registry URL's username/password do not leak an identity past _auth (%s)", async (_name, authValue) => { const { stdout, stderr, exitCode } = await whoamiWith({ - ".npmrc": `registry=https://url-user:url-pass@somehost.com/\n//somehost.com/:_auth=${authValue}\n`, + ".npmrc": `registry=https://url-user:url-pass@registry.invalid/\n//registry.invalid/:_auth=${authValue}\n`, }); expect(stdout).toBe(""); expect(stderr).toContain("missing authentication"); @@ -680,8 +722,8 @@ registry=https://somehost.com/org1/npm/registry/ // so whoami reports the bunfig identity and the _auth line is never consulted. test("bunfig.toml username/password are the identity when bunfig declares the registry", async () => { const { stdout, exitCode } = await whoamiWith({ - "bunfig.toml": `[install.registry]\nurl = "https://somehost.com/"\nusername = "bunfig-user"\npassword = "bunfig-pass"\n`, - ".npmrc": `//somehost.com/:_auth=!!not-base64!!\n`, + "bunfig.toml": `[install.registry]\nurl = "https://registry.invalid/"\nusername = "bunfig-user"\npassword = "bunfig-pass"\n`, + ".npmrc": `//registry.invalid/:_auth=!!not-base64!!\n`, }); expect(stdout).toBe("bunfig-user\n"); expect(exitCode).toBe(0); diff --git a/test/cli/install/redacted-config-logs.test.ts b/test/cli/install/redacted-config-logs.test.ts index 478e6262b627..4930966f9ef7 100644 --- a/test/cli/install/redacted-config-logs.test.ts +++ b/test/cli/install/redacted-config-logs.test.ts @@ -212,11 +212,6 @@ describe.concurrent("redact", async () => { npmrc: "//registry.npmjs.org/:_auth=", expected: "supplies no credentials", }, - { - title: "_auth one length", - npmrc: "//registry.npmjs.org/:_auth=1", - expected: "", - }, { // The most common .npmrc authoring mistake, and the value is always a live secret. // npm decodes _password with Buffer.from(v, "base64"), which never throws — it @@ -250,6 +245,7 @@ describe.concurrent("redact", async () => { expect(exitCode1).toBe(+!!bunfig); if (expected) expect(err1).toContain(expected); + else expect(err1).not.toMatch(/\b(error|warn):/); if (secret) expect(err1).not.toContain(secret); if (forbidden) expect(err1).not.toContain(forbidden); @@ -266,6 +262,7 @@ describe.concurrent("redact", async () => { expect(exitCode2).toBe(+!!bunfig); if (expected) expect(err2).toContain(expected); + else expect(err2).not.toMatch(/\b(error|warn):/); if (secret) expect(err2).not.toContain(secret); if (forbidden) expect(err2).not.toContain(forbidden); }); From d7fb062cd769cf3880b6c432c62a7f47603a91bc Mon Sep 17 00:00:00 2001 From: Alistair Smith Date: Tue, 25 Aug 2026 19:10:02 -0700 Subject: [PATCH 03/15] test: pin verbatim _auth with values a base64 round trip would change --- test/cli/install/bun-install.test.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/test/cli/install/bun-install.test.ts b/test/cli/install/bun-install.test.ts index 4dc21209b5bf..a07be208aa95 100644 --- a/test/cli/install/bun-install.test.ts +++ b/test/cli/install/bun-install.test.ts @@ -839,9 +839,14 @@ describe.concurrent("bun-install", () => { // The rightmost terminal marker is the one that is read; a second one to its left // is stripped but never recorded, so it cannot outrank the first. - it("reads the rightmost terminal marker, not the leftmost", async () => { - const seen = await probeEmbedded("/api/:_authToken=T:_auth=WDpZ", () => ""); - expect(seen).toEqual({ path: "/api/@myorg%2fpkg", auth: "Basic WDpZ" }); + // The `_auth` values are ones a base64 round trip would change, so the header + // proves the value went out as written. + it.each([ + ["an opaque value", "opaque-blob"], + ["a blank password", btoa("u:")], + ])("reads the rightmost terminal marker, not the leftmost (%s)", async (_name, value) => { + const seen = await probeEmbedded(`/api/:_authToken=T:_auth=${value}`, () => ""); + expect(seen).toEqual({ path: "/api/@myorg%2fpkg", auth: `Basic ${value}` }); }); it("reads the leftmost of two duplicate non-terminal markers", async () => { From 3a5926728ea494747f171a4b2459e5a99cb9df33 Mon Sep 17 00:00:00 2001 From: Alistair Smith Date: Thu, 27 Aug 2026 13:01:38 -0700 Subject: [PATCH 04/15] install: strip slash-form credential segments too, route --registry through the scope builder, one Authorization builder --- docs/pm/npmrc.mdx | 2 +- src/install/NetworkTask.rs | 21 +- .../PackageManager/PackageManagerOptions.rs | 37 ++-- src/install/npm.rs | 80 ++++---- src/runtime/cli/pm_diff_command.rs | 21 +- test/cli/install/bun-install.test.ts | 93 ++++++++- test/cli/install/npmrc.test.ts | 191 ++++++++++++++---- 7 files changed, 318 insertions(+), 127 deletions(-) diff --git a/docs/pm/npmrc.mdx b/docs/pm/npmrc.mdx index 6e734c9f32bc..c59babd3f2b0 100644 --- a/docs/pm/npmrc.mdx +++ b/docs/pm/npmrc.mdx @@ -83,7 +83,7 @@ Bun supports the following options: - `_authToken` - `username` - `_password` (base64 encoded password) -- `_auth` (base64 encoded username:password, for example `btoa(username + ":" + password)`) +- `_auth` (sent as written in a `Basic` header; npm's form is base64 encoded username:password, for example `btoa(username + ":" + password)`) - `email` The equivalent `bunfig.toml` option is to add a key in [`install.scopes`](/runtime/bunfig#install-scopes): diff --git a/src/install/NetworkTask.rs b/src/install/NetworkTask.rs index fe70b5b5e4b2..7ea4f689ed99 100644 --- a/src/install/NetworkTask.rs +++ b/src/install/NetworkTask.rs @@ -390,26 +390,19 @@ fn append_auth(header_builder: &mut HeaderBuilder, scope: &npm::registry::Scope) // Routing through `format_args!`/`BStr` Display would be // lossy for non-UTF-8 tokens (U+FFFD expands 1→3 bytes) and overrun the // exact byte count reserved by `count_auth`. Use raw-byte append. - if !scope.token.is_empty() { - header_builder.append_bytes_value("Authorization", b"Bearer ", &scope.token); - } else if !scope.auth.is_empty() { - header_builder.append_bytes_value("Authorization", b"Basic ", &scope.auth); - } else { + let Some((scheme, value)) = scope.authorization_parts() else { return; - } + }; + header_builder.append_bytes_value("Authorization", scheme, value); header_builder.append("npm-auth-type", "legacy"); } fn count_auth(header_builder: &mut HeaderBuilder, scope: &npm::registry::Scope) { - if !scope.token.is_empty() { - header_builder.count("Authorization", ""); - header_builder.content.cap += "Bearer ".len() + scope.token.len(); - } else if !scope.auth.is_empty() { - header_builder.count("Authorization", ""); - header_builder.content.cap += "Basic ".len() + scope.auth.len(); - } else { + let Some((scheme, value)) = scope.authorization_parts() else { return; - } + }; + header_builder.count("Authorization", ""); + header_builder.content.cap += scheme.len() + value.len(); header_builder.count("npm-auth-type", "legacy"); } diff --git a/src/install/PackageManager/PackageManagerOptions.rs b/src/install/PackageManager/PackageManagerOptions.rs index 907a55d8bd0e..62d5662576e4 100644 --- a/src/install/PackageManager/PackageManagerOptions.rs +++ b/src/install/PackageManager/PackageManagerOptions.rs @@ -632,12 +632,8 @@ impl Options { let mut api_registry = Api::NpmRegistry::from_url(registry_); // Credentials in the URL win, as they do for `registry=` in .npmrc. if !api_registry.has_credentials() { - let prev_url = self.scope.url.url(); let new_url = bun_url::URL::parse(&api_registry.url); - if bun_core::without_trailing_slash(new_url.host) - == bun_core::without_trailing_slash(prev_url.host) - && (new_url.is_https() || !prev_url.is_https()) - { + if same_host_not_downgraded(&new_url, &self.scope.url.url()) { api_registry.token = core::mem::take(&mut self.scope.token); api_registry.auth = core::mem::take(&mut self.scope.auth); } @@ -651,24 +647,18 @@ impl Options { if let Some(cli) = &maybe_cli { if !cli.registry.is_empty() { - let api_registry = Api::NpmRegistry::from_url(cli.registry); - if api_registry.has_credentials() { - self.scope = Npm::registry::Scope::from_api(b"", api_registry, env)?; - } else { + let mut api_registry = Api::NpmRegistry::from_url(cli.registry); + // Credentials in the URL win, as they do for `registry=` in .npmrc. + if !api_registry.has_credentials() { let new_url = bun_url::URL::parse(&api_registry.url); - let same_origin = { - let prev_url = self.scope.url.url(); - bun_core::without_trailing_slash(new_url.host) - == bun_core::without_trailing_slash(prev_url.host) - && (new_url.is_https() || !prev_url.is_https()) - }; - if !same_origin { - self.scope.token = Box::default(); - self.scope.auth = Box::default(); - self.scope.user = Box::default(); + if same_host_not_downgraded(&new_url, &self.scope.url.url()) { + api_registry.token = core::mem::take(&mut self.scope.token); + api_registry.auth = core::mem::take(&mut self.scope.auth); } - self.scope.set_url(api_registry.url); } + // Through the same builder as every other registry, so a credential + // embedded in the URL is stripped from the request path here too. + self.scope = Npm::registry::Scope::from_api(b"", api_registry, env)?; } } @@ -1116,3 +1106,10 @@ impl Enable { self.contains(Enable::GLOBAL_VIRTUAL_STORE) } } + +/// `new` is on `base`'s host and does not downgrade https to http, so `base`'s +/// credentials may follow it: the shape `npm_config_registry` and `--registry` share. +fn same_host_not_downgraded(new: &bun_url::URL, base: &bun_url::URL) -> bool { + bun_core::without_trailing_slash(new.host) == bun_core::without_trailing_slash(base.host) + && (new.is_https() || !base.is_https()) +} diff --git a/src/install/npm.rs b/src/install/npm.rs index f26a19db589a..db4435121cfa 100644 --- a/src/install/npm.rs +++ b/src/install/npm.rs @@ -51,10 +51,10 @@ pub fn whoami(manager: &mut PackageManager) -> Result, WhoamiError> { return Ok(registry_url.username.to_vec()); } - if registry.token.is_empty() { + // An `_auth` whose username could not be derived still authenticates the request. + let Some(authorization) = registry.authorization() else { return Err(WhoamiError::NeedAuth); - } - let authorization = registry.authorization().expect("token is set"); + }; let auth_type: &[u8] = match &manager.options.publish_config.auth_type { Some(auth_type) => auth_type.as_str().as_bytes(), @@ -331,16 +331,23 @@ pub mod registry { } } - const EMBEDDED_MARKERS: [(&[u8], EmbeddedOpt); 4] = [ + /// `:=` is yarn's shape (`/api/:_authToken=T`); `/=` is the one Bun's + /// JFrog guide shows (`/npm/_auth=`). + const EMBEDDED_MARKERS: [(&[u8], EmbeddedOpt); 8] = [ (b":_authToken=", EmbeddedOpt::Token), (b":_auth=", EmbeddedOpt::Auth), (b":username=", EmbeddedOpt::Username), (b":_password=", EmbeddedOpt::Password), + (b"/_authToken=", EmbeddedOpt::Token), + (b"/_auth=", EmbeddedOpt::Auth), + (b"/username=", EmbeddedOpt::Username), + (b"/_password=", EmbeddedOpt::Password), ]; - /// The rightmost `:=` credential marker in `pathname`, as `(colon, marker)`. - /// Anchoring on the marker rather than on any `:` keeps a colon inside the value from - /// ending the scan, and leaves a colon that merely belongs to the path alone. + /// The rightmost credential marker in `pathname`, as `(start, marker, opt)`. + /// Anchoring on the marker rather than on any `:` or `/` keeps a colon or slash + /// inside the value (base64 holds `/`) from ending the scan, and leaves one that + /// merely belongs to the path alone. fn last_embedded_marker(pathname: &[u8]) -> Option<(usize, &'static [u8], EmbeddedOpt)> { EMBEDDED_MARKERS .iter() @@ -350,19 +357,22 @@ pub mod registry { .max_by_key(|&(i, _, _)| i) } - /// Strip trailing yarn-style credential segments out of `url.pathname`. Runs before - /// the registry's own credentials are consulted: the pathname must be sanitized + /// Strip trailing credential segments out of `url.pathname`. Runs before the + /// registry's own credentials are consulted: the pathname must be sanitized /// whether or not the credential is adopted, or the secret ships in the request path. /// /// fn parse_embedded_auth<'a>(url: &mut URL<'a>) -> EmbeddedAuth<'a> { let mut out = EmbeddedAuth::default(); let mut pathname: &'a [u8] = url.pathname; - let mut needs_to_check_slash = true; // Right to left: the credentials are appended after the path. - while let Some((colon, marker, opt)) = last_embedded_marker(pathname) { - let value = &pathname[colon + marker.len()..]; + while let Some((start, marker, opt)) = last_embedded_marker(pathname) { + let mut value = &pathname[start + marker.len()..]; + // The slash that closes the URL (`/:_authToken=S/`) is not part of the value. + if let Some(unslashed) = value.strip_suffix(b"/") { + value = unslashed; + } // An empty marker supplies no credential; it must not end the scan or shadow // a credential from `.npmrc`. if !out.terminal && !value.is_empty() { @@ -370,35 +380,18 @@ pub mod registry { out.terminal = matches!(opt, EmbeddedOpt::Token | EmbeddedOpt::Auth); } - pathname = &pathname[..colon]; - needs_to_check_slash = false; - if pathname.len() > 1 && pathname[pathname.len() - 1] == b'/' { + // A `/=` segment leaves the slash that closes the path; yarn's `:=` + // is written after that slash, so the slash goes with it. + pathname = if marker[0] == b'/' { + &pathname[..start + 1] + } else { + &pathname[..start] + }; + if marker[0] == b':' && pathname.len() > 1 && pathname[pathname.len() - 1] == b'/' { pathname = &pathname[..pathname.len() - 1]; } } - // In this case, there is only one. - if needs_to_check_slash { - if let Some(last_slash) = strings::last_index_of_char(pathname, b'/') { - let remain = &pathname[last_slash + 1..]; - if let Some(eql_i) = strings::index_of_char_usize(remain, b'=') { - let segment = &remain[..eql_i]; - let value = &remain[eql_i + 1..]; - let opt = EMBEDDED_MARKERS - .iter() - .find(|(marker, _)| &marker[1..marker.len() - 1] == segment) - .map(|&(_, opt)| opt); - if let Some(opt) = opt { - pathname = &pathname[..last_slash + 1]; - if !value.is_empty() { - *out.slot(opt) = Some(value); - out.terminal = true; - } - } - } - } - } - url.pathname = pathname; url.path = pathname; out @@ -411,16 +404,23 @@ pub mod registry { /// The `Authorization` value for this registry, or `None` when it has no /// credentials. Raw bytes: a credential need not be UTF-8. - pub fn authorization(&self) -> Option> { + /// The `Authorization` header as `(scheme, value)`, unallocated; a token + /// outranks a Basic credential, as in npm. + pub fn authorization_parts(&self) -> Option<(&'static [u8], &[u8])> { if !self.token.is_empty() { - Some([b"Bearer ".as_slice(), &self.token].concat()) + Some((b"Bearer ", &self.token)) } else if !self.auth.is_empty() { - Some([b"Basic ".as_slice(), &self.auth].concat()) + Some((b"Basic ", &self.auth)) } else { None } } + pub fn authorization(&self) -> Option> { + self.authorization_parts() + .map(|(scheme, value)| [scheme, value].concat()) + } + /// Stores the WHATWG serialization (the base `bun_url::join` resolves against) so same-origin checks, concatenated tarball URLs and `url_hash` agree with the requests; credentials must already be split off. pub fn set_url(&mut self, href: Box<[u8]>) { self.url = URL::from_string(&bun_core::String::borrow_utf8(&href)) diff --git a/src/runtime/cli/pm_diff_command.rs b/src/runtime/cli/pm_diff_command.rs index ab5379c35adb..601fb1e1a293 100644 --- a/src/runtime/cli/pm_diff_command.rs +++ b/src/runtime/cli/pm_diff_command.rs @@ -866,28 +866,21 @@ fn registry_get( && url.hostname == registry.hostname && url.get_port_auto() == registry.get_port_auto() }; - let (token, auth): (&[u8], &[u8]) = if same_origin { - (&scope.token, &scope.auth) + let authorization = if same_origin { + scope.authorization_parts() } else { - (b"", b"") + None }; - if !token.is_empty() { + if let Some((scheme, value)) = authorization { headers.count(b"Authorization", b""); - headers.content.cap += b"Bearer ".len() + token.len(); - headers.count(b"npm-auth-type", b"legacy"); - } else if !auth.is_empty() { - headers.count(b"Authorization", b""); - headers.content.cap += b"Basic ".len() + auth.len(); + headers.content.cap += scheme.len() + value.len(); headers.count(b"npm-auth-type", b"legacy"); } headers.allocate()?; headers.append(b"Accept", accept); // Raw-byte append: a non-UTF-8 token through Display would grow past the reserved count. - if !token.is_empty() { - headers.append_bytes_value(b"Authorization", b"Bearer ", token); - headers.append(b"npm-auth-type", b"legacy"); - } else if !auth.is_empty() { - headers.append_bytes_value(b"Authorization", b"Basic ", auth); + if let Some((scheme, value)) = authorization { + headers.append_bytes_value(b"Authorization", scheme, value); headers.append(b"npm-auth-type", b"legacy"); } diff --git a/test/cli/install/bun-install.test.ts b/test/cli/install/bun-install.test.ts index a07be208aa95..1fdce18fed77 100644 --- a/test/cli/install/bun-install.test.ts +++ b/test/cli/install/bun-install.test.ts @@ -754,7 +754,12 @@ describe.concurrent("bun-install", () => { // secret ships in the request path, where proxies and logs can see it. describe("credentials embedded in the registry URL", () => { // Returns the Authorization header and the request path the registry saw. - async function probeEmbedded(registryPath: string, authLines: (host: string) => string, userinfo = "") { + async function probeEmbedded( + registryPath: string, + authLines: (host: string) => string, + userinfo = "", + { cli = false }: { cli?: boolean } = {}, + ) { const seen: Array<{ path: string; auth: string | null }> = []; await using registry = Bun.serve({ port: 0, @@ -765,14 +770,15 @@ describe.concurrent("bun-install", () => { }, }); const host = `127.0.0.1:${registry.port}`; + const registryUrl = `http://${userinfo}${host}${registryPath}`; using dir = tempDir("npmrc-embedded-auth", { - ".npmrc": `@myorg:registry=http://${userinfo}${host}${registryPath}\n${authLines(host)}\n`, + ".npmrc": cli ? `${authLines(host)}\n` : `@myorg:registry=${registryUrl}\n${authLines(host)}\n`, "package.json": JSON.stringify({ name: "probe", version: "0.0.0", dependencies: { "@myorg/pkg": "1.0.0" } }), "home/.gitkeep": "", }); const home = join(String(dir), "home"); await using proc = Bun.spawn({ - cmd: [bunExe(), "install", "--no-cache"], + cmd: [bunExe(), "install", "--no-cache", ...(cli ? ["--registry", registryUrl] : [])], cwd: String(dir), env: { ...env, HOME: home, USERPROFILE: home, XDG_CONFIG_HOME: home }, stdout: "pipe", @@ -788,6 +794,87 @@ describe.concurrent("bun-install", () => { return seen[0]!; } + // The bunfig object form is the one way into the scope builder with a token already + // set, so it pins that the strip does not depend on the credential being adopted. + async function probeBunfig(registryPath: string, token: string) { + const seen: Array<{ path: string; auth: string | null }> = []; + await using registry = Bun.serve({ + port: 0, + hostname: "127.0.0.1", + fetch(req) { + seen.push({ path: new URL(req.url).pathname, auth: req.headers.get("authorization") }); + return new Response("not found", { status: 404 }); + }, + }); + const host = `127.0.0.1:${registry.port}`; + using dir = tempDir("bunfig-embedded-auth", { + "bunfig.toml": `[install.scopes]\nmyorg = { url = "http://${host}${registryPath}", token = "${token}" }\n`, + "package.json": JSON.stringify({ name: "probe", version: "0.0.0", dependencies: { "@myorg/pkg": "1.0.0" } }), + "home/.gitkeep": "", + }); + const home = join(String(dir), "home"); + await using proc = Bun.spawn({ + cmd: [bunExe(), "install", "--no-cache"], + cwd: String(dir), + env: { ...env, HOME: home, USERPROFILE: home, XDG_CONFIG_HOME: home }, + stdout: "pipe", + stderr: "pipe", + stdin: "ignore", + }); + const [, , exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ requests: seen.length, exitCode }).toEqual({ requests: 1, exitCode: 1 }); + return seen[0]!; + } + + // Bun's JFrog guide writes the credential as a path segment, `/npm/_auth=`. + // Base64 holds `/`, so the marker, not the last slash, must anchor the strip. + it("strips a slash-form _auth whose base64 value contains a slash", async () => { + const seen = await probeEmbedded("/api/npm/_auth=YWJj/ZGVm", () => ""); + expect(seen).toEqual({ path: "/api/npm/@myorg%2fpkg", auth: "Basic YWJj/ZGVm" }); + }); + + it("strips a slash-form _authToken whose value contains a slash", async () => { + const seen = await probeEmbedded("/api/_authToken=a/b", () => ""); + expect(seen).toEqual({ path: "/api/@myorg%2fpkg", auth: "Bearer a/b" }); + }); + + it("strips slash-form username and _password segments", async () => { + const seen = await probeEmbedded("/api/username=u/_password=p/q", () => ""); + expect(seen).toEqual({ path: "/api/@myorg%2fpkg", auth: `Basic ${Buffer.from("u:p/q").toString("base64")}` }); + }); + + it("leaves a plain `=` in a path segment alone", async () => { + const seen = await probeEmbedded("/a=b/c/", () => ""); + expect(seen).toEqual({ path: "/a=b/c/@myorg%2fpkg", auth: null }); + }); + + // `--registry` used to bypass the scope builder; with a trailing slash after the + // marker the token went out in every request path. + it("strips an embedded _authToken from a --registry URL with a trailing slash", async () => { + const seen = await probeEmbedded("/api/:_authToken=S/", () => "", "", { cli: true }); + expect(seen).toEqual({ path: "/api/@myorg%2fpkg", auth: "Bearer S" }); + }); + + it("keeps the .npmrc token of the same host over a --registry URL's embedded one", async () => { + const seen = await probeEmbedded( + "/api/:_authToken=S/", + host => `registry=http://${host}/\n//${host}/:_authToken=T`, + "", + { cli: true }, + ); + expect(seen).toEqual({ path: "/api/@myorg%2fpkg", auth: "Bearer T" }); + }); + + it("strips an embedded _auth when bunfig already supplies a token", async () => { + const seen = await probeBunfig("/api/:_auth=opaque-blob", "T"); + expect(seen).toEqual({ path: "/api/@myorg%2fpkg", auth: "Bearer T" }); + }); + + it("keeps bunfig's token over an embedded _authToken", async () => { + const seen = await probeBunfig("/api/:_authToken=EMBEDDED", "T"); + expect(seen).toEqual({ path: "/api/@myorg%2fpkg", auth: "Bearer T" }); + }); + it("strips an embedded _authToken from the path and uses it when .npmrc has none", async () => { const seen = await probeEmbedded("/api/:_authToken=EMBEDDED", () => ""); expect(seen).toEqual({ path: "/api/@myorg%2fpkg", auth: "Bearer EMBEDDED" }); diff --git a/test/cli/install/npmrc.test.ts b/test/cli/install/npmrc.test.ts index 2b8db5e054dc..21b6264d89e2 100644 --- a/test/cli/install/npmrc.test.ts +++ b/test/cli/install/npmrc.test.ts @@ -551,15 +551,13 @@ registry=https://somehost.com/org1/npm/registry/ }); describe("credentials keyed to a bracketed IPv6 host", () => { - // Config keys are matched literally against the keys walked up from the registry - // URL, so the bracketed authority only has to survive the registry side's parse. + // Keys and registry URLs are both parsed as URLs and compared on host and path, so + // the bracketed authority has to survive both parses. test.each([ ["loopback with a port", "http://[::1]:4873/", "//[::1]:4873/"], ["loopback without a port", "http://[::1]/", "//[::1]/"], ["full address with a path", "http://[2001:db8::1]:4873/npm/registry/", "//[2001:db8::1]:4873/npm/registry/"], ["key without the trailing slash", "http://[::1]:4873/", "//[::1]:4873"], - // The address ends in the scheme's default port digits; they are not a port. - ["address whose last group spells the default port", "http://[::80]/", "//[::80]/"], ])("_authToken is applied: %s", (_, registryUrl, key) => { const result = loadNpmrc(`registry=${registryUrl}\n${key}:_authToken=v6-token\n`); expect(result).toEqual({ @@ -649,6 +647,124 @@ registry=https://somehost.com/org1/npm/registry/ expect(exitCode).toBe(0); }); + test("an env registry on the same host keeps the .npmrc username and _password", async () => { + const blob = Buffer.from("alice:hunter2").toString("base64"); + const authorizations: (string | null)[] = []; + await using registry = Bun.serve({ + port: 0, + hostname: "127.0.0.1", + fetch(req) { + authorizations.push(req.headers.get("authorization")); + return Response.json({ + name: "pkg", + "dist-tags": { latest: "1.0.0" }, + versions: { "1.0.0": { name: "pkg", version: "1.0.0" } }, + }); + }, + }); + const host = `127.0.0.1:${registry.port}`; + using dir = tempDir("npmrc-env-registry-auth", { + "home/.gitkeep": "", + "package.json": JSON.stringify({ name: "probe", version: "0.0.0" }), + ".npmrc": `registry=http://${host}/\n//${host}/:username=alice\n//${host}/:_password=${Buffer.from("hunter2").toString("base64")}\n`, + }); + const homeDir = join(String(dir), "home"); + await using proc = Bun.spawn({ + cmd: [bunExe(), "pm", "view", "pkg", "version"], + cwd: String(dir), + env: { + ...env, + HOME: homeDir, + USERPROFILE: homeDir, + XDG_CONFIG_HOME: homeDir, + npm_config_registry: `http://${host}/`, + }, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, , exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ authorizations, stdout }).toEqual({ authorizations: [`Basic ${blob}`], stdout: "1.0.0\n" }); + expect(exitCode).toBe(0); + }); + test("an env registry on the same host keeps the registry URL's userinfo", async () => { + const blob = Buffer.from("alice:hunter2").toString("base64"); + const authorizations: (string | null)[] = []; + await using registry = Bun.serve({ + port: 0, + hostname: "127.0.0.1", + fetch(req) { + authorizations.push(req.headers.get("authorization")); + return Response.json({ + name: "pkg", + "dist-tags": { latest: "1.0.0" }, + versions: { "1.0.0": { name: "pkg", version: "1.0.0" } }, + }); + }, + }); + const host = `127.0.0.1:${registry.port}`; + using dir = tempDir("npmrc-env-registry-auth", { + "home/.gitkeep": "", + "package.json": JSON.stringify({ name: "probe", version: "0.0.0" }), + ".npmrc": `registry=http://alice:hunter2@${host}/\n`, + }); + const homeDir = join(String(dir), "home"); + await using proc = Bun.spawn({ + cmd: [bunExe(), "pm", "view", "pkg", "version"], + cwd: String(dir), + env: { + ...env, + HOME: homeDir, + USERPROFILE: homeDir, + XDG_CONFIG_HOME: homeDir, + npm_config_registry: `http://${host}/`, + }, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, , exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ authorizations, stdout }).toEqual({ authorizations: [`Basic ${blob}`], stdout: "1.0.0\n" }); + expect(exitCode).toBe(0); + }); + test("an env registry that downgrades https to http drops the .npmrc _auth", async () => { + const blob = Buffer.from("alice:hunter2").toString("base64"); + const authorizations: (string | null)[] = []; + await using registry = Bun.serve({ + port: 0, + hostname: "127.0.0.1", + fetch(req) { + authorizations.push(req.headers.get("authorization")); + return Response.json({ + name: "pkg", + "dist-tags": { latest: "1.0.0" }, + versions: { "1.0.0": { name: "pkg", version: "1.0.0" } }, + }); + }, + }); + const host = `127.0.0.1:${registry.port}`; + using dir = tempDir("npmrc-env-registry-auth", { + "home/.gitkeep": "", + "package.json": JSON.stringify({ name: "probe", version: "0.0.0" }), + ".npmrc": `registry=https://${host}/\n//${host}/:_auth=${blob}\n`, + }); + const homeDir = join(String(dir), "home"); + await using proc = Bun.spawn({ + cmd: [bunExe(), "pm", "view", "pkg", "version"], + cwd: String(dir), + env: { + ...env, + HOME: homeDir, + USERPROFILE: homeDir, + XDG_CONFIG_HOME: homeDir, + npm_config_registry: `http://${host}/`, + }, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, , exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ authorizations, stdout }).toEqual({ authorizations: [null], stdout: "1.0.0\n" }); + expect(exitCode).toBe(0); + }); + describe("bun pm whoami derives the username from _auth", () => { async function whoamiWith(files: Record) { using dir = tempDir("npmrc-whoami-auth", { @@ -670,37 +786,42 @@ registry=https://somehost.com/org1/npm/registry/ return { stdout, stderr, exitCode }; } - function whoami(authValue: string) { - return whoamiWith({ - ".npmrc": `registry=https://registry.invalid/\n//registry.invalid/:_auth=${authValue}\n`, + // Answers `/-/whoami` with a fixed name and records the Authorization header, so a + // request that goes out is visible even when Bun cannot derive a name locally. + async function whoamiAgainstRegistry(authLine: (host: string) => string, userinfo = "") { + const authorizations: (string | null)[] = []; + await using registry = Bun.serve({ + port: 0, + hostname: "127.0.0.1", + fetch(req) { + authorizations.push(req.headers.get("authorization")); + return Response.json({ username: "from-registry" }); + }, }); + const host = `127.0.0.1:${registry.port}`; + const result = await whoamiWith({ + ".npmrc": `registry=http://${userinfo}${host}/\n${authLine(host)}\n`, + }); + return { ...result, authorizations }; } - test("a decodable _auth prints its username", async () => { - const { stdout, exitCode } = await whoami(Buffer.from("alice:s3cret").toString("base64")); - expect(stdout).toBe("alice\n"); + test("a decodable _auth prints its username without a request", async () => { + const blob = Buffer.from("alice:s3cret").toString("base64"); + const { stdout, exitCode, authorizations } = await whoamiAgainstRegistry(host => `//${host}/:_auth=${blob}`); + expect({ stdout, authorizations }).toEqual({ stdout: "alice\n", authorizations: [] }); expect(exitCode).toBe(0); }); - test("a non-decodable _auth carries no identity", async () => { - const { stdout, stderr, exitCode } = await whoami("!!not-base64!!"); - expect(stdout).toBe(""); - expect(stderr).toContain("missing authentication"); - expect(exitCode).toBe(1); - }); - - test("an _auth with a blank username carries no identity", async () => { - const { stdout, stderr, exitCode } = await whoami(Buffer.from(":s3cret").toString("base64")); - expect(stdout).toBe(""); - expect(stderr).toContain("missing authentication"); - expect(exitCode).toBe(1); - }); - - test("an _auth with a blank password carries no identity", async () => { - const { stdout, stderr, exitCode } = await whoami(Buffer.from("tok:").toString("base64")); - expect(stdout).toBe(""); - expect(stderr).toContain("missing authentication"); - expect(exitCode).toBe(1); + // No local identity in these values, so the credential goes to the registry as + // written and the registry answers; main gave up with "missing authentication". + test.each([ + ["opaque", "!!not-base64!!"], + ["blank username", Buffer.from(":s3cret").toString("base64")], + ["blank password", Buffer.from("tok:").toString("base64")], + ])("an _auth without a local identity asks the registry (%s)", async (_name, authValue) => { + const { stdout, exitCode, authorizations } = await whoamiAgainstRegistry(host => `//${host}/:_auth=${authValue}`); + expect({ stdout, authorizations }).toEqual({ stdout: "from-registry\n", authorizations: [`Basic ${authValue}`] }); + expect(exitCode).toBe(0); }); // The wire sends `Basic <_auth>` here (auth beats the username + password the @@ -710,12 +831,12 @@ registry=https://somehost.com/org1/npm/registry/ ["opaque", "!!not-base64!!"], ["blank-password", Buffer.from("tok:").toString("base64")], ])("the registry URL's username/password do not leak an identity past _auth (%s)", async (_name, authValue) => { - const { stdout, stderr, exitCode } = await whoamiWith({ - ".npmrc": `registry=https://url-user:url-pass@registry.invalid/\n//registry.invalid/:_auth=${authValue}\n`, - }); - expect(stdout).toBe(""); - expect(stderr).toContain("missing authentication"); - expect(exitCode).toBe(1); + const { stdout, exitCode, authorizations } = await whoamiAgainstRegistry( + host => `//${host}/:_auth=${authValue}`, + "url-user:url-pass@", + ); + expect({ stdout, authorizations }).toEqual({ stdout: "from-registry\n", authorizations: [`Basic ${authValue}`] }); + expect(exitCode).toBe(0); }); // Credentials declared in bunfig.toml beat every .npmrc line for that registry, From 176ecb740732456b8864ee199159e99aa8553a59 Mon Sep 17 00:00:00 2001 From: Alistair Smith Date: Thu, 27 Aug 2026 14:28:53 -0700 Subject: [PATCH 05/15] install: split the merged doc comment on the Authorization builders --- src/install/npm.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/install/npm.rs b/src/install/npm.rs index db4435121cfa..8132461f68bd 100644 --- a/src/install/npm.rs +++ b/src/install/npm.rs @@ -402,8 +402,6 @@ pub mod registry { bun_semver::semver_string::Builder::string_hash(str) } - /// The `Authorization` value for this registry, or `None` when it has no - /// credentials. Raw bytes: a credential need not be UTF-8. /// The `Authorization` header as `(scheme, value)`, unallocated; a token /// outranks a Basic credential, as in npm. pub fn authorization_parts(&self) -> Option<(&'static [u8], &[u8])> { @@ -416,6 +414,8 @@ pub mod registry { } } + /// The `Authorization` value for this registry, or `None` when it has no + /// credentials. Raw bytes: a credential need not be UTF-8. pub fn authorization(&self) -> Option> { self.authorization_parts() .map(|(scheme, value)| [scheme, value].concat()) From 4bdca5604e1137c634ef08624439ae55b8de719c Mon Sep 17 00:00:00 2001 From: Alistair Smith Date: Mon, 24 Aug 2026 23:48:45 -0700 Subject: [PATCH 06/15] install: resolve .npmrc credentials by npm's key walk, normalise keys on read, warn on unknown options --- src/ast/lib.rs | 20 + src/bun_core/fmt.rs | 23 +- src/bun_core/lib.rs | 15 +- src/ini/lib.rs | 607 ++++++++---- src/install/PackageManager.rs | 16 +- src/install_jsc/ini_jsc.rs | 5 +- test/cli/install/bun-install.test.ts | 888 +++++++++++++++--- test/cli/install/npmrc.test.ts | 519 +++++++--- test/cli/install/redacted-config-logs.test.ts | 23 + 9 files changed, 1656 insertions(+), 460 deletions(-) diff --git a/src/ast/lib.rs b/src/ast/lib.rs index af50e2755ac7..ca747ac293fc 100644 --- a/src/ast/lib.rs +++ b/src/ast/lib.rs @@ -1782,6 +1782,26 @@ impl Log { ) } + /// `add_error_fmt_opts` at warning level. + #[cold] + pub fn add_warning_fmt_opts(&mut self, args: fmt::Arguments<'_>, opts: AddErrorOptions<'_>) { + if !Kind::Warn.should_print(self.level) { + return; + } + let text = alloc_print(args); + self.add_formatted_msg( + Kind::Warn, + opts.source, + Range { + loc: opts.loc, + len: opts.len, + }, + text, + Box::default(), + opts.redact_sensitive_information, + ) + } + // TODO(dylan-conway): rename and replace `addErrorFmt` #[inline] pub fn add_error_fmt_opts(&mut self, args: fmt::Arguments<'_>, opts: AddErrorOptions<'_>) { diff --git a/src/bun_core/fmt.rs b/src/bun_core/fmt.rs index 8793aea5d90e..9458e7cbc9fb 100644 --- a/src/bun_core/fmt.rs +++ b/src/bun_core/fmt.rs @@ -1743,6 +1743,13 @@ impl RedactedKeywords { b"_auth" | b"_authToken" | b"token" | b"_password" | b"password" | b"email" ) } + + /// Prefix match, as loose as the ini parser's option matching (`_auth` also covers `_authToken`). + pub(crate) fn has_prefix(s: &[u8]) -> bool { + [b"_auth".as_slice(), b"token", b"_password", b"email"] + .iter() + .any(|k| s.starts_with(k)) + } } impl Display for QuickAndDirtyJavaScriptSyntaxHighlighter<'_> { @@ -1788,8 +1795,9 @@ impl Display for QuickAndDirtyJavaScriptSyntaxHighlighter<'_> { bstr::BStr::new(&text[..i]), )?; } else { - should_redact_value = - self.opts.redact_sensitive_information && RedactedKeywords::has(&text[..i]); + should_redact_value = self.opts.redact_sensitive_information + && (RedactedKeywords::has(&text[..i]) + || RedactedKeywords::has_prefix(&text[..i])); 'write: { if let Some(prev) = prev_keyword { match prev { @@ -2054,6 +2062,17 @@ impl Display for QuickAndDirtyJavaScriptSyntaxHighlighter<'_> { text = &text[i..]; continue 'outer; } + + // A quoted ini key (`"//host/:_authToken"=v`) never reaches the + // identifier path; arm redaction here, after the value redactors. + let mut rest: &[u8] = inner; + while let Some(colon) = strings::index_of_char(rest, b':') { + rest = &rest[colon as usize + 1..]; + if RedactedKeywords::has_prefix(rest) { + should_redact_value = true; + break; + } + } } write!( diff --git a/src/bun_core/lib.rs b/src/bun_core/lib.rs index 20c1cf097471..a148d1a5766d 100644 --- a/src/bun_core/lib.rs +++ b/src/bun_core/lib.rs @@ -2014,22 +2014,23 @@ pub(crate) mod strings_impl { return None; } - let mut whitespace = false; + // A longer name that starts with `item` (`_authtoken`, `_auth_`) is a misspelling + // of it, and its value is just as secret. A closing quote ends a quoted ini key. let mut offset = item.len(); + while offset < text.len() && crate::js_lexer::is_identifier_continue(text[offset] as i32) { + offset += 1; + } + if offset < text.len() && matches!(text[offset], b'"' | b'\'') { + offset += 1; + } while offset < text.len() && text[offset].is_ascii_whitespace() { offset += 1; - whitespace = true; } if offset == text.len() { return None; } let cont = crate::js_lexer::is_identifier_continue(text[offset] as i32); - // must be another identifier - if !whitespace && cont { - return None; - } - // `null` is not returned after this point. Redact to the next // newline if anything is unexpected if cont { diff --git a/src/ini/lib.rs b/src/ini/lib.rs index 00d01bd5c042..ee0c6d3348b1 100644 --- a/src/ini/lib.rs +++ b/src/ini/lib.rs @@ -47,15 +47,11 @@ pub(crate) fn next_dot(key: &[u8]) -> Option { pub(crate) enum IniOption { Some(T), None, -} - -impl IniOption { - fn get(self) -> Option { - match self { - IniOption::Some(v) => Some(v), - IniOption::None => None, - } - } + /// A `//host/...:=` line whose `` is not a known option. + Unknown { + suffix: Box<[u8]>, + loc: bun_ast::Loc, + }, } // ────────────────────────────────────────────────────────────────────────── @@ -64,7 +60,7 @@ impl IniOption { #[derive(Clone, Copy, PartialEq, Eq, strum::IntoStaticStr, strum::EnumString)] pub enum ConfigOpt { - /// `${username}:${password}` encoded in base64 + /// usually `${username}:${password}` encoded in base64, but sent verbatim #[strum(serialize = "_auth")] _Auth, @@ -95,12 +91,21 @@ pub enum ConfigOpt { // ConfigItem // ────────────────────────────────────────────────────────────────────────── -pub struct ConfigItem { +/// One `//:=` line, from whichever `.npmrc` declared it. Every +/// file's lines are collected into one flat list and resolved together, the way +/// npm collapses its config files into a single map before reading credentials. +pub(crate) struct ConfigItem { + /// npm's registry key: the text between `//` and `:=` after + /// `normalize_key`, so `//127.0.0.1:1234/api/:_authToken=T` yields + /// `127.0.0.1:1234/api/`. Compared byte for byte from then on. pub(crate) registry_url: Box<[u8]>, pub(crate) optname: ConfigOpt, pub(crate) value: Box<[u8]>, pub(crate) loc: Loc, pub(crate) optname_loc: Loc, + /// Index into the `.npmrc` files parsed by `load_npmrc_config`, so a + /// diagnostic points at the file the line came from. + pub(crate) source_idx: u32, } // ────────────────────────────────────────────────────────────────────────── @@ -121,8 +126,8 @@ bun_core::comptime_string_map! { } pub use draft::{ - ConfigIterator, Parser, RegistryAuth, ScopeItem, ScopeIterator, ToStringFormatter, - apply_registry_auth, load_npmrc, load_npmrc_config, + ConfigIterator, Parser, ScopeItem, ScopeIterator, ToStringFormatter, load_npmrc, + load_npmrc_config, }; mod draft { @@ -1009,6 +1014,7 @@ mod draft { pub(crate) log: &'a mut Log, pub(crate) prop_idx: usize, + pub(crate) source_idx: u32, } impl<'a> ConfigIterator<'a> { @@ -1043,7 +1049,9 @@ mod draft { buf[1..1 + name.len()].copy_from_slice(name); let name_with_eq = &buf[..1 + name.len()]; - if let Some(index) = bun_core::last_index_of(key, name_with_eq) { + if let Some(index) = bun_core::last_index_of(key, name_with_eq) + .filter(|&index| index + name_with_eq.len() == key.len()) + { let url_part = &key[2..index]; if let Some(value_expr) = prop.value { if let Some(value) = value_expr.as_utf8_string_literal() { @@ -1057,16 +1065,27 @@ mod draft { None => keyexpr.loc, }; return Some(IniOption::Some(ConfigItem { - registry_url: Box::<[u8]>::from(url_part), + registry_url: normalize_key(url_part), value: Box::<[u8]>::from(value), optname: opt, loc: keyexpr.loc, optname_loc, + source_idx: self.source_idx, })); } } } } + // `//host/...:word=` where `word` is none of the seven options: npm + // warns here, and silence would hide a typo like `_authtoken`. + if let Some(colon) = bun_core::strings::last_index_of_char(key, b':') + .filter(|&colon| !bun_core::strings::contains_char(&key[colon..], b'/')) + { + return Some(IniOption::Unknown { + suffix: Box::from(&key[colon + 1..]), + loc: keyexpr.loc, + }); + } } } } @@ -1075,90 +1094,32 @@ mod draft { } } - // ────────────────────────────────────────────────────────────────────────── - // RegistryAuth - // ────────────────────────────────────────────────────────────────────────── - - pub struct RegistryAuth { - host: Box<[u8]>, - pathname: Box<[u8]>, - credential: RegistryCredential, - } - - enum RegistryCredential { - Token(Box<[u8]>), - /// `_auth`, verbatim: npm sends it as `Basic ` without decoding it. - Auth(Box<[u8]>), - Username(Box<[u8]>), - Password(Box<[u8]>), - Email(Box<[u8]>), - } - - impl RegistryAuth { - pub(crate) fn from_config_item( - item: ConfigItem, - log: &mut Log, - source: &Source, - ) -> Option { - let ConfigItem { - registry_url, - optname, - value, - loc, - optname_loc: _, - } = item; - let credential = match optname { - ConfigOpt::_AuthToken => RegistryCredential::Token(value), - ConfigOpt::Username => RegistryCredential::Username(value), - ConfigOpt::Email => RegistryCredential::Email(value), - // npm's `Buffer.from(value, "base64")` never fails: invalid bytes are - // skipped and as much as possible is decoded. - ConfigOpt::_Password => { - let mut decoded = vec![0u8; bun_base64::decode_lenient_len(value.len())]; - let count = bun_base64::decode_lenient(&mut decoded[..], &value, false); - decoded.truncate(count); - RegistryCredential::Password(decoded.into_boxed_slice()) - } - ConfigOpt::_Auth => { - if value.is_empty() { - log.add_error_opts( - b"empty _auth value: this line supplies no credentials", - bun_ast::AddErrorOptions { - source: Some(source), - loc, - redact_sensitive_information: true, - ..Default::default() - }, - ); - return None; - } - RegistryCredential::Auth(value) - } - ConfigOpt::Certfile | ConfigOpt::Keyfile => return None, - }; - let url = URL::parse(®istry_url); - Some(RegistryAuth { - host: bun_core::without_trailing_slash(url.host).into(), - pathname: bun_core::without_trailing_slash(url.pathname).into(), - credential, - }) - } - - pub(crate) fn matches(&self, registry_url: &[u8]) -> bool { - let url = URL::parse(registry_url); - bun_core::without_trailing_slash(url.host) == &*self.host - && bun_core::without_trailing_slash(url.pathname) == &*self.pathname - } - - pub(crate) fn apply_to(&self, registry: &mut NpmRegistry) { - match &self.credential { - RegistryCredential::Token(token) => registry.token.clone_from(token), - RegistryCredential::Auth(auth) => registry.auth.clone_from(auth), - RegistryCredential::Username(username) => registry.username.clone_from(username), - RegistryCredential::Password(password) => registry.password.clone_from(password), - RegistryCredential::Email(email) => registry.email.clone_from(email), + /// npm writes a key with `nerfDart(new URL(registry))`: no scheme, a lowercase + /// host, no default port. A hand-written key is folded to that shape once, here, + /// so the byte-for-byte lookups later behave like npm's map lookups. Bun's docs + /// long showed `//http://host/:_authToken=`, so a leading scheme is dropped too. + fn normalize_key(raw: &[u8]) -> Box<[u8]> { + let has_scheme = |scheme: &[u8]| { + raw.len() >= scheme.len() && raw[..scheme.len()].eq_ignore_ascii_case(scheme) + }; + let (default_port, rest): (&[u8], &[u8]) = if has_scheme(b"https://") { + (b"443", &raw[b"https://".len()..]) + } else if has_scheme(b"http://") { + (b"80", &raw[b"http://".len()..]) + } else { + (b"443", raw) + }; + let host_end = bun_core::strings::index_of_char_usize(rest, b'/').unwrap_or(rest.len()); + let mut key = rest.to_vec(); + key[..host_end].make_ascii_lowercase(); + // In a bracketed IPv6 authority only a colon after `]` introduces a port. + if let Some(colon) = bun_core::strings::last_index_of_char(&key[..host_end], b':') { + let is_port = key[0] != b'[' || (colon > 0 && key[colon - 1] == b']'); + if is_port && key[colon + 1..host_end] == *default_port { + key.drain(colon..host_end); } } + key.into_boxed_slice() } // ────────────────────────────────────────────────────────────────────────── @@ -1224,15 +1185,19 @@ mod draft { // loadNpmrcConfig / loadNpmrc // ────────────────────────────────────────────────────────────────────────── + /// Read every `.npmrc` into `install`, then resolve the collapsed credential lines + /// for the registries `install` and `bunfig` declare. A `bunfig` registry that came + /// with its own credentials keeps them: project config beats `.npmrc`. pub fn load_npmrc_config( install: &mut BunInstall, + bunfig: &mut BunInstall, env: &DotEnvLoader, auto_loaded: bool, npmrc_paths: &[&ZStr], - ) -> Vec { + ) { let mut log = Log::init(); - - let mut configs: Vec = Vec::new(); + let mut configs: Vec = Vec::new(); + let mut sources: Vec = Vec::new(); for &npmrc_path in npmrc_paths { let source = match bun_ast::source_from_file( @@ -1252,72 +1217,375 @@ mod draft { Global::crash(); } }; - // `source.contents` is owned; drops at end of loop iteration. - match load_npmrc(install, env, &mut log, &source, &mut configs) { + let source_idx = sources.len() as u32; + sources.push(source); + + match parse_npmrc_into( + install, + env, + &mut log, + &sources[source_idx as usize], + source_idx, + &mut configs, + ) { Ok(()) => {} Err(AllocError) => bun_core::out_of_memory(), } - if log.has_errors() { - if log.errors == 1 { - bun_core::warn!( - "Encountered an error while reading {}:\n\n", - bstr::BStr::new(npmrc_path.as_bytes()), - ); - } else { - bun_core::warn!( - "Encountered errors while reading {}:\n\n", - bstr::BStr::new(npmrc_path.as_bytes()), - ); + report_log(&mut log); + } + + let mut registries = resolve_credentials(install, &configs); + if !configs.is_empty() { + for registry in bunfig + .default_registry + .iter_mut() + .chain(bunfig.scoped.iter_mut().flat_map(|s| s.scopes.values_mut())) + { + if let Some(key) = RegistryKey::for_bunfig(registry) { + credentials(&configs, &key).apply(registry); + registries.push(key); } - Output::flush(); } - let _ = log.print(std::ptr::from_mut::( - Output::error_writer(), - )); } - configs + diagnose_config(&configs, &sources, ®istries, &mut log); + report_log(&mut log); + } + + /// Print and clear `log`. Errors get a header naming the file the first one came + /// from; the accumulated messages would otherwise reprint once per remaining file. + fn report_log(log: &mut Log) { + if log.has_errors() { + let path: &[u8] = log + .msgs + .iter() + .find(|msg| msg.kind == bun_ast::Kind::Err) + .and_then(|msg| msg.data.location.as_ref()) + .map_or(b"", |loc| &loc.file); + if log.errors == 1 { + bun_core::warn!( + "Encountered an error while reading {}:\n\n", + bstr::BStr::new(path), + ); + } else { + bun_core::warn!( + "Encountered errors while reading {}:\n\n", + bstr::BStr::new(path), + ); + } + Output::flush(); + } + let _ = log.print(std::ptr::from_mut::( + Output::error_writer(), + )); + log.reset(); } - pub fn apply_registry_auth(install: &mut BunInstall, auth: &[RegistryAuth]) { - if auth.is_empty() { + /// npm's `regKey.replace(/([^/]+|\/)$/, '')`: strip one trailing `/`, else the + /// trailing run of non-`/` bytes. + fn strip_one_key_component(key: &mut Vec) { + if key.last() == Some(&b'/') { + key.pop(); return; } - if let Some(registry) = install.default_registry.as_mut() { - if !registry.has_credentials() { - for item in auth { - let matched = item.matches(if registry.url.is_empty() { - bun_install_types::NodeLinker::npm::Registry::DEFAULT_URL.as_bytes() - } else { - ®istry.url - }); - if matched { - item.apply_to(registry); - } + while key.last().is_some_and(|&b| b != b'/') { + key.pop(); + } + } + + /// The port a WHATWG URL drops from `host` for this scheme. + fn default_port_for(protocol: &[u8]) -> Option<&'static [u8]> { + if protocol.eq_ignore_ascii_case(b"https") { + Some(b"443") + } else if protocol.eq_ignore_ascii_case(b"http") { + Some(b"80") + } else { + None + } + } + + fn url_or_default(url: &[u8]) -> &[u8] { + if url.is_empty() { + bun_install_types::NodeLinker::npm::Registry::DEFAULT_URL.as_bytes() + } else { + url + } + } + + /// A registry as the walk sees it. npm builds the key from a WHATWG URL, whose + /// `host` is lowercased and drops a default port; `bun_url` does neither, so the + /// authority is normalized here, the same way `normalize_key` folds config keys. + struct RegistryKey { + /// `/`: npm's key for the registry itself, and where the walk + /// starts (`regFetch` appends `/` to the registry URL and the first + /// iteration of the walk strips it right back off). + key: Box<[u8]>, + } + + impl RegistryKey { + fn from_url(url_bytes: &[u8]) -> RegistryKey { + Self::from_parsed(&URL::parse(url_bytes)) + } + + fn from_parsed(url: &URL<'_>) -> RegistryKey { + let default_port = default_port_for(url.protocol); + let host = if default_port.is_some_and(|port| url.port == port) { + url.hostname + } else { + url.host + }; + let pathname = url.pathname.strip_suffix(b"/").unwrap_or(url.pathname); + let mut key = Vec::with_capacity(host.len() + pathname.len() + 1); + key.extend_from_slice(host); + key[..host.len()].make_ascii_lowercase(); + key.extend_from_slice(pathname); + key.push(b'/'); + RegistryKey { + key: key.into_boxed_slice(), + } + } + + /// npm's `regFromURI`: this key, then one component shorter each time, down + /// to the bare host. For `h/a/` that is `h/a/`, `h/a`, `h/`, `h`. + fn walk(&self) -> impl Iterator> { + let mut next = Some(self.key.to_vec()); + core::iter::from_fn(move || { + let current = next.take()?; + let mut shorter = current.clone(); + strip_one_key_component(&mut shorter); + next = (!shorter.is_empty()).then_some(shorter); + Some(current) + }) + } + + /// The key for a registry `bunfig.toml` declared, or `None` when `bunfig.toml` + /// also gave it credentials: no `.npmrc` line can apply to it then, so none is + /// resolved or diagnosed against it. + fn for_bunfig(registry: &NpmRegistry) -> Option { + (!registry.has_credentials()) + .then(|| RegistryKey::from_url(url_or_default(®istry.url))) + } + + /// The key without its trailing `/`: a distinct config key that npm's walk + /// visits right after the slashed one. + fn unslashed(&self) -> &[u8] { + &self.key[..self.key.len() - 1] + } + } + + /// npm's config is a flat map, so a key repeated across `.npmrc` files collapses + /// to the last one read before any credential resolution happens. + fn lookup<'a>(configs: &'a [ConfigItem], key: &[u8], opt: ConfigOpt) -> Option<&'a ConfigItem> { + configs + .iter() + .rfind(|conf_item| conf_item.optname == opt && *conf_item.registry_url == *key) + } + + /// `lookup` plus npm's `opts[k]` truthiness test: an empty value supplies nothing. + /// The emptiness test comes AFTER the collapse, so a later `username=` clears an + /// earlier one rather than losing to it. + fn lookup_truthy<'a>( + configs: &'a [ConfigItem], + key: &[u8], + opt: ConfigOpt, + ) -> Option<&'a ConfigItem> { + lookup(configs, key, opt).filter(|conf_item| !conf_item.value.is_empty()) + } + + /// What one config key supplies, in npm's order: `_authToken`, else `_auth`, else + /// `username` + `_password`. `certfile`/`keyfile` are absent because Bun has no + /// mTLS, and honouring them would stop the walk on a key that supplies no credential. + enum Auth<'a> { + Token(&'a ConfigItem), + Basic(&'a ConfigItem), + UserPass { + username: &'a ConfigItem, + password: &'a ConfigItem, + }, + /// Bun-only: a half pair on the registry's own key layers over the URL's userinfo. + Partial { + username: Option<&'a ConfigItem>, + password: Option<&'a ConfigItem>, + }, + } + + /// npm's `hasAuth`, keyed on byte equality with the config key. + fn has_auth<'a>(configs: &'a [ConfigItem], key: &[u8]) -> Option> { + if let Some(token) = lookup_truthy(configs, key, ConfigOpt::_AuthToken) { + return Some(Auth::Token(token)); + } + if let Some(auth) = lookup_truthy(configs, key, ConfigOpt::_Auth) { + return Some(Auth::Basic(auth)); + } + let username = lookup_truthy(configs, key, ConfigOpt::Username)?; + let password = lookup_truthy(configs, key, ConfigOpt::_Password)?; + Some(Auth::UserPass { username, password }) + } + + /// Every line that applies to one registry. + struct Credentials<'a> { + auth: Option>, + /// `email` is not part of npm's auth at all, so it never walks. + email: Option<&'a ConfigItem>, + } + + /// npm's `regFromURI`: walk the registry's config keys deepest-first until one + /// supplies auth. For `host=h, pathname=/a/` that is `h/a/`, `h/a`, `h/`, `h`. + fn credentials<'a>(configs: &'a [ConfigItem], registry: &RegistryKey) -> Credentials<'a> { + let mut auth = registry.walk().find_map(|key| has_auth(configs, &key)); + if auth.is_none() { + for key in [®istry.key[..], registry.unslashed()] { + let username = lookup_truthy(configs, key, ConfigOpt::Username); + let password = lookup_truthy(configs, key, ConfigOpt::_Password); + if username.is_some() || password.is_some() { + auth = Some(Auth::Partial { username, password }); + break; } } } - if let Some(scoped) = install.scoped.as_mut() { - for registry in scoped.scopes.values_mut() { - if registry.has_credentials() { - continue; + let email = lookup_truthy(configs, ®istry.key, ConfigOpt::Email) + .or_else(|| lookup_truthy(configs, registry.unslashed(), ConfigOpt::Email)); + Credentials { auth, email } + } + + /// npm's `Buffer.from(value, "base64")` never fails: invalid bytes are skipped and + /// as much as possible is decoded. + fn decode_password(value: &[u8]) -> Box<[u8]> { + let mut decoded = vec![0u8; bun_base64::decode_lenient_len(value.len())]; + let count = bun_base64::decode_lenient(&mut decoded[..], value, false); + decoded.truncate(count); + decoded.into_boxed_slice() + } + + impl<'a> Credentials<'a> { + fn items(&self) -> impl Iterator { + let (a, b) = match &self.auth { + Some(Auth::Token(item) | Auth::Basic(item)) => (Some(*item), None), + Some(Auth::UserPass { username, password }) => (Some(*username), Some(*password)), + Some(Auth::Partial { username, password }) => (*username, *password), + None => (None, None), + }; + a.into_iter().chain(b).chain(self.email) + } + + fn apply(&self, v: &mut NpmRegistry) { + match &self.auth { + Some(Auth::Token(token)) => v.token.clone_from(&token.value), + // npm forwards `_auth` verbatim as `Basic `; `Scope::from_api` + // decodes it only to derive a username for `bun pm whoami`. + Some(Auth::Basic(auth)) => v.auth.clone_from(&auth.value), + Some(Auth::UserPass { username, password }) => { + v.username.clone_from(&username.value); + v.password = decode_password(&password.value); } - for item in auth { - let matched = item.matches(®istry.url); - if matched { - item.apply_to(registry); + Some(Auth::Partial { username, password }) => { + if let Some(username) = username { + v.username.clone_from(&username.value); + } + if let Some(password) = password { + v.password = decode_password(&password.value); } } + None => {} + } + if let Some(email) = self.email { + v.email.clone_from(&email.value); } } } + /// Resolve the registries `.npmrc` itself declares (or npm's default, when it + /// declares none) against the fully-accumulated `configs`, exactly once. Returns + /// the registries resolved, for `diagnose_config`. + fn resolve_credentials(install: &mut BunInstall, configs: &[ConfigItem]) -> Vec { + let mut registries: Vec = Vec::new(); + if configs.is_empty() { + return registries; + } + + let default_url: &[u8] = match &install.default_registry { + Some(registry) => ®istry.url, + None => b"", + }; + let default_key = RegistryKey::from_url(url_or_default(default_url)); + let default_credentials = credentials(configs, &default_key); + if default_credentials.items().next().is_some() { + default_credentials.apply(install.default_registry.get_or_insert_with(|| { + NpmRegistry { + url: bun_install_types::NodeLinker::npm::Registry::DEFAULT_URL + .as_bytes() + .into(), + ..Default::default() + } + })); + } + registries.push(default_key); + + if let Some(scoped) = install.scoped.as_mut() { + for v in scoped.scopes.values_mut() { + let key = RegistryKey::from_url(&v.url); + credentials(configs, &key).apply(v); + registries.push(key); + } + } + registries + } + + /// An empty `_auth` on a registry's own key supplies nothing; main errored on it and + /// so does this, once, against the line the key collapsed to. + fn diagnose_config( + configs: &[ConfigItem], + sources: &[Source], + registries: &[RegistryKey], + log: &mut Log, + ) { + for conf_item in configs.iter() { + if conf_item.optname != ConfigOpt::_Auth + || !conf_item.value.is_empty() + || !lookup(configs, &conf_item.registry_url, ConfigOpt::_Auth) + .is_some_and(|collapsed| core::ptr::eq(collapsed, conf_item)) + { + continue; + } + if registries.iter().any(|registry| { + *conf_item.registry_url == *registry.key + || *conf_item.registry_url == *registry.unslashed() + }) { + log.add_error_opts( + b"empty _auth value: this line supplies no credentials", + bun_ast::AddErrorOptions { + source: Some(&sources[conf_item.source_idx as usize]), + loc: conf_item.loc, + redact_sensitive_information: true, + ..Default::default() + }, + ); + } + } + } + + /// Single-file entry point (the `bun:internal-for-testing` hook). pub fn load_npmrc( install: &mut BunInstall, env: &DotEnvLoader, log: &mut Log, source: &Source, - configs: &mut Vec, + ) -> OOM<()> { + let mut configs: Vec = Vec::new(); + parse_npmrc_into(install, env, log, source, 0, &mut configs)?; + let registries = resolve_credentials(install, &configs); + diagnose_config(&configs, std::slice::from_ref(source), ®istries, log); + Ok(()) + } + + /// One file's options, `registry=` lines and `//host/…:=` lines (onto `configs`). + fn parse_npmrc_into( + install: &mut BunInstall, + env: &DotEnvLoader, + log: &mut Log, + source: &Source, + source_idx: u32, + configs: &mut Vec, ) -> OOM<()> { let arena = Arena::new(); let bump = &arena; @@ -1539,23 +1807,42 @@ mod draft { iter.count = false; while let Some(val) = iter.next()? { - if let Some(result) = val.get() { + if let IniOption::Some(result) = val { let registry = result.registry.clone(); registry_map.scopes.put(&*result.scope, registry)?; } } } + // Collect this file's `//host/…:=` lines. Credentials are resolved + // later, once, over the lines of every `.npmrc`. { let mut iter = ConfigIterator { config: out_obj, log, prop_idx: 0, + source_idx, }; while let Some(val) = iter.next() { - let Some(conf_item) = val.get() else { - continue; + let conf_item = match val { + IniOption::Some(conf_item) => conf_item, + IniOption::None => continue, + IniOption::Unknown { suffix, loc } => { + iter.log.add_warning_fmt_opts( + format_args!( + "{} is not a known .npmrc option; ignoring this line", + bstr::BStr::new(&suffix), + ), + bun_ast::AddErrorOptions { + source: Some(source), + loc, + redact_sensitive_information: true, + ..Default::default() + }, + ); + continue; + } }; if matches!(conf_item.optname, ConfigOpt::Certfile | ConfigOpt::Keyfile) { bun_ast::add_warning_pretty!( @@ -1567,40 +1854,12 @@ mod draft { ); continue; } - if let Some(auth) = RegistryAuth::from_config_item(conf_item, iter.log, source) { - configs.push(auth); - } - } - - if !configs.is_empty() { - for auth in configs.iter() { - let matched = auth.matches(install.default_registry.as_ref().map_or( - bun_install_types::NodeLinker::npm::Registry::DEFAULT_URL.as_bytes(), - |r| &*r.url, - )); - if matched { - auth.apply_to(install.default_registry.get_or_insert_with(|| { - NpmRegistry { - url: bun_install_types::NodeLinker::npm::Registry::DEFAULT_URL - .as_bytes() - .into(), - ..Default::default() - } - })); - } - for registry in registry_map.scopes.values_mut() { - if auth.matches(®istry.url) { - auth.apply_to(registry); - } - } - } + configs.push(conf_item); } } - // The single write-back happens here, after the registry-config loop - // has finished mutating the scope *values* in place. (An - // OOM `?` above leaves `install.scoped` as `None`, which is moot — install - // aborts on OOM.) + // An OOM `?` above leaves `install.scoped` as `None`, which is moot — + // install aborts on OOM. install.scoped = Some(registry_map); Ok(()) diff --git a/src/install/PackageManager.rs b/src/install/PackageManager.rs index dd33e38ce222..515f9f9aab90 100644 --- a/src/install/PackageManager.rs +++ b/src/install/PackageManager.rs @@ -1944,18 +1944,24 @@ pub fn init( } } - let registry_auth = if global_len > 0 { + if global_len > 0 { ini::load_npmrc_config( &mut install, + &mut bunfig_install, env, true, &[ZStr::from_buf(&buf[..], global_len), &*npmrc_local], - ) + ); } else { - ini::load_npmrc_config(&mut install, env, true, &[&*npmrc_local]) - }; + ini::load_npmrc_config( + &mut install, + &mut bunfig_install, + env, + true, + &[&*npmrc_local], + ); + } - ini::apply_registry_auth(&mut bunfig_install, ®istry_auth); overlay_bunfig_install(&mut install, bunfig_install); ctx.install = Some(Box::new(install)); } diff --git a/src/install_jsc/ini_jsc.rs b/src/install_jsc/ini_jsc.rs index 653443844dbf..e160b795c1d4 100644 --- a/src/install_jsc/ini_jsc.rs +++ b/src/install_jsc/ini_jsc.rs @@ -29,7 +29,7 @@ impl IniTestingAPIs { use bun_ast::{Log, Source}; use bun_core::String as BunString; use bun_dotenv as dotenv; - use bun_ini::{RegistryAuth, load_npmrc}; + use bun_ini::load_npmrc; use bun_install::npm::Registry; let arg = frame.argument(0); @@ -80,8 +80,7 @@ impl IniTestingAPIs { }; let mut install = Box::new(BunInstall::default()); - let mut configs: Vec = Vec::new(); - if load_npmrc(&mut install, env, &mut log, &source, &mut configs).is_err() { + if load_npmrc(&mut install, env, &mut log, &source).is_err() { return log.to_js(global, format_args!("error")); } diff --git a/test/cli/install/bun-install.test.ts b/test/cli/install/bun-install.test.ts index 1fdce18fed77..0e5c066991cd 100644 --- a/test/cli/install/bun-install.test.ts +++ b/test/cli/install/bun-install.test.ts @@ -749,196 +749,814 @@ describe.concurrent("bun-install", () => { }); }); - // A yarn-style credential inside the registry URL is stripped from the path before - // the request goes out, whether or not `.npmrc` also supplies one. Otherwise the - // secret ships in the request path, where proxies and logs can see it. - describe("credentials embedded in the registry URL", () => { - // Returns the Authorization header and the request path the registry saw. - async function probeEmbedded( - registryPath: string, + // https://github.com/oven-sh/bun/issues/30311 + // npm resolves `.npmrc` credentials by walking UP the registry URL's path + // segments and applying the LONGEST matching ancestor. A bare string prefix + // (`/projects/12` for `/projects/123/...`) is NOT an ancestor and must not match. + describe(".npmrc auth resolves by path-segment ancestor", () => { + const scope = "myorg"; + const registryPath = "/api/v4/projects/123/packages/npm/"; + + type ProbeOptions = { + /** `@myorg:registry=` (the default) or the unscoped `registry=` line. */ + registry?: "scoped" | "default"; + /** Userinfo embedded in the registry URL, e.g. `"user:pass@"`. */ + userinfo?: string; + /** Declare the registry in `bunfig.toml` with this token instead of in `.npmrc`. */ + bunfigToken?: string; + /** Declare the registry in `bunfig.toml` with these credentials instead of in `.npmrc`. */ + bunfigBasic?: { username: string; password: string }; + /** Declare the registry in `bunfig.toml` with no credentials, leaving them to `.npmrc`. */ + bunfigBare?: true; + /** Lines for `$HOME/.npmrc`, which npm/Bun read before the project's `.npmrc`. */ + homeNpmrc?: (host: string) => string; + /** The registry's path. Both the registry line and the manifest request use it. */ + path?: string; + }; + + // Runs `bun install` against a local registry mounted at `registryPath` and + // returns the `Authorization` header it received (or null). + async function probeAuthorization( authLines: (host: string) => string, - userinfo = "", - { cli = false }: { cli?: boolean } = {}, - ) { - const seen: Array<{ path: string; auth: string | null }> = []; - await using registry = Bun.serve({ - port: 0, - hostname: "127.0.0.1", - fetch(req) { - seen.push({ path: new URL(req.url).pathname, auth: req.headers.get("authorization") }); - return new Response("not found", { status: 404 }); - }, - }); - const host = `127.0.0.1:${registry.port}`; - const registryUrl = `http://${userinfo}${host}${registryPath}`; - using dir = tempDir("npmrc-embedded-auth", { - ".npmrc": cli ? `${authLines(host)}\n` : `@myorg:registry=${registryUrl}\n${authLines(host)}\n`, - "package.json": JSON.stringify({ name: "probe", version: "0.0.0", dependencies: { "@myorg/pkg": "1.0.0" } }), - "home/.gitkeep": "", - }); - const home = join(String(dir), "home"); - await using proc = Bun.spawn({ - cmd: [bunExe(), "install", "--no-cache", ...(cli ? ["--registry", registryUrl] : [])], - cwd: String(dir), - env: { ...env, HOME: home, USERPROFILE: home, XDG_CONFIG_HOME: home }, - stdout: "pipe", - stderr: "pipe", - stdin: "ignore", - }); - const [, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); - expect({ requests: seen.length, exitCode, stderr }).toEqual({ - requests: 1, - exitCode: 1, - stderr: expect.any(String), - }); - return seen[0]!; - } + { + registry: registryKind = "scoped", + userinfo = "", + bunfigToken, + bunfigBasic, + bunfigBare, + homeNpmrc, + path: regPath = registryPath, + }: ProbeOptions = {}, + ): Promise { + const scoped = registryKind === "scoped"; + const depName = scoped ? `@${scope}/pkg` : "pkg"; + const manifestPath = `${regPath}${scoped ? `@${scope}%2fpkg` : "pkg"}`; + const authorizations: (string | null)[] = []; + const paths: string[] = []; - // The bunfig object form is the one way into the scope builder with a token already - // set, so it pins that the strip does not depend on the credential being adopted. - async function probeBunfig(registryPath: string, token: string) { - const seen: Array<{ path: string; auth: string | null }> = []; await using registry = Bun.serve({ port: 0, hostname: "127.0.0.1", fetch(req) { - seen.push({ path: new URL(req.url).pathname, auth: req.headers.get("authorization") }); + paths.push(new URL(req.url).pathname); + authorizations.push(req.headers.get("authorization")); return new Response("not found", { status: 404 }); }, }); + const host = `127.0.0.1:${registry.port}`; - using dir = tempDir("bunfig-embedded-auth", { - "bunfig.toml": `[install.scopes]\nmyorg = { url = "http://${host}${registryPath}", token = "${token}" }\n`, - "package.json": JSON.stringify({ name: "probe", version: "0.0.0", dependencies: { "@myorg/pkg": "1.0.0" } }), - "home/.gitkeep": "", - }); + const registryUrl = `http://${userinfo}${host}${regPath}`; + const registryLine = scoped ? `@${scope}:registry=${registryUrl}` : `registry=${registryUrl}`; + + // A `registry=` line in `.npmrc` replaces the registry it names, discarding the + // credentials `bunfig.toml` gave it, so the two spellings are exclusive. + const inBunfig = bunfigToken !== undefined || bunfigBasic !== undefined || bunfigBare === true; + const files: Record = { + ".npmrc": `${inBunfig ? "" : `${registryLine}\n`}${authLines(host)}\n`, + "package.json": JSON.stringify({ + name: "probe", + version: "0.0.0", + dependencies: { [depName]: "1.0.0" }, + }), + // `HOME` points here, not at the project dir: pointing it at the project would + // load the same `.npmrc` twice and hide cross-file resolution bugs. + ...(homeNpmrc ? { "home/.npmrc": `${homeNpmrc(host)}\n` } : { "home/.gitkeep": "" }), + }; + if (inBunfig) { + const creds = + bunfigToken !== undefined + ? { token: bunfigToken } + : bunfigBasic !== undefined + ? { username: bunfigBasic.username, password: bunfigBasic.password } + : {}; + const registryEntry = { url: registryUrl, ...creds }; + files["bunfig.toml"] = Bun.TOML.stringify( + scoped ? { install: { scopes: { [scope]: registryEntry } } } : { install: { registry: registryEntry } }, + ); + } + + using dir = tempDir("npmrc-auth-ancestor", files); const home = join(String(dir), "home"); + await using proc = Bun.spawn({ cmd: [bunExe(), "install", "--no-cache"], cwd: String(dir), + // An empty home: the developer's own `.npmrc` declares a `registry=` that + // would replace the one under test. env: { ...env, HOME: home, USERPROFILE: home, XDG_CONFIG_HOME: home }, stdout: "pipe", stderr: "pipe", stdin: "ignore", }); - const [, , exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); - expect({ requests: seen.length, exitCode }).toEqual({ requests: 1, exitCode: 1 }); - return seen[0]!; + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + // The manifest request must actually have reached our registry, otherwise + // the assertions below would be vacuous. + expect({ paths, saw404: stderr.includes("404"), startedInstall: stdout.includes("bun install") }).toEqual({ + paths: [manifestPath], + saw404: true, + startedInstall: true, + }); + expect(exitCode).not.toBe(0); + + return authorizations[0]!; } - // Bun's JFrog guide writes the credential as a path segment, `/npm/_auth=`. - // Base64 holds `/`, so the marker, not the last slash, must anchor the strip. - it("strips a slash-form _auth whose base64 value contains a slash", async () => { - const seen = await probeEmbedded("/api/npm/_auth=YWJj/ZGVm", () => ""); - expect(seen).toEqual({ path: "/api/npm/@myorg%2fpkg", auth: "Basic YWJj/ZGVm" }); - }); + const token = "walkup-secret-token"; + const b64 = (s: string) => Buffer.from(s).toString("base64"); + + // Matrix measured against npm 10.9.3 / 11.15.0 for registry pathname + // `/api/v4/projects/123/packages/npm/`. + const matrix: Array<[name: string, confPath: string, expected: string | null]> = [ + ["host root", "/", `Bearer ${token}`], + ["shallow ancestor", "/api/", `Bearer ${token}`], + ["mid ancestor", "/api/v4/projects/", `Bearer ${token}`], + ["exact match with trailing slash", registryPath, `Bearer ${token}`], + ["exact match without trailing slash", registryPath.slice(0, -1), `Bearer ${token}`], + // SECURITY: `/api/v4/projects/12` is a string prefix of the registry path + // but not a path-segment ancestor. A `startsWith` implementation leaks + // project 123's credentials to whoever controls project 12. + ["string prefix, not an ancestor (trailing slash)", "/api/v4/projects/12/", null], + ["string prefix, not an ancestor (no trailing slash)", "/api/v4/projects/12", null], + ["unrelated sibling path", "/api/v4/projects/123/packages/other/", null], + ["deeper than the registry path", `${registryPath}deeper/`, null], + ]; - it("strips a slash-form _authToken whose value contains a slash", async () => { - const seen = await probeEmbedded("/api/_authToken=a/b", () => ""); - expect(seen).toEqual({ path: "/api/@myorg%2fpkg", auth: "Bearer a/b" }); + it.each(matrix)("%s", async (_name, confPath, expected) => { + const auth = await probeAuthorization(host => `//${host}${confPath}:_authToken=${token}`); + expect(auth).toBe(expected); }); - it("strips slash-form username and _password segments", async () => { - const seen = await probeEmbedded("/api/username=u/_password=p/q", () => ""); - expect(seen).toEqual({ path: "/api/@myorg%2fpkg", auth: `Basic ${Buffer.from("u:p/q").toString("base64")}` }); + // Longest ancestor wins, independent of the order the lines appear in. + it("longest match wins: root then deep", async () => { + const auth = await probeAuthorization( + host => `//${host}/:_authToken=ROOT\n//${host}${registryPath}:_authToken=DEEP`, + ); + expect(auth).toBe("Bearer DEEP"); }); - it("leaves a plain `=` in a path segment alone", async () => { - const seen = await probeEmbedded("/a=b/c/", () => ""); - expect(seen).toEqual({ path: "/a=b/c/@myorg%2fpkg", auth: null }); + it("longest match wins: deep then root", async () => { + const auth = await probeAuthorization( + host => `//${host}${registryPath}:_authToken=DEEP\n//${host}/:_authToken=ROOT`, + ); + expect(auth).toBe("Bearer DEEP"); }); - // `--registry` used to bypass the scope builder; with a trailing slash after the - // marker the token went out in every request path. - it("strips an embedded _authToken from a --registry URL with a trailing slash", async () => { - const seen = await probeEmbedded("/api/:_authToken=S/", () => "", "", { cli: true }); - expect(seen).toEqual({ path: "/api/@myorg%2fpkg", auth: "Bearer S" }); + it("longest match wins: mid ancestor beats root", async () => { + const auth = await probeAuthorization(host => `//${host}/api/v4/:_authToken=MID\n//${host}/:_authToken=ROOT`); + expect(auth).toBe("Bearer MID"); }); - it("keeps the .npmrc token of the same host over a --registry URL's embedded one", async () => { - const seen = await probeEmbedded( - "/api/:_authToken=S/", - host => `registry=http://${host}/\n//${host}/:_authToken=T`, - "", - { cli: true }, + it("longest match wins: non-ancestor deeper line does not shadow the root line", async () => { + const auth = await probeAuthorization( + host => `//${host}/:_authToken=ROOT\n//${host}/api/v4/projects/12/:_authToken=ATTACKER`, ); - expect(seen).toEqual({ path: "/api/@myorg%2fpkg", auth: "Bearer T" }); + expect(auth).toBe("Bearer ROOT"); }); - it("strips an embedded _auth when bunfig already supplies a token", async () => { - const seen = await probeBunfig("/api/:_auth=opaque-blob", "T"); - expect(seen).toEqual({ path: "/api/@myorg%2fpkg", auth: "Bearer T" }); + // Ancestor matching governs the whole config item, not just `_authToken`. + it("walks up for _auth", async () => { + const basic = Buffer.from("linus:verysecure").toString("base64"); + const auth = await probeAuthorization(host => `//${host}/:_auth=${basic}`); + expect(auth).toBe(`Basic ${basic}`); }); - it("keeps bunfig's token over an embedded _authToken", async () => { - const seen = await probeBunfig("/api/:_authToken=EMBEDDED", "T"); - expect(seen).toEqual({ path: "/api/@myorg%2fpkg", auth: "Bearer T" }); + it("walks up for username + _password", async () => { + const password = Buffer.from("verysecure").toString("base64"); + const auth = await probeAuthorization(host => `//${host}/:username=gandalf\n//${host}/:_password=${password}`); + expect(auth).toBe(`Basic ${Buffer.from("gandalf:verysecure").toString("base64")}`); }); - it("strips an embedded _authToken from the path and uses it when .npmrc has none", async () => { - const seen = await probeEmbedded("/api/:_authToken=EMBEDDED", () => ""); - expect(seen).toEqual({ path: "/api/@myorg%2fpkg", auth: "Bearer EMBEDDED" }); + it("sends no Authorization header when no config item matches the host", async () => { + const auth = await probeAuthorization(() => `//other.example.com/:_authToken=${token}`); + expect(auth).toBe(null); }); - // main only stripped the marker while looking for a credential to adopt; with - // userinfo already in the URL it skipped the scan and the token shipped in the path. - it("strips an embedded _authToken from the path when the URL also carries userinfo", async () => { - const seen = await probeEmbedded("/api/:_authToken=EMBEDDED", () => "", "u:p@"); - expect(seen).toEqual({ path: "/api/@myorg%2fpkg", auth: `Basic ${btoa("u:p")}` }); + // Measured against npm 10.9.3 and 11.15.0: npm picks ONE config path per + // registry — the deepest ancestor carrying `_authToken`, `_auth`, or a complete + // `username` + `_password` pair — and reads every credential from that path + // alone. Options are never resolved independently of each other. Within that one + // path npm's `Auth` ctor picks by precedence, not file order: + // `_authToken` > `_auth` > `username` + `_password`. + describe("credentials resolve per config path, not per option", () => { + const password = Buffer.from("verysecure").toString("base64"); + const basic = Buffer.from("gandalf:verysecure").toString("base64"); + const frodo = Buffer.from("frodo:onering").toString("base64"); + + it("a split username/_password pair authenticates with neither", async () => { + const auth = await probeAuthorization( + host => `//${host}/:username=gandalf\n//${host}${registryPath}:_password=${password}`, + ); + expect(auth).toBe(null); + }); + + it("a split _password/username pair authenticates with neither", async () => { + const auth = await probeAuthorization( + host => `//${host}${registryPath}:username=gandalf\n//${host}/:_password=${password}`, + ); + expect(auth).toBe(null); + }); + + it("a deeper username + _password shadows a shallower _authToken", async () => { + const auth = await probeAuthorization( + host => + `//${host}/:_authToken=ROOT\n//${host}${registryPath}:username=gandalf\n//${host}${registryPath}:_password=${password}`, + ); + expect(auth).toBe(`Basic ${basic}`); + }); + + it("a deeper _authToken shadows a shallower username + _password", async () => { + const auth = await probeAuthorization( + host => + `//${host}${registryPath}:_authToken=DEEP\n//${host}/:username=gandalf\n//${host}/:_password=${password}`, + ); + expect(auth).toBe("Bearer DEEP"); + }); + + it("a deeper _auth shadows a shallower _authToken", async () => { + const auth = await probeAuthorization( + host => `//${host}/:_authToken=ROOT\n//${host}${registryPath}:_auth=${basic}`, + ); + expect(auth).toBe(`Basic ${basic}`); + }); + + // Same path, both credentials: npm's `Auth` ctor is + // `if (token) … else if (auth) … else if (username && password)`, so `_auth` + // wins no matter which line the file lists first. + it("_auth beats username + _password at the same path: _auth first", async () => { + const auth = await probeAuthorization( + host => `//${host}/:_auth=${frodo}\n//${host}/:username=gandalf\n//${host}/:_password=${password}`, + ); + expect(auth).toBe(`Basic ${frodo}`); + }); + + it("_auth beats username + _password at the same path: username first", async () => { + const auth = await probeAuthorization( + host => `//${host}/:username=gandalf\n//${host}/:_password=${password}\n//${host}/:_auth=${frodo}`, + ); + expect(auth).toBe(`Basic ${frodo}`); + }); + + // `if (token)` comes before `else if (auth)`, so `_authToken` wins over + // `_auth` at the same path regardless of line order. + it("_authToken beats _auth at the same path: _authToken first", async () => { + const auth = await probeAuthorization(host => `//${host}/:_authToken=TOKEN\n//${host}/:_auth=${frodo}`); + expect(auth).toBe("Bearer TOKEN"); + }); + + it("_authToken beats _auth at the same path: _auth first", async () => { + const auth = await probeAuthorization(host => `//${host}/:_auth=${frodo}\n//${host}/:_authToken=TOKEN`); + expect(auth).toBe("Bearer TOKEN"); + }); + + it("_authToken beats username + _password at the same path: _authToken first", async () => { + const auth = await probeAuthorization( + host => `//${host}/:_authToken=TOKEN\n//${host}/:username=gandalf\n//${host}/:_password=${password}`, + ); + expect(auth).toBe("Bearer TOKEN"); + }); + + it("_authToken beats username + _password at the same path: username first", async () => { + const auth = await probeAuthorization( + host => `//${host}/:username=gandalf\n//${host}/:_password=${password}\n//${host}/:_authToken=TOKEN`, + ); + expect(auth).toBe("Bearer TOKEN"); + }); + + it("a deeper email is not a credential and does not shadow a shallower _authToken", async () => { + const auth = await probeAuthorization( + host => `//${host}/:_authToken=ROOT\n//${host}${registryPath}:email=gandalf@example.com`, + ); + expect(auth).toBe("Bearer ROOT"); + }); + + it("a deeper username without a _password does not shadow a shallower _authToken", async () => { + const auth = await probeAuthorization( + host => `//${host}/:_authToken=ROOT\n//${host}${registryPath}:username=gandalf`, + ); + expect(auth).toBe("Bearer ROOT"); + }); + + it("a deeper username without a _password does not shadow a shallower username + _password", async () => { + const auth = await probeAuthorization( + host => + `//${host}/:username=gandalf\n//${host}/:_password=${password}\n//${host}${registryPath}:username=saruman`, + ); + expect(auth).toBe(`Basic ${basic}`); + }); }); - // A bare `:` belongs to the path. Only `name=value` segments naming a credential - // are stripped, so a registry mounted under `/a:b/c/` keeps its path. - it("leaves a plain colon in the registry path alone", async () => { - const seen = await probeEmbedded("/a:b/c/", host => `//${host}/a:b/c/:_authToken=TOK`); - expect(seen).toEqual({ path: "/a:b/c/@myorg%2fpkg", auth: "Bearer TOK" }); + // npm's walk strips a trailing `/` and the segment before it in separate steps, + // so `//host/api/v4/` and `//host/api/v4` are two different config paths (the + // slashed one checked first), as are `//host/` and `//host`. + describe("a trailing slash makes a distinct config path", () => { + const password = Buffer.from("verysecure").toString("base64"); + + // The slashed line comes first, so file order cannot be what picks it. + it("the slashed path is deeper than its unslashed twin", async () => { + const auth = await probeAuthorization( + host => `//${host}/api/v4/:_authToken=SLASH\n//${host}/api/v4:_authToken=NOSLASH`, + ); + expect(auth).toBe("Bearer SLASH"); + }); + + it("the slashed host root is deeper than the bare host", async () => { + const auth = await probeAuthorization(host => `//${host}/:_authToken=SLASH\n//${host}:_authToken=BARE`); + expect(auth).toBe("Bearer SLASH"); + }); + + it("the bare host still matches when nothing deeper does", async () => { + const auth = await probeAuthorization(host => `//${host}:_authToken=BARE`); + expect(auth).toBe("Bearer BARE"); + }); + + // `//host:username` and `//host/:_password` are two config keys, neither of which + // is a complete credential. npm composes nothing from them, and neither may Bun — + // including when the registry sits at the host root, so both keys are its own. + it("a username and a _password split across the two host-root spellings authenticate with neither", async () => { + const auth = await probeAuthorization(host => `//${host}:username=gandalf\n//${host}/:_password=${password}`); + expect(auth).toBe(null); + }); + + it("the same split, with the registry at the host root, still authenticates with neither", async () => { + const auth = await probeAuthorization(host => `//${host}:username=gandalf\n//${host}/:_password=${password}`, { + path: "/", + }); + expect(auth).toBe(null); + }); }); - it("leaves a plain colon in the registry path alone when no credential is configured", async () => { - const seen = await probeEmbedded("/a:b/c/", () => ""); - expect(seen).toEqual({ path: "/a:b/c/@myorg%2fpkg", auth: null }); + // Bun does not implement certificate auth: it warns and ignores `certfile`/`keyfile`. + // An unsupported option must never suppress credentials Bun can actually send. + describe("certfile + keyfile are ignored", () => { + it("a complete pair does not stop a shallower _authToken", async () => { + const auth = await probeAuthorization( + host => + `//${host}/:_authToken=${token}\n//${host}${registryPath}:certfile=a.pem\n//${host}${registryPath}:keyfile=b.key`, + ); + expect(auth).toBe(`Bearer ${token}`); + }); + + it("a lone certfile does not stop a shallower _authToken", async () => { + const auth = await probeAuthorization( + host => `//${host}/:_authToken=${token}\n//${host}${registryPath}:certfile=a.pem`, + ); + expect(auth).toBe(`Bearer ${token}`); + }); + + it("a lone keyfile does not stop a shallower _authToken", async () => { + const auth = await probeAuthorization( + host => `//${host}/:_authToken=${token}\n//${host}${registryPath}:keyfile=b.key`, + ); + expect(auth).toBe(`Bearer ${token}`); + }); + + it("a deeper _authToken still beats a shallower complete pair", async () => { + const auth = await probeAuthorization( + host => `//${host}/:certfile=a.pem\n//${host}/:keyfile=b.key\n//${host}${registryPath}:_authToken=${token}`, + ); + expect(auth).toBe(`Bearer ${token}`); + }); + + // `bunfig.toml` and registry-URL userinfo are Bun's second credential layer. + it("a complete pair leaves a bunfig.toml token intact on a scoped registry", async () => { + const auth = await probeAuthorization( + host => `//${host}${registryPath}:certfile=a.pem\n//${host}${registryPath}:keyfile=b.key`, + { bunfigToken: token }, + ); + expect(auth).toBe(`Bearer ${token}`); + }); + + it("a complete pair leaves a bunfig.toml token intact on the default registry", async () => { + const auth = await probeAuthorization( + host => `//${host}${registryPath}:certfile=a.pem\n//${host}${registryPath}:keyfile=b.key`, + { registry: "default", bunfigToken: token }, + ); + expect(auth).toBe(`Bearer ${token}`); + }); + + it("a complete pair leaves the registry URL's userinfo intact", async () => { + const auth = await probeAuthorization( + host => `//${host}${registryPath}:certfile=a.pem\n//${host}${registryPath}:keyfile=b.key`, + { registry: "default", userinfo: "user:pass@" }, + ); + expect(auth).toBe(`Basic ${Buffer.from("user:pass").toString("base64")}`); + }); + + it("a complete pair on a shallower path leaves a bunfig.toml token intact", async () => { + const auth = await probeAuthorization(host => `//${host}/:certfile=a.pem\n//${host}/:keyfile=b.key`, { + bunfigToken: token, + }); + expect(auth).toBe(`Bearer ${token}`); + }); + + it("a lone certfile leaves a bunfig.toml token intact", async () => { + const auth = await probeAuthorization(host => `//${host}${registryPath}:certfile=a.pem`, { + bunfigToken: token, + }); + expect(auth).toBe(`Bearer ${token}`); + }); + + it("a lone keyfile leaves the registry URL's userinfo intact", async () => { + const auth = await probeAuthorization(host => `//${host}${registryPath}:keyfile=b.key`, { + registry: "default", + userinfo: "user:pass@", + }); + expect(auth).toBe(`Basic ${Buffer.from("user:pass").toString("base64")}`); + }); + + it("a bunfig.toml token with no matching .npmrc line is sent", async () => { + const auth = await probeAuthorization(() => `//other.example.com/:_authToken=OTHER`, { bunfigToken: token }); + expect(auth).toBe(`Bearer ${token}`); + }); }); - // The scan anchors on the `:=` marker, not on any `:`, so a colon inside the - // value neither ends it nor splits it. - it("strips an embedded _authToken whose value contains a colon", async () => { - const seen = await probeEmbedded("/api/:_authToken=aa:bb", () => ""); - expect(seen).toEqual({ path: "/api/@myorg%2fpkg", auth: "Bearer aa:bb" }); + // npm merges every `.npmrc` into ONE flat config map before it resolves anything, + // so a key repeated across files collapses last-write-wins *before* `hasAuth` runs. + // Resolving per-file instead makes the home file's `_authToken` win and the + // project's `_auth` unreachable. + describe("keys collapse across .npmrc files before resolution", () => { + const path = "/api/v4/"; + const basic = Buffer.from("alice:s3cret").toString("base64"); + + // Measured against npm 10.9.3: `Basic `. + it("the project's empty _authToken falsifies the home file's token, so _auth wins", async () => { + const auth = await probeAuthorization(host => `//${host}${path}:_authToken=\n//${host}${path}:_auth=${basic}`, { + path, + homeNpmrc: host => `//${host}${path}:_authToken=HOMETOKEN`, + }); + expect(auth).toBe(`Basic ${basic}`); + }); + + it("a project token overrides the home file's token at the same key", async () => { + const auth = await probeAuthorization(host => `//${host}${path}:_authToken=PROJECT`, { + path, + homeNpmrc: host => `//${host}${path}:_authToken=HOMETOKEN`, + }); + expect(auth).toBe("Bearer PROJECT"); + }); + + it("the home file's token applies when the project declares nothing", async () => { + const auth = await probeAuthorization(() => "", { + path, + homeNpmrc: host => `//${host}${path}:_authToken=HOMETOKEN`, + }); + expect(auth).toBe("Bearer HOMETOKEN"); + }); + + // The home file's key is a strict ancestor, so it is never even consulted: + // `hasAuth` stops at the deeper key the project file supplies. + it("a deeper project token beats a shallower home token", async () => { + const auth = await probeAuthorization(host => `//${host}${path}:_authToken=PROJECT`, { + path, + homeNpmrc: host => `//${host}/:_authToken=HOMETOKEN`, + }); + expect(auth).toBe("Bearer PROJECT"); + }); + + // The emptiness test happens AFTER the collapse, so a later empty value clears an + // earlier one instead of losing to it. npm 10.9.3 sends no header for both. + const homeBasic = (host: string) => + `//${host}${path}:username=alice\n//${host}${path}:_password=${Buffer.from("s3cret").toString("base64")}`; + + it("the project's empty username clears the home file's username and password pair", async () => { + const auth = await probeAuthorization(host => `//${host}${path}:username=`, { path, homeNpmrc: homeBasic }); + expect(auth).toBe(null); + }); + + it("the project's empty _password clears the home file's username and password pair", async () => { + const auth = await probeAuthorization(host => `//${host}${path}:_password=`, { path, homeNpmrc: homeBasic }); + expect(auth).toBe(null); + }); + + it("a project username overrides the home file's at the same key", async () => { + const auth = await probeAuthorization(host => `//${host}${path}:username=bob`, { path, homeNpmrc: homeBasic }); + expect(auth).toBe(`Basic ${Buffer.from("bob:s3cret").toString("base64")}`); + }); }); - // An embedded `_password` is used verbatim, unlike an `.npmrc` one, which is base64. - it("strips an embedded username and _password whose value contains a colon", async () => { - const seen = await probeEmbedded("/api/:username=u/:_password=p:q", () => ""); - expect(seen).toEqual({ path: "/api/@myorg%2fpkg", auth: `Basic ${Buffer.from("u:p:q").toString("base64")}` }); + // npm never decodes `_auth`; it forwards the value as `Basic `. An opaque + // blob and a blank password are credentials, not errors. + describe("_auth is forwarded verbatim", () => { + it("sends an opaque _auth blob that does not decode to user:pass", async () => { + const blob = b64("opaquetokenblob"); + const auth = await probeAuthorization(host => `//${host}${registryPath}:_auth=${blob}`); + expect(auth).toBe(`Basic ${blob}`); + }); + + it("sends an _auth that is not even valid base64", async () => { + const blob = "!!not-base64!!"; + const auth = await probeAuthorization(host => `//${host}${registryPath}:_auth=${blob}`); + expect(auth).toBe(`Basic ${blob}`); + }); + + it("sends an _auth with a blank password", async () => { + const value = b64("tok:"); + const auth = await probeAuthorization(host => `//${host}${registryPath}:_auth=${value}`); + expect(auth).toBe(`Basic ${value}`); + }); + + it("prefers an opaque _auth over username and _password at the same key", async () => { + const blob = b64("opaquetokenblob"); + const auth = await probeAuthorization( + host => + `//${host}${registryPath}:_auth=${blob}\n` + + `//${host}${registryPath}:username=x\n` + + `//${host}${registryPath}:_password=${b64("y")}`, + ); + expect(auth).toBe(`Basic ${blob}`); + }); + + it("sends a decodable _auth verbatim rather than re-encoding it", async () => { + const value = b64("ab:cd"); + const auth = await probeAuthorization(host => `//${host}${registryPath}:_auth=${value}`); + expect(auth).toBe(`Basic ${value}`); + }); + + // Which credential wins must not depend on whether `_auth` decodes: with a + // username + password also stored on the registry (here from the URL's + // userinfo), all three blobs go out verbatim. + const userinfo = { registry: "default", userinfo: "url-user:url-pass@" } as const; + + it("a non-decodable _auth wins over the registry's username + password", async () => { + const blob = "!!not-base64!!"; + const auth = await probeAuthorization(host => `//${host}${registryPath}:_auth=${blob}`, userinfo); + expect(auth).toBe(`Basic ${blob}`); + }); + + it("a colon-less _auth wins over the registry's username + password", async () => { + const blob = b64("opaquetokenblob"); + const auth = await probeAuthorization(host => `//${host}${registryPath}:_auth=${blob}`, userinfo); + expect(auth).toBe(`Basic ${blob}`); + }); + + it("a decodable _auth wins over the registry's username + password, verbatim", async () => { + const value = b64("alice:s3cret"); + const auth = await probeAuthorization(host => `//${host}${registryPath}:_auth=${value}`, userinfo); + expect(auth).toBe(`Basic ${value}`); + }); }); - // `_authToken` ends what is read, not what is stripped: a segment to its left is - // still a secret, and leaving it behind puts it in the request path. - it("strips a _password written to the left of the _authToken it loses to", async () => { - const seen = await probeEmbedded("/api/:_password=cA==:_authToken=T", () => ""); - expect(seen).toEqual({ path: "/api/@myorg%2fpkg", auth: "Bearer T" }); + // A yarn-style credential inside the registry URL is stripped from the path before + // the request goes out, whether or not `.npmrc` also supplies one. Otherwise the + // secret ships in the request path, where proxies and logs can see it. + describe("credentials embedded in the registry URL", () => { + // Returns the Authorization header and the request path the registry saw. + async function probeEmbedded(registryPath: string, authLines: (host: string) => string, userinfo = "") { + const seen: Array<{ path: string; auth: string | null }> = []; + await using registry = Bun.serve({ + port: 0, + hostname: "127.0.0.1", + fetch(req) { + seen.push({ path: new URL(req.url).pathname, auth: req.headers.get("authorization") }); + return new Response("not found", { status: 404 }); + }, + }); + const host = `127.0.0.1:${registry.port}`; + using dir = tempDir("npmrc-embedded-auth", { + ".npmrc": `@myorg:registry=http://${userinfo}${host}${registryPath}\n${authLines(host)}\n`, + "package.json": JSON.stringify({ name: "probe", version: "0.0.0", dependencies: { "@myorg/pkg": "1.0.0" } }), + "home/.gitkeep": "", + }); + const home = join(String(dir), "home"); + await using proc = Bun.spawn({ + cmd: [bunExe(), "install", "--no-cache"], + cwd: String(dir), + env: { ...env, HOME: home, USERPROFILE: home, XDG_CONFIG_HOME: home }, + stdout: "pipe", + stderr: "pipe", + stdin: "ignore", + }); + const [, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ requests: seen.length, exitCode, stderr }).toEqual({ + requests: 1, + exitCode: 1, + stderr: expect.any(String), + }); + return seen[0]!; + } + + it("strips an embedded _authToken from the path and uses it when .npmrc has none", async () => { + const seen = await probeEmbedded("/api/:_authToken=EMBEDDED", () => ""); + expect(seen).toEqual({ path: "/api/@myorg%2fpkg", auth: "Bearer EMBEDDED" }); + }); + + it("strips an embedded _authToken from the path even when an .npmrc ancestor wins", async () => { + const seen = await probeEmbedded("/api/:_authToken=EMBEDDED", host => `//${host}/:_authToken=FROM_NPMRC`); + expect(seen).toEqual({ path: "/api/@myorg%2fpkg", auth: "Bearer FROM_NPMRC" }); + }); + + // main only stripped the marker while looking for a credential to adopt; with + // userinfo already in the URL it skipped the scan and the token shipped in the path. + it("strips an embedded _authToken from the path when the URL also carries userinfo", async () => { + const seen = await probeEmbedded("/api/:_authToken=EMBEDDED", () => "", "u:p@"); + expect(seen).toEqual({ path: "/api/@myorg%2fpkg", auth: `Basic ${btoa("u:p")}` }); + }); + + // A bare `:` belongs to the path. Only `name=value` segments naming a credential + // are stripped, so a registry mounted under `/a:b/c/` keeps its path. + it("leaves a plain colon in the registry path alone", async () => { + const seen = await probeEmbedded("/a:b/c/", host => `//${host}/a:b/c/:_authToken=TOK`); + expect(seen).toEqual({ path: "/a:b/c/@myorg%2fpkg", auth: "Bearer TOK" }); + }); + + it("leaves a plain colon in the registry path alone when no credential is configured", async () => { + const seen = await probeEmbedded("/a:b/c/", () => ""); + expect(seen).toEqual({ path: "/a:b/c/@myorg%2fpkg", auth: null }); + }); + + // An empty marker supplies nothing: it is stripped from the path, but it must not + // end the scan or shadow the credential the .npmrc supplies. + it("an empty embedded _auth does not discard the .npmrc credential", async () => { + const seen = await probeEmbedded("/api/:_auth=", host => `//${host}/:_auth=b3BhcXVlYmxvYg`); + expect(seen).toEqual({ path: "/api/@myorg%2fpkg", auth: "Basic b3BhcXVlYmxvYg" }); + }); + + it("an empty embedded _authToken does not discard the .npmrc credential", async () => { + const seen = await probeEmbedded("/api/:_authToken=", host => `//${host}/:_auth=b3BhcXVlYmxvYg`); + expect(seen).toEqual({ path: "/api/@myorg%2fpkg", auth: "Basic b3BhcXVlYmxvYg" }); + }); + + // An `.npmrc` `_auth` supersedes URL-embedded credentials outright, so `bun pm + // whoami` can never report an identity the registry did not authenticate. + it("an .npmrc _auth wins over embedded username and _password", async () => { + const seen = await probeEmbedded( + "/api/:username=embeddeduser/:_password=embeddedpass", + host => `//${host}/:_auth=T1BBUVVFQkxPQg==`, + ); + expect(seen).toEqual({ path: "/api/@myorg%2fpkg", auth: "Basic T1BBUVVFQkxPQg==" }); + }); + + // The scan anchors on the `:=` marker, not on any `:`, so a colon inside the + // value neither ends it nor splits it. + it("strips an embedded _authToken whose value contains a colon", async () => { + const seen = await probeEmbedded("/api/:_authToken=aa:bb", () => ""); + expect(seen).toEqual({ path: "/api/@myorg%2fpkg", auth: "Bearer aa:bb" }); + }); + + // An embedded `_password` is used verbatim, unlike an `.npmrc` one, which is base64. + it("strips an embedded username and _password whose value contains a colon", async () => { + const seen = await probeEmbedded("/api/:username=u/:_password=p:q", () => ""); + expect(seen).toEqual({ path: "/api/@myorg%2fpkg", auth: `Basic ${Buffer.from("u:p:q").toString("base64")}` }); + }); + + // `_authToken` ends what is read, not what is stripped: a segment to its left is + // still a secret, and leaving it behind puts it in the request path. + it("strips a _password written to the left of the _authToken it loses to", async () => { + const seen = await probeEmbedded("/api/:_password=cA==:_authToken=T", () => ""); + expect(seen).toEqual({ path: "/api/@myorg%2fpkg", auth: "Bearer T" }); + }); + + it("strips a username written to the left of the _authToken it loses to", async () => { + const seen = await probeEmbedded("/api/:username=u:_authToken=T", () => ""); + expect(seen).toEqual({ path: "/api/@myorg%2fpkg", auth: "Bearer T" }); + }); + + // The rightmost terminal marker is the one that is read; a second one to its left + // is stripped but never recorded, so it cannot outrank the first. + // The `_auth` values are ones a base64 round trip would change, so the header + // proves the value went out as written. + it.each([ + ["an opaque value", "opaque-blob"], + ["a blank password", btoa("u:")], + ])("reads the rightmost terminal marker, not the leftmost (%s)", async (_name, value) => { + const seen = await probeEmbedded(`/api/:_authToken=T:_auth=${value}`, () => ""); + expect(seen).toEqual({ path: "/api/@myorg%2fpkg", auth: `Basic ${value}` }); + }); + + it("reads the leftmost of two duplicate non-terminal markers", async () => { + const seen = await probeEmbedded("/api/:username=a:username=b:_password=p", () => ""); + expect(seen).toEqual({ path: "/api/@myorg%2fpkg", auth: `Basic ${Buffer.from("a:p").toString("base64")}` }); + }); }); - it("strips a username written to the left of the _authToken it loses to", async () => { - const seen = await probeEmbedded("/api/:username=u:_authToken=T", () => ""); - expect(seen).toEqual({ path: "/api/@myorg%2fpkg", auth: "Bearer T" }); + // npm's walk decides which ancestor supplies auth. Layering a half credential over + // one the registry already stores (from its URL's userinfo) is Bun-only and stays + // exact-path: an ancestor's stray `username=` must not rebind a deeper registry's + // stored password to a new identity. + describe("a partial credential only layers over stored credentials at the registry's own path", () => { + const stored = { registry: "default", userinfo: "url-user:url-pass@" } as const; + const storedBasic = `Basic ${Buffer.from("url-user:url-pass").toString("base64")}`; + + it("an ancestor's lone username does not rebind the stored password", async () => { + const auth = await probeAuthorization(host => `//${host}/:username=attacker`, stored); + expect(auth).toBe(storedBasic); + }); + + it("an ancestor's lone _password does not rebind the stored username", async () => { + const auth = await probeAuthorization( + host => `//${host}/:_password=${Buffer.from("other").toString("base64")}`, + stored, + ); + expect(auth).toBe(storedBasic); + }); + + it("an ancestor's empty _authToken does not clear the stored token", async () => { + const auth = await probeAuthorization(host => `//${host}/:username=u\n//${host}/:_authToken=`, { + registry: "default", + userinfo: `:${token}@`, + }); + expect(auth).toBe(`Bearer ${token}`); + }); + + it("the registry's own path still layers a lone username over the stored password", async () => { + const auth = await probeAuthorization(host => `//${host}${registryPath}:username=npmrc-user`, stored); + expect(auth).toBe(`Basic ${Buffer.from("npmrc-user:url-pass").toString("base64")}`); + }); }); - // The rightmost terminal marker is the one that is read; a second one to its left - // is stripped but never recorded, so it cannot outrank the first. - // The `_auth` values are ones a base64 round trip would change, so the header - // proves the value went out as written. - it.each([ - ["an opaque value", "opaque-blob"], - ["a blank password", btoa("u:")], - ])("reads the rightmost terminal marker, not the leftmost (%s)", async (_name, value) => { - const seen = await probeEmbedded(`/api/:_authToken=T:_auth=${value}`, () => ""); - expect(seen).toEqual({ path: "/api/@myorg%2fpkg", auth: `Basic ${value}` }); + // A registry declared in `bunfig.toml` resolves through the same walk, unless + // `bunfig.toml` itself gave it credentials: project config beats `.npmrc`. + describe("registries declared in bunfig.toml", () => { + it.each([ + ["scoped", { bunfigBare: true }], + ["default", { registry: "default", bunfigBare: true }], + ] as const)("a host-root _authToken applies to a credential-less %s registry", async (_name, opts) => { + const auth = await probeAuthorization(host => `//${host}/:_authToken=${token}`, opts); + expect(auth).toBe(`Bearer ${token}`); + }); + + it("a string prefix of the path is still not an ancestor", async () => { + const auth = await probeAuthorization(host => `//${host}/api/v4/projects/12/:_authToken=${token}`, { + bunfigBare: true, + }); + expect(auth).toBeNull(); + }); + + it("walks up for _auth", async () => { + const blob = b64("alice:s3cret"); + const auth = await probeAuthorization(host => `//${host}/api/:_auth=${blob}`, { bunfigBare: true }); + expect(auth).toBe(`Basic ${blob}`); + }); + + it("a bunfig.toml token is kept even when an ancestor key would supply another", async () => { + const auth = await probeAuthorization(host => `//${host}/:_authToken=FROM_NPMRC`, { bunfigToken: token }); + expect(auth).toBe(`Bearer ${token}`); + }); + + it("bunfig.toml username + password are kept even when the registry's own key supplies _auth", async () => { + const basic = { username: "bunfig-user", password: "bunfig-pass" }; + const auth = await probeAuthorization(host => `//${host}${registryPath}:_auth=${b64("x:y")}`, { + bunfigBasic: basic, + }); + expect(auth).toBe(`Basic ${b64(`${basic.username}:${basic.password}`)}`); + }); }); - it("reads the leftmost of two duplicate non-terminal markers", async () => { - const seen = await probeEmbedded("/api/:username=a:username=b:_password=p", () => ""); - expect(seen).toEqual({ path: "/api/@myorg%2fpkg", auth: `Basic ${Buffer.from("a:p").toString("base64")}` }); + // The unscoped `registry=` line resolves credentials through the same walk. It is + // a separate branch in `src/ini/lib.rs`, and it is the only one that can carry + // credentials from `bunfig.toml` or from userinfo in the registry URL. + describe("the default registry walks up too", () => { + const asDefault = { registry: "default" } as const; + + it("host root", async () => { + const auth = await probeAuthorization(host => `//${host}/:_authToken=${token}`, asDefault); + expect(auth).toBe(`Bearer ${token}`); + }); + + it("string prefix, not an ancestor (trailing slash)", async () => { + const auth = await probeAuthorization(host => `//${host}/api/v4/projects/12/:_authToken=${token}`, asDefault); + expect(auth).toBe(null); + }); + + it("string prefix, not an ancestor (no trailing slash)", async () => { + const auth = await probeAuthorization(host => `//${host}/api/v4/projects/12:_authToken=${token}`, asDefault); + expect(auth).toBe(null); + }); + + it("longest match wins: root then deep", async () => { + const auth = await probeAuthorization( + host => `//${host}/:_authToken=ROOT\n//${host}${registryPath}:_authToken=DEEP`, + asDefault, + ); + expect(auth).toBe("Bearer DEEP"); + }); + + it("longest match wins: deep then root", async () => { + const auth = await probeAuthorization( + host => `//${host}${registryPath}:_authToken=DEEP\n//${host}/:_authToken=ROOT`, + asDefault, + ); + expect(auth).toBe("Bearer DEEP"); + }); + + // Userinfo in the registry URL is the registry's pre-`.npmrc` credential. No + // config path matches, so nothing may overwrite or clear it. + it("userinfo in the registry URL survives a non-matching auth line", async () => { + const auth = await probeAuthorization(() => `//other.example.com/:_authToken=${token}`, { + ...asDefault, + userinfo: "user:pass@", + }); + expect(auth).toBe(`Basic ${Buffer.from("user:pass").toString("base64")}`); + }); + + it("a matching auth line replaces the registry URL's userinfo", async () => { + const auth = await probeAuthorization(host => `//${host}/:_authToken=${token}`, { + ...asDefault, + userinfo: "user:pass@", + }); + expect(auth).toBe(`Bearer ${token}`); + }); }); }); diff --git a/test/cli/install/npmrc.test.ts b/test/cli/install/npmrc.test.ts index 21b6264d89e2..80be315f5a8d 100644 --- a/test/cli/install/npmrc.test.ts +++ b/test/cli/install/npmrc.test.ts @@ -498,10 +498,61 @@ ${Object.keys(opts) dotEnv: { SECRET_AUTH: "" }, }, (stdout: string, stderr: string) => { - expect(stderr).toContain("empty _auth value"); + expect(stderr).toContain("supplies no credentials"); }, ); + describe("empty _auth across the home and project .npmrc", () => { + const blob = Buffer.from("alice:s3cret").toString("base64"); + + // Returns whether the empty-`_auth` diagnostic was printed; the install itself + // must still succeed either way. + async function diagnosed(homeNpmrc: string, projectNpmrc: string) { + using dir = tempDir("npmrc-empty-auth-two-files", { + "home/.npmrc": homeNpmrc, + ".npmrc": projectNpmrc, + "package.json": JSON.stringify({ name: "foo", version: "1.0.0" }), + }); + const homeDir = join(String(dir), "home"); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "install", "--dry-run"], + cwd: String(dir), + env: { ...env, HOME: homeDir, USERPROFILE: homeDir, XDG_CONFIG_HOME: homeDir }, + stdout: "pipe", + stderr: "pipe", + }); + + const [, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(exitCode).toBe(0); + return stderr.includes("supplies no credentials"); + } + + test("a home line is diagnosed against a registry the project declares", async () => { + expect(await diagnosed(`//somehost.com/:_auth=\n`, `registry=http://somehost.com/\n`)).toBe(true); + }); + + test("a line that only matches a registry's path ancestor is not diagnosed", async () => { + expect( + await diagnosed(`//somehost.com/:_auth=\n`, `@myorg:registry=https://somehost.com/api/v4/packages/npm/\n`), + ).toBe(false); + }); + + // The key collapses to the project's value, so the home line supplies nothing + // either way and the credential is sent. + test("a home line the project overrides with a value is not diagnosed", async () => { + expect( + await diagnosed(`//somehost.com/:_auth=\n`, `registry=http://somehost.com/\n//somehost.com/:_auth=${blob}\n`), + ).toBe(false); + }); + + test("a project line that clears the home file's value is diagnosed", async () => { + expect( + await diagnosed(`//somehost.com/:_auth=${blob}\n`, `registry=http://somehost.com/\n//somehost.com/:_auth=\n`), + ).toBe(true); + }); + }); + await makeTest([["email", "user@example.com"]], result => { expect(result.default_registry_url).toEqual("https://registry.npmjs.org/"); expect(result.default_registry_email).toEqual("user@example.com"); @@ -551,13 +602,16 @@ registry=https://somehost.com/org1/npm/registry/ }); describe("credentials keyed to a bracketed IPv6 host", () => { - // Keys and registry URLs are both parsed as URLs and compared on host and path, so - // the bracketed authority has to survive both parses. + // Config keys are matched literally against the keys walked up from the registry + // URL, so the bracketed authority only has to survive the registry side's parse. test.each([ ["loopback with a port", "http://[::1]:4873/", "//[::1]:4873/"], ["loopback without a port", "http://[::1]/", "//[::1]/"], ["full address with a path", "http://[2001:db8::1]:4873/npm/registry/", "//[2001:db8::1]:4873/npm/registry/"], ["key without the trailing slash", "http://[::1]:4873/", "//[::1]:4873"], + ["host-root key for a registry under a path", "http://[::1]:4873/npm/registry/", "//[::1]:4873/"], + // The address ends in the scheme's default port digits; they are not a port. + ["address whose last group spells the default port", "http://[::80]/", "//[::80]/"], ])("_authToken is applied: %s", (_, registryUrl, key) => { const result = loadNpmrc(`registry=${registryUrl}\n${key}:_authToken=v6-token\n`); expect(result).toEqual({ @@ -647,124 +701,171 @@ registry=https://somehost.com/org1/npm/registry/ expect(exitCode).toBe(0); }); - test("an env registry on the same host keeps the .npmrc username and _password", async () => { - const blob = Buffer.from("alice:hunter2").toString("base64"); - const authorizations: (string | null)[] = []; - await using registry = Bun.serve({ - port: 0, - hostname: "127.0.0.1", - fetch(req) { - authorizations.push(req.headers.get("authorization")); - return Response.json({ - name: "pkg", - "dist-tags": { latest: "1.0.0" }, - versions: { "1.0.0": { name: "pkg", version: "1.0.0" } }, - }); - }, + describe("default registry resolves auth by path-segment ancestor", () => { + // https://github.com/oven-sh/bun/issues/30311 + test("host-root auth applies to a deep default registry", () => { + const result = loadNpmrc(` +registry=https://somehost.com/org1/npm/registry/ +//somehost.com/:_authToken=root +`); + expect(result.default_registry_url).toEqual("https://somehost.com/org1/npm/registry/"); + expect(result.default_registry_token).toBe("root"); }); - const host = `127.0.0.1:${registry.port}`; - using dir = tempDir("npmrc-env-registry-auth", { - "home/.gitkeep": "", - "package.json": JSON.stringify({ name: "probe", version: "0.0.0" }), - ".npmrc": `registry=http://${host}/\n//${host}/:username=alice\n//${host}/:_password=${Buffer.from("hunter2").toString("base64")}\n`, + + test("mid-path ancestor auth applies to a deep default registry", () => { + const result = loadNpmrc(` +registry=https://somehost.com/org1/npm/registry/ +//somehost.com/org1/:_authToken=mid +`); + expect(result.default_registry_token).toBe("mid"); }); - const homeDir = join(String(dir), "home"); - await using proc = Bun.spawn({ - cmd: [bunExe(), "pm", "view", "pkg", "version"], - cwd: String(dir), - env: { - ...env, - HOME: homeDir, - USERPROFILE: homeDir, - XDG_CONFIG_HOME: homeDir, - npm_config_registry: `http://${host}/`, - }, - stdout: "pipe", - stderr: "pipe", + + test.each([ + [ + "shallow first", + ` +registry=https://somehost.com/org1/npm/registry/ +//somehost.com/:_authToken=root +//somehost.com/org1/:_authToken=mid +//somehost.com/org1/npm/registry/:_authToken=exact +`, + ], + [ + "deep first", + ` +registry=https://somehost.com/org1/npm/registry/ +//somehost.com/org1/npm/registry/:_authToken=exact +//somehost.com/org1/:_authToken=mid +//somehost.com/:_authToken=root +`, + ], + ])("longest matching ancestor wins (%s)", (_name, ini) => { + expect(loadNpmrc(ini).default_registry_token).toBe("exact"); + }); + + test.each([ + ["trailing slash", "//somehost.com/api/v4/projects/12/:_authToken=attacker"], + ["no trailing slash", "//somehost.com/api/v4/projects/12:_authToken=attacker"], + ])("a path prefix that is not a segment ancestor never matches (%s)", (_name, line) => { + const result = loadNpmrc(` +registry=https://somehost.com/api/v4/projects/123/packages/npm/ +${line} +`); + expect(result.default_registry_url).toEqual("https://somehost.com/api/v4/projects/123/packages/npm/"); + expect(result.default_registry_token).toBe(""); + }); + + test("host-root _auth applies to a deep default registry", () => { + const result = loadNpmrc(` +registry=https://somehost.com/org1/npm/registry/ +//somehost.com/:_auth=${Buffer.from("bilbo:verysecure").toString("base64")} +`); + // `_auth` is forwarded verbatim; the config layer never decodes it into + // username/password (whoami derives the username in `Scope::from_api`). + expect(result.default_registry_auth).toBe(Buffer.from("bilbo:verysecure").toString("base64")); + expect(result.default_registry_username).toBe(""); + expect(result.default_registry_password).toBe(""); + }); + + test("host-root username + _password apply to a deep default registry", () => { + const result = loadNpmrc(` +registry=https://somehost.com/org1/npm/registry/ +//somehost.com/:username=bilbo +//somehost.com/:_password=${Buffer.from("verysecure").toString("base64")} +`); + expect(result.default_registry_username).toBe("bilbo"); + expect(result.default_registry_password).toBe("verysecure"); + }); + + // `email` is not part of npm's auth (`npm-registry-fetch`'s `getAuth` never reads + // it), so it does not walk: only a line naming the registry's own path applies. + test("an ancestor's email does not apply to a deeper registry", () => { + const result = loadNpmrc(` +registry=https://somehost.com/org1/npm/registry/ +//somehost.com/:email=bilbo@example.com +`); + expect(result.default_registry_email).toBe(""); + }); + + test("the registry's own email applies", () => { + const result = loadNpmrc(` +registry=https://somehost.com/org1/npm/registry/ +//somehost.com/:email=gandalf@example.com +//somehost.com/org1/npm/registry/:email=bilbo@example.com +`); + expect(result.default_registry_email).toBe("bilbo@example.com"); }); - const [stdout, , exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); - expect({ authorizations, stdout }).toEqual({ authorizations: [`Basic ${blob}`], stdout: "1.0.0\n" }); - expect(exitCode).toBe(0); }); - test("an env registry on the same host keeps the registry URL's userinfo", async () => { - const blob = Buffer.from("alice:hunter2").toString("base64"); - const authorizations: (string | null)[] = []; - await using registry = Bun.serve({ - port: 0, - hostname: "127.0.0.1", - fetch(req) { - authorizations.push(req.headers.get("authorization")); - return Response.json({ - name: "pkg", - "dist-tags": { latest: "1.0.0" }, - versions: { "1.0.0": { name: "pkg", version: "1.0.0" } }, - }); - }, + + describe("credentials that did not come from .npmrc survive resolution", () => { + test("an invalid _auth does not discard the registry URL's token", () => { + const result = loadNpmrc(` +registry=https://:TOK@somehost.com/ +//somehost.com/:_auth=not-valid-base64 +`); + expect(result.default_registry_token).toBe("TOK"); }); - const host = `127.0.0.1:${registry.port}`; - using dir = tempDir("npmrc-env-registry-auth", { + + test("an .npmrc username/_password does not discard the registry URL's token", () => { + const result = loadNpmrc(` +registry=https://:TOK@somehost.com/ +//somehost.com/:username=gandalf +//somehost.com/:_password=${Buffer.from("verysecure").toString("base64")} +`); + expect(result.default_registry_token).toBe("TOK"); + expect(result.default_registry_username).toBe("gandalf"); + expect(result.default_registry_password).toBe("verysecure"); + }); + }); + + test("an empty _auth for an ancestor path of a registry is not an error", async () => { + using server = Bun.serve({ port: 0, fetch: () => new Response("{}") }); + const host = `127.0.0.1:${server.port}`; + using dir = tempDir("npmrc-empty-auth-ancestor-2", { + "package.json": JSON.stringify({ name: "foo", version: "1.0.0" }), + ".npmrc": `@myorg:registry=http://${host}/deep/\n//${host}/:_auth=\n`, "home/.gitkeep": "", - "package.json": JSON.stringify({ name: "probe", version: "0.0.0" }), - ".npmrc": `registry=http://alice:hunter2@${host}/\n`, }); - const homeDir = join(String(dir), "home"); + const home = join(String(dir), "home"); + await using proc = Bun.spawn({ - cmd: [bunExe(), "pm", "view", "pkg", "version"], + cmd: [bunExe(), "install", "--no-save"], cwd: String(dir), - env: { - ...env, - HOME: homeDir, - USERPROFILE: homeDir, - XDG_CONFIG_HOME: homeDir, - npm_config_registry: `http://${host}/`, - }, + env: { ...env, HOME: home, USERPROFILE: home, XDG_CONFIG_HOME: home }, stdout: "pipe", stderr: "pipe", }); - const [stdout, , exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); - expect({ authorizations, stdout }).toEqual({ authorizations: [`Basic ${blob}`], stdout: "1.0.0\n" }); - expect(exitCode).toBe(0); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stderr).not.toContain("supplies no credentials"); + expect({ stdout, stderr, exitCode }).toMatchObject({ exitCode: 0 }); }); - test("an env registry that downgrades https to http drops the .npmrc _auth", async () => { - const blob = Buffer.from("alice:hunter2").toString("base64"); - const authorizations: (string | null)[] = []; - await using registry = Bun.serve({ - port: 0, - hostname: "127.0.0.1", - fetch(req) { - authorizations.push(req.headers.get("authorization")); - return Response.json({ - name: "pkg", - "dist-tags": { latest: "1.0.0" }, - versions: { "1.0.0": { name: "pkg", version: "1.0.0" } }, - }); - }, - }); - const host = `127.0.0.1:${registry.port}`; - using dir = tempDir("npmrc-env-registry-auth", { + + test("an empty _auth naming a registry's own path is still an error", async () => { + using server = Bun.serve({ port: 0, fetch: () => new Response("{}") }); + const host = `127.0.0.1:${server.port}`; + using dir = tempDir("npmrc-empty-auth-exact", { + "package.json": JSON.stringify({ name: "foo", version: "1.0.0" }), + ".npmrc": `@myorg:registry=http://${host}/deep/\n//${host}/deep/:_auth=\n`, "home/.gitkeep": "", - "package.json": JSON.stringify({ name: "probe", version: "0.0.0" }), - ".npmrc": `registry=https://${host}/\n//${host}/:_auth=${blob}\n`, }); - const homeDir = join(String(dir), "home"); + const home = join(String(dir), "home"); + await using proc = Bun.spawn({ - cmd: [bunExe(), "pm", "view", "pkg", "version"], + cmd: [bunExe(), "install", "--no-save"], cwd: String(dir), - env: { - ...env, - HOME: homeDir, - USERPROFILE: homeDir, - XDG_CONFIG_HOME: homeDir, - npm_config_registry: `http://${host}/`, - }, + env: { ...env, HOME: home, USERPROFILE: home, XDG_CONFIG_HOME: home }, stdout: "pipe", stderr: "pipe", }); - const [stdout, , exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); - expect({ authorizations, stdout }).toEqual({ authorizations: [null], stdout: "1.0.0\n" }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stderr).toContain("supplies no credentials"); expect(exitCode).toBe(0); }); + // `Scope::from_api` decodes `_auth` solely to derive the identity `bun pm whoami` + // prints; the credential itself is always forwarded verbatim. describe("bun pm whoami derives the username from _auth", () => { async function whoamiWith(files: Record) { using dir = tempDir("npmrc-whoami-auth", { @@ -786,42 +887,37 @@ registry=https://somehost.com/org1/npm/registry/ return { stdout, stderr, exitCode }; } - // Answers `/-/whoami` with a fixed name and records the Authorization header, so a - // request that goes out is visible even when Bun cannot derive a name locally. - async function whoamiAgainstRegistry(authLine: (host: string) => string, userinfo = "") { - const authorizations: (string | null)[] = []; - await using registry = Bun.serve({ - port: 0, - hostname: "127.0.0.1", - fetch(req) { - authorizations.push(req.headers.get("authorization")); - return Response.json({ username: "from-registry" }); - }, - }); - const host = `127.0.0.1:${registry.port}`; - const result = await whoamiWith({ - ".npmrc": `registry=http://${userinfo}${host}/\n${authLine(host)}\n`, + function whoami(authValue: string) { + return whoamiWith({ + ".npmrc": `registry=https://registry.invalid/\n//registry.invalid/:_auth=${authValue}\n`, }); - return { ...result, authorizations }; } - test("a decodable _auth prints its username without a request", async () => { - const blob = Buffer.from("alice:s3cret").toString("base64"); - const { stdout, exitCode, authorizations } = await whoamiAgainstRegistry(host => `//${host}/:_auth=${blob}`); - expect({ stdout, authorizations }).toEqual({ stdout: "alice\n", authorizations: [] }); + test("a decodable _auth prints its username", async () => { + const { stdout, exitCode } = await whoami(Buffer.from("alice:s3cret").toString("base64")); + expect(stdout).toBe("alice\n"); expect(exitCode).toBe(0); }); - // No local identity in these values, so the credential goes to the registry as - // written and the registry answers; main gave up with "missing authentication". - test.each([ - ["opaque", "!!not-base64!!"], - ["blank username", Buffer.from(":s3cret").toString("base64")], - ["blank password", Buffer.from("tok:").toString("base64")], - ])("an _auth without a local identity asks the registry (%s)", async (_name, authValue) => { - const { stdout, exitCode, authorizations } = await whoamiAgainstRegistry(host => `//${host}/:_auth=${authValue}`); - expect({ stdout, authorizations }).toEqual({ stdout: "from-registry\n", authorizations: [`Basic ${authValue}`] }); - expect(exitCode).toBe(0); + test("a non-decodable _auth carries no identity", async () => { + const { stdout, stderr, exitCode } = await whoami("!!not-base64!!"); + expect(stdout).toBe(""); + expect(stderr).toContain("missing authentication"); + expect(exitCode).toBe(1); + }); + + test("an _auth with a blank username carries no identity", async () => { + const { stdout, stderr, exitCode } = await whoami(Buffer.from(":s3cret").toString("base64")); + expect(stdout).toBe(""); + expect(stderr).toContain("missing authentication"); + expect(exitCode).toBe(1); + }); + + test("an _auth with a blank password carries no identity", async () => { + const { stdout, stderr, exitCode } = await whoami(Buffer.from("tok:").toString("base64")); + expect(stdout).toBe(""); + expect(stderr).toContain("missing authentication"); + expect(exitCode).toBe(1); }); // The wire sends `Basic <_auth>` here (auth beats the username + password the @@ -831,12 +927,12 @@ registry=https://somehost.com/org1/npm/registry/ ["opaque", "!!not-base64!!"], ["blank-password", Buffer.from("tok:").toString("base64")], ])("the registry URL's username/password do not leak an identity past _auth (%s)", async (_name, authValue) => { - const { stdout, exitCode, authorizations } = await whoamiAgainstRegistry( - host => `//${host}/:_auth=${authValue}`, - "url-user:url-pass@", - ); - expect({ stdout, authorizations }).toEqual({ stdout: "from-registry\n", authorizations: [`Basic ${authValue}`] }); - expect(exitCode).toBe(0); + const { stdout, stderr, exitCode } = await whoamiWith({ + ".npmrc": `registry=https://url-user:url-pass@registry.invalid/\n//registry.invalid/:_auth=${authValue}\n`, + }); + expect(stdout).toBe(""); + expect(stderr).toContain("missing authentication"); + expect(exitCode).toBe(1); }); // Credentials declared in bunfig.toml beat every .npmrc line for that registry, @@ -931,6 +1027,161 @@ describe("scoped registry routing", () => { }); }); +// npm keys on a WHATWG URL's `host`, which is lowercased and drops a default port. +// The config key's path stays case-sensitive; only its authority is folded. +describe("the config key's authority is normalized like a WHATWG URL", () => { + const token = (ini: string) => loadNpmrc(ini).default_registry_token; + + it("matches a lowercase key against an uppercase registry host", () => { + expect(token(`registry=https://Registry.Example.COM/api/\n//registry.example.com/:_authToken=T\n`)).toBe("T"); + }); + + // npm's `nerfDart` lowercases the keys it writes; a hand-written key is folded the + // same way when read, so an uppercase host still applies. + it("lowercases the key's host, so an uppercase key matches", () => { + expect(token(`registry=https://Registry.Example.COM/api/\n//Registry.Example.COM/:_authToken=T\n`)).toBe("T"); + expect(token(`registry=https://registry.example.com/api/\n//Registry.Example.COM/:_authToken=T\n`)).toBe("T"); + }); + + it("keeps the key's path case-sensitive", () => { + expect(token(`registry=https://example.com/API/\n//example.com/api/:_authToken=T\n`)).toBe(""); + }); + + it("drops a default https port from the registry host", () => { + expect(token(`registry=https://example.com:443/api/\n//example.com/:_authToken=T\n`)).toBe("T"); + }); + + it("drops a default http port from the registry host", () => { + expect(token(`registry=http://example.com:80/api/\n//example.com/:_authToken=T\n`)).toBe("T"); + }); + + it("keeps a non-default port in the registry host", () => { + expect(token(`registry=https://example.com:8443/api/\n//example.com:8443/:_authToken=T\n`)).toBe("T"); + expect(token(`registry=https://example.com:8443/api/\n//example.com/:_authToken=T\n`)).toBe(""); + }); + + it("drops a default port from an uppercase scheme too", () => { + expect(token(`registry=HTTPS://example.com:443/api/\n//example.com/:_authToken=T\n`)).toBe("T"); + }); + + // npm's key never spells out a default port; a hand-written `:443` is dropped when + // read, so the key still matches (released Bun matched it too). + it("drops :443 from a key, so it matches an https registry", () => { + expect(token(`registry=https://example.com:443/api/\n//example.com:443/:_authToken=T\n`)).toBe("T"); + expect(token(`registry=https://example.com/api/\n//example.com:443/:_authToken=T\n`)).toBe("T"); + }); + + // Bun's docs long showed keys with a scheme (`//http://localhost:4873/:_authToken=`); + // npm never writes one. The scheme is dropped when read, and names the default port. + it("drops a leading scheme from a key", () => { + expect(token(`registry=http://localhost:4873/\n//http://localhost:4873/:_authToken=T\n`)).toBe("T"); + expect(token(`registry=https://example.com/api/\n//https://example.com/:_authToken=T\n`)).toBe("T"); + expect(token(`registry=http://example.com/api/\n//http://example.com:80/:_authToken=T\n`)).toBe("T"); + expect(token(`registry=http://example.com/api/\n//HTTP://Example.COM/:_authToken=T\n`)).toBe("T"); + }); + + it("drops a default port after a bracketed IPv6 host, not inside it", () => { + expect(token(`registry=https://[::1]/\n//[::1]:443/:_authToken=T\n`)).toBe("T"); + expect(token(`registry=http://[::80]/\n//[::80]/:_authToken=T\n`)).toBe("T"); + }); + + // The option name must end the key. `_authtoken` (lowercase t) used to match `_auth` + // as a substring and go out as `Basic `; now it is an unknown option. + it("does not read a misspelt option as a shorter one it contains", () => { + expect(token(`registry=https://example.com/\n//example.com/:_authtoken=T\n`)).toBe(""); + expect(loadNpmrc(`registry=https://example.com/\n//example.com/:_authtoken=T\n`).default_registry_auth).toBe(""); + }); +}); + +// Diagnostics printed while reading .npmrc must never echo a credential. +describe(".npmrc diagnostics", () => { + async function stderrOf(npmrc: string, bunfig?: object) { + using dir = tempDir("npmrc-diagnostics", { + ".npmrc": npmrc, + "package.json": JSON.stringify({ name: "x", version: "1.0.0" }), + "home/.gitkeep": "", + ...(bunfig ? { "bunfig.toml": Bun.TOML.stringify(bunfig) } : {}), + }); + const home = join(String(dir), "home"); + await using proc = Bun.spawn({ + cmd: [bunExe(), "install", "--no-cache"], + cwd: String(dir), + env: { ...env, HOME: home, USERPROFILE: home, XDG_CONFIG_HOME: home }, + stdout: "pipe", + stderr: "pipe", + stdin: "ignore", + }); + const [, stderr] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + return stderr; + } + + it("warns about an unknown option name without printing its value", async () => { + const stderr = await stderrOf(`registry=https://example.com/\n//example.com/:_authtoken=SECRETTOKEN\n`); + expect(stderr).toContain("_authtoken is not a known .npmrc option"); + expect(stderr).not.toContain("SECRETTOKEN"); + }); + + it("says nothing about a key that matches once normalized", async () => { + const stderr = await stderrOf(`registry=https://example.com/api/\n//Example.COM:443/:_authToken=SECRETTOKEN\n`); + expect(stderr).toBe("No packages! Deleted empty lockfile\n"); + }); + + it("an empty _auth naming a bunfig.toml scope is an error", async () => { + const stderr = await stderrOf(`//example.com/api/:_auth=\n`, { + install: { scopes: { myorg: { url: "https://example.com/api/" } } }, + }); + expect(stderr).toContain("empty _auth value"); + }); + + // A credential can be arbitrary bytes. `bun pm view` panicked on non-UTF-8 (lossy + // Display expanded U+FFFD past the reserved byte count) until the header append went + // raw. A JS `\xff` escape lands as valid UTF-8, so the bytes are written raw here. + for (const [opt, scheme] of [ + ["_auth", "Basic"], + ["_authToken", "Bearer"], + ] as const) { + it(`a non-UTF-8 ${opt} reaches the registry verbatim from bun pm view`, async () => { + const seen: Buffer[] = []; + await using registry = Bun.serve({ + port: 0, + hostname: "127.0.0.1", + fetch(req) { + seen.push(Buffer.from(req.headers.get("authorization") ?? "", "binary")); + return Response.json({ + "name": "pkg", + "dist-tags": { latest: "1.0.0" }, + "versions": { "1.0.0": { name: "pkg", version: "1.0.0" } }, + }); + }, + }); + const host = `127.0.0.1:${registry.port}`; + using dir = tempDir("npmrc-raw-bytes", { + "package.json": JSON.stringify({ name: "x", version: "1.0.0" }), + "home/.gitkeep": "", + }); + const prefix = Buffer.from(`registry=http://${host}/\n//${host}/:${opt}=`); + await write(join(String(dir), ".npmrc"), Buffer.concat([prefix, Buffer.from([0xff, 0xfe, 0xfd, 0x0a])])); + const home = join(String(dir), "home"); + await using proc = Bun.spawn({ + cmd: [bunExe(), "pm", "view", "pkg", "version"], + cwd: String(dir), + env: { ...env, HOME: home, USERPROFILE: home, XDG_CONFIG_HOME: home }, + stdout: "pipe", + stderr: "pipe", + stdin: "ignore", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(seen).toEqual([Buffer.concat([Buffer.from(`${scheme} `), Buffer.from([0xff, 0xfe, 0xfd])])]); + expect(stderr).not.toContain("invalid _auth"); + expect({ stdout, exitCode, signalCode: proc.signalCode }).toEqual({ + stdout: "1.0.0\n", + exitCode: 0, + signalCode: null, + }); + }); + } +}); + describe("--registry override", () => { test("does not send the token configured for the previous registry host to the --registry host", async () => { const tgz = join(import.meta.dir, "registry", "packages", "no-deps", "no-deps-1.0.0.tgz"); diff --git a/test/cli/install/redacted-config-logs.test.ts b/test/cli/install/redacted-config-logs.test.ts index 4930966f9ef7..80af4ac5ef62 100644 --- a/test/cli/install/redacted-config-logs.test.ts +++ b/test/cli/install/redacted-config-logs.test.ts @@ -212,6 +212,29 @@ describe.concurrent("redact", async () => { npmrc: "//registry.npmjs.org/:_auth=", expected: "supplies no credentials", }, + { + // A quoted key is a string literal, not an identifier, so it took a different + // path through the highlighter and the value came out verbatim under color. + // The misspelt option name is what makes a diagnostic print this line at all. + title: "quoted _authToken key", + npmrc: '"//registry.npmjs.org/:_authtoken"=npm_notarealtokenvalue', + expected: "*", + secret: "npm_notarealtokenvalue", + }, + { + title: "quoted _auth key", + npmrc: 'registry=https://registry.example.com/api/\n"//registry.example.com/:_auth_"=does-not-decode', + expected: "*", + secret: "does-not-decode", + }, + { + // Unquoted twin of the above: the identifier path must redact after a + // misspelt option too, since the warning prints the line. + title: "misspelt _authToken key", + npmrc: "//registry.npmjs.org/:_authtoken=npm_notarealtokenvalue", + expected: "is not a known .npmrc option", + secret: "npm_notarealtokenvalue", + }, { // The most common .npmrc authoring mistake, and the value is always a live secret. // npm decodes _password with Buffer.from(v, "base64"), which never throws — it From fc0badcdc97222a49016e70c6ecfd1f0ced577cc Mon Sep 17 00:00:00 2001 From: Alistair Smith Date: Tue, 25 Aug 2026 00:53:12 -0700 Subject: [PATCH 07/15] ini: a known option with a non-string value is not a typo; redact quoted password keys; drop the dead _authToken redaction branch --- src/bun_core/fmt.rs | 12 +++++++++--- src/bun_core/lib.rs | 3 --- src/ini/lib.rs | 3 +++ test/cli/install/npmrc.test.ts | 17 +++++++++++++++++ test/cli/install/redacted-config-logs.test.ts | 6 ++++++ 5 files changed, 35 insertions(+), 6 deletions(-) diff --git a/src/bun_core/fmt.rs b/src/bun_core/fmt.rs index 9458e7cbc9fb..952b6aef82b5 100644 --- a/src/bun_core/fmt.rs +++ b/src/bun_core/fmt.rs @@ -1746,9 +1746,15 @@ impl RedactedKeywords { /// Prefix match, as loose as the ini parser's option matching (`_auth` also covers `_authToken`). pub(crate) fn has_prefix(s: &[u8]) -> bool { - [b"_auth".as_slice(), b"token", b"_password", b"email"] - .iter() - .any(|k| s.starts_with(k)) + [ + b"_auth".as_slice(), + b"token", + b"_password", + b"password", + b"email", + ] + .iter() + .any(|k| s.starts_with(k)) } } diff --git a/src/bun_core/lib.rs b/src/bun_core/lib.rs index a148d1a5766d..be372bee8d8b 100644 --- a/src/bun_core/lib.rs +++ b/src/bun_core/lib.rs @@ -2092,9 +2092,6 @@ pub(crate) mod strings_impl { if let Some(r) = starts_with_redacted_item(str, b"_auth") { return Some(r); } - if let Some(r) = starts_with_redacted_item(str, b"_authToken") { - return Some(r); - } if let Some(r) = starts_with_redacted_item(str, b"email") { return Some(r); } diff --git a/src/ini/lib.rs b/src/ini/lib.rs index ee0c6d3348b1..432267491a4a 100644 --- a/src/ini/lib.rs +++ b/src/ini/lib.rs @@ -1074,6 +1074,9 @@ mod draft { })); } } + // A known option whose value is not a string (`=true`, no `=`): + // nothing to apply, and not a typo to warn about. + return Some(IniOption::None); } } // `//host/...:word=` where `word` is none of the seven options: npm diff --git a/test/cli/install/npmrc.test.ts b/test/cli/install/npmrc.test.ts index 80be315f5a8d..d089697867a1 100644 --- a/test/cli/install/npmrc.test.ts +++ b/test/cli/install/npmrc.test.ts @@ -1126,6 +1126,23 @@ describe(".npmrc diagnostics", () => { expect(stderr).toBe("No packages! Deleted empty lockfile\n"); }); + it("a known option with a non-string value is ignored without a warning", async () => { + using dir = tempDir("npmrc-non-string-value", { + ".npmrc": "registry=http://127.0.0.1:1/\n//127.0.0.1:1/:_authToken=true\n", + "package.json": JSON.stringify({ name: "probe", version: "0.0.0" }), + }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "install"], + cwd: String(dir), + env, + stdout: "pipe", + stderr: "pipe", + }); + const [, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stderr).not.toContain("not a known .npmrc option"); + expect(exitCode).toBe(0); + }); + it("an empty _auth naming a bunfig.toml scope is an error", async () => { const stderr = await stderrOf(`//example.com/api/:_auth=\n`, { install: { scopes: { myorg: { url: "https://example.com/api/" } } }, diff --git a/test/cli/install/redacted-config-logs.test.ts b/test/cli/install/redacted-config-logs.test.ts index 80af4ac5ef62..482c8260259b 100644 --- a/test/cli/install/redacted-config-logs.test.ts +++ b/test/cli/install/redacted-config-logs.test.ts @@ -227,6 +227,12 @@ describe.concurrent("redact", async () => { expected: "*", secret: "does-not-decode", }, + { + title: "quoted password key", + npmrc: '"//registry.npmjs.org/:password"=SUPERSECRETVALUE', + expected: "is not a known .npmrc option", + secret: "SUPERSECRETVALUE", + }, { // Unquoted twin of the above: the identifier path must redact after a // misspelt option too, since the warning prints the line. From 960dcc369d44d0fd7f5cf317319aea50b24576f8 Mon Sep 17 00:00:00 2001 From: Alistair Smith Date: Tue, 25 Aug 2026 01:20:29 -0700 Subject: [PATCH 08/15] test: isolate the non-string .npmrc value test from the ambient ~/.npmrc --- test/cli/install/npmrc.test.ts | 14 +------------- 1 file changed, 1 insertion(+), 13 deletions(-) diff --git a/test/cli/install/npmrc.test.ts b/test/cli/install/npmrc.test.ts index d089697867a1..4334e8d0cae9 100644 --- a/test/cli/install/npmrc.test.ts +++ b/test/cli/install/npmrc.test.ts @@ -1127,20 +1127,8 @@ describe(".npmrc diagnostics", () => { }); it("a known option with a non-string value is ignored without a warning", async () => { - using dir = tempDir("npmrc-non-string-value", { - ".npmrc": "registry=http://127.0.0.1:1/\n//127.0.0.1:1/:_authToken=true\n", - "package.json": JSON.stringify({ name: "probe", version: "0.0.0" }), - }); - await using proc = Bun.spawn({ - cmd: [bunExe(), "install"], - cwd: String(dir), - env, - stdout: "pipe", - stderr: "pipe", - }); - const [, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + const stderr = await stderrOf("registry=http://127.0.0.1:1/\n//127.0.0.1:1/:_authToken=true\n"); expect(stderr).not.toContain("not a known .npmrc option"); - expect(exitCode).toBe(0); }); it("an empty _auth naming a bunfig.toml scope is an error", async () => { From ee260a2b0f7a000f8c7a77bd1da1c07fdb00c261 Mon Sep 17 00:00:00 2001 From: Alistair Smith Date: Tue, 25 Aug 2026 01:52:41 -0700 Subject: [PATCH 09/15] bun_core: drop the now-redundant exact redaction keyword match; ini: drop a stale ordering comment --- src/bun_core/fmt.rs | 12 +----------- src/ini/lib.rs | 2 -- 2 files changed, 1 insertion(+), 13 deletions(-) diff --git a/src/bun_core/fmt.rs b/src/bun_core/fmt.rs index 952b6aef82b5..1c18dd3f74c8 100644 --- a/src/bun_core/fmt.rs +++ b/src/bun_core/fmt.rs @@ -1735,15 +1735,6 @@ impl Keywords { pub(crate) struct RedactedKeywords; impl RedactedKeywords { - // 6 entries — a `matches!` chain is plenty at this size (the big keyword - // table in `Keywords::get` is where the length-dispatched map pays off). - pub(crate) fn has(s: &[u8]) -> bool { - matches!( - s, - b"_auth" | b"_authToken" | b"token" | b"_password" | b"password" | b"email" - ) - } - /// Prefix match, as loose as the ini parser's option matching (`_auth` also covers `_authToken`). pub(crate) fn has_prefix(s: &[u8]) -> bool { [ @@ -1802,8 +1793,7 @@ impl Display for QuickAndDirtyJavaScriptSyntaxHighlighter<'_> { )?; } else { should_redact_value = self.opts.redact_sensitive_information - && (RedactedKeywords::has(&text[..i]) - || RedactedKeywords::has_prefix(&text[..i])); + && RedactedKeywords::has_prefix(&text[..i]); 'write: { if let Some(prev) = prev_keyword { match prev { diff --git a/src/ini/lib.rs b/src/ini/lib.rs index 432267491a4a..d527e140e4bd 100644 --- a/src/ini/lib.rs +++ b/src/ini/lib.rs @@ -1030,8 +1030,6 @@ mod draft { if let Some(keyexpr) = prop.key { if let Some(key) = keyexpr.as_utf8_string_literal() { if bun_core::has_prefix(key, b"//") { - // Order matters: `_authToken` must be - // matched before `_auth`. const OPTNAMES: &[(&[u8], ConfigOpt)] = &[ (b"keyfile", ConfigOpt::Keyfile), (b"certfile", ConfigOpt::Certfile), From b6b6a8cb6c497aa393e327ce4b502e16eb88bf5b Mon Sep 17 00:00:00 2001 From: Alistair Smith Date: Wed, 26 Aug 2026 23:52:52 -0700 Subject: [PATCH 10/15] ini: the unknown-option warning names the file and line instead of echoing the line --- src/ast/lib.rs | 20 ------------------- src/ini/lib.rs | 20 +++++++++++-------- test/cli/install/redacted-config-logs.test.ts | 12 +++++++++-- 3 files changed, 22 insertions(+), 30 deletions(-) diff --git a/src/ast/lib.rs b/src/ast/lib.rs index ca747ac293fc..af50e2755ac7 100644 --- a/src/ast/lib.rs +++ b/src/ast/lib.rs @@ -1782,26 +1782,6 @@ impl Log { ) } - /// `add_error_fmt_opts` at warning level. - #[cold] - pub fn add_warning_fmt_opts(&mut self, args: fmt::Arguments<'_>, opts: AddErrorOptions<'_>) { - if !Kind::Warn.should_print(self.level) { - return; - } - let text = alloc_print(args); - self.add_formatted_msg( - Kind::Warn, - opts.source, - Range { - loc: opts.loc, - len: opts.len, - }, - text, - Box::default(), - opts.redact_sensitive_information, - ) - } - // TODO(dylan-conway): rename and replace `addErrorFmt` #[inline] pub fn add_error_fmt_opts(&mut self, args: fmt::Arguments<'_>, opts: AddErrorOptions<'_>) { diff --git a/src/ini/lib.rs b/src/ini/lib.rs index d527e140e4bd..9af8d956d211 100644 --- a/src/ini/lib.rs +++ b/src/ini/lib.rs @@ -1830,17 +1830,21 @@ mod draft { IniOption::Some(conf_item) => conf_item, IniOption::None => continue, IniOption::Unknown { suffix, loc } => { - iter.log.add_warning_fmt_opts( + // No source excerpt: the value after `=` may be a secret under a + // misspelt name the redactor cannot recognise (`:authToken=`). + let line = 1 + bun_core::strings::count_char( + &source.contents[..usize::try_from(loc.start).unwrap_or(0)], + b'\n', + ); + iter.log.add_warning_fmt( + None, + bun_ast::Loc::EMPTY, format_args!( - "{} is not a known .npmrc option; ignoring this line", + "{}:{}: {} is not a known .npmrc option; ignoring this line", + bstr::BStr::new(source.path.text), + line, bstr::BStr::new(&suffix), ), - bun_ast::AddErrorOptions { - source: Some(source), - loc, - redact_sensitive_information: true, - ..Default::default() - }, ); continue; } diff --git a/test/cli/install/redacted-config-logs.test.ts b/test/cli/install/redacted-config-logs.test.ts index 482c8260259b..86e1e826f60e 100644 --- a/test/cli/install/redacted-config-logs.test.ts +++ b/test/cli/install/redacted-config-logs.test.ts @@ -218,13 +218,13 @@ describe.concurrent("redact", async () => { // The misspelt option name is what makes a diagnostic print this line at all. title: "quoted _authToken key", npmrc: '"//registry.npmjs.org/:_authtoken"=npm_notarealtokenvalue', - expected: "*", + expected: "is not a known .npmrc option", secret: "npm_notarealtokenvalue", }, { title: "quoted _auth key", npmrc: 'registry=https://registry.example.com/api/\n"//registry.example.com/:_auth_"=does-not-decode', - expected: "*", + expected: "is not a known .npmrc option", secret: "does-not-decode", }, { @@ -233,6 +233,14 @@ describe.concurrent("redact", async () => { expected: "is not a known .npmrc option", secret: "SUPERSECRETVALUE", }, + { + // A typo that drops the underscore matches no redaction keyword, so the + // warning must not echo the line at all. + title: "misspelt option without the underscore", + npmrc: "//registry.npmjs.org/:authToken=npm_notarealtokenvalue", + expected: "is not a known .npmrc option", + secret: "npm_notarealtokenvalue", + }, { // Unquoted twin of the above: the identifier path must redact after a // misspelt option too, since the warning prints the line. From c1e406a0f52725b44cee4ab909e9aa96234e2a11 Mon Sep 17 00:00:00 2001 From: Alistair Smith Date: Thu, 27 Aug 2026 00:36:02 -0700 Subject: [PATCH 11/15] ini: keep the port of a scheme-less .npmrc key as written, like npm --- src/ini/lib.rs | 26 +++++++++++++++----------- test/cli/install/npmrc.test.ts | 12 ++++++------ 2 files changed, 21 insertions(+), 17 deletions(-) diff --git a/src/ini/lib.rs b/src/ini/lib.rs index 9af8d956d211..c1d7331616a4 100644 --- a/src/ini/lib.rs +++ b/src/ini/lib.rs @@ -1096,28 +1096,32 @@ mod draft { } /// npm writes a key with `nerfDart(new URL(registry))`: no scheme, a lowercase - /// host, no default port. A hand-written key is folded to that shape once, here, - /// so the byte-for-byte lookups later behave like npm's map lookups. Bun's docs - /// long showed `//http://host/:_authToken=`, so a leading scheme is dropped too. + /// host, and no default port (URL parsing drops it). A hand-written key is + /// compared as written, so a port in it stays, whatever the scheme turns out to + /// be: `//host:443/` is `http://host:443/`'s key, not `https://host/`'s. Bun's + /// docs long showed `//http://host/:_authToken=`; with a scheme spelled out, that + /// scheme's default port is dropped the way URL parsing would. fn normalize_key(raw: &[u8]) -> Box<[u8]> { let has_scheme = |scheme: &[u8]| { raw.len() >= scheme.len() && raw[..scheme.len()].eq_ignore_ascii_case(scheme) }; - let (default_port, rest): (&[u8], &[u8]) = if has_scheme(b"https://") { - (b"443", &raw[b"https://".len()..]) + let (default_port, rest): (Option<&[u8]>, &[u8]) = if has_scheme(b"https://") { + (Some(b"443"), &raw[b"https://".len()..]) } else if has_scheme(b"http://") { - (b"80", &raw[b"http://".len()..]) + (Some(b"80"), &raw[b"http://".len()..]) } else { - (b"443", raw) + (None, raw) }; let host_end = bun_core::strings::index_of_char_usize(rest, b'/').unwrap_or(rest.len()); let mut key = rest.to_vec(); key[..host_end].make_ascii_lowercase(); // In a bracketed IPv6 authority only a colon after `]` introduces a port. - if let Some(colon) = bun_core::strings::last_index_of_char(&key[..host_end], b':') { - let is_port = key[0] != b'[' || (colon > 0 && key[colon - 1] == b']'); - if is_port && key[colon + 1..host_end] == *default_port { - key.drain(colon..host_end); + if let Some(default_port) = default_port { + if let Some(colon) = bun_core::strings::last_index_of_char(&key[..host_end], b':') { + let is_port = key[0] != b'[' || (colon > 0 && key[colon - 1] == b']'); + if is_port && key[colon + 1..host_end] == *default_port { + key.drain(colon..host_end); + } } } key.into_boxed_slice() diff --git a/test/cli/install/npmrc.test.ts b/test/cli/install/npmrc.test.ts index 4334e8d0cae9..ee2ff0bbb1b1 100644 --- a/test/cli/install/npmrc.test.ts +++ b/test/cli/install/npmrc.test.ts @@ -1064,11 +1064,11 @@ describe("the config key's authority is normalized like a WHATWG URL", () => { expect(token(`registry=HTTPS://example.com:443/api/\n//example.com/:_authToken=T\n`)).toBe("T"); }); - // npm's key never spells out a default port; a hand-written `:443` is dropped when - // read, so the key still matches (released Bun matched it too). - it("drops :443 from a key, so it matches an https registry", () => { - expect(token(`registry=https://example.com:443/api/\n//example.com:443/:_authToken=T\n`)).toBe("T"); - expect(token(`registry=https://example.com/api/\n//example.com:443/:_authToken=T\n`)).toBe("T"); + // npm compares a hand-written key as written: a port in it stays, so `//host:443/` + // is the key of `http://host:443/` (a TLS-terminating proxy) and not of `https://host/`. + it("keeps a port written in a scheme-less key", () => { + expect(token(`registry=http://example.com:443/api/\n//example.com:443/:_authToken=T\n`)).toBe("T"); + expect(token(`registry=https://example.com/api/\n//example.com:443/:_authToken=T\n`)).toBe(""); }); // Bun's docs long showed keys with a scheme (`//http://localhost:4873/:_authToken=`); @@ -1081,7 +1081,7 @@ describe("the config key's authority is normalized like a WHATWG URL", () => { }); it("drops a default port after a bracketed IPv6 host, not inside it", () => { - expect(token(`registry=https://[::1]/\n//[::1]:443/:_authToken=T\n`)).toBe("T"); + expect(token(`registry=https://[::1]/\n//https://[::1]:443/:_authToken=T\n`)).toBe("T"); expect(token(`registry=http://[::80]/\n//[::80]/:_authToken=T\n`)).toBe("T"); }); From f945ae5ad25a5f5498df6c795ee89fc182784304 Mon Sep 17 00:00:00 2001 From: Alistair Smith Date: Thu, 27 Aug 2026 01:35:39 -0700 Subject: [PATCH 12/15] bun_core: drop the redaction widening the excerpt-free warning made unreachable --- src/bun_core/fmt.rs | 33 +++++-------------- src/bun_core/lib.rs | 18 +++++----- test/cli/install/redacted-config-logs.test.ts | 10 ++---- 3 files changed, 22 insertions(+), 39 deletions(-) diff --git a/src/bun_core/fmt.rs b/src/bun_core/fmt.rs index 1c18dd3f74c8..8793aea5d90e 100644 --- a/src/bun_core/fmt.rs +++ b/src/bun_core/fmt.rs @@ -1735,17 +1735,13 @@ impl Keywords { pub(crate) struct RedactedKeywords; impl RedactedKeywords { - /// Prefix match, as loose as the ini parser's option matching (`_auth` also covers `_authToken`). - pub(crate) fn has_prefix(s: &[u8]) -> bool { - [ - b"_auth".as_slice(), - b"token", - b"_password", - b"password", - b"email", - ] - .iter() - .any(|k| s.starts_with(k)) + // 6 entries — a `matches!` chain is plenty at this size (the big keyword + // table in `Keywords::get` is where the length-dispatched map pays off). + pub(crate) fn has(s: &[u8]) -> bool { + matches!( + s, + b"_auth" | b"_authToken" | b"token" | b"_password" | b"password" | b"email" + ) } } @@ -1792,8 +1788,8 @@ impl Display for QuickAndDirtyJavaScriptSyntaxHighlighter<'_> { bstr::BStr::new(&text[..i]), )?; } else { - should_redact_value = self.opts.redact_sensitive_information - && RedactedKeywords::has_prefix(&text[..i]); + should_redact_value = + self.opts.redact_sensitive_information && RedactedKeywords::has(&text[..i]); 'write: { if let Some(prev) = prev_keyword { match prev { @@ -2058,17 +2054,6 @@ impl Display for QuickAndDirtyJavaScriptSyntaxHighlighter<'_> { text = &text[i..]; continue 'outer; } - - // A quoted ini key (`"//host/:_authToken"=v`) never reaches the - // identifier path; arm redaction here, after the value redactors. - let mut rest: &[u8] = inner; - while let Some(colon) = strings::index_of_char(rest, b':') { - rest = &rest[colon as usize + 1..]; - if RedactedKeywords::has_prefix(rest) { - should_redact_value = true; - break; - } - } } write!( diff --git a/src/bun_core/lib.rs b/src/bun_core/lib.rs index be372bee8d8b..20c1cf097471 100644 --- a/src/bun_core/lib.rs +++ b/src/bun_core/lib.rs @@ -2014,23 +2014,22 @@ pub(crate) mod strings_impl { return None; } - // A longer name that starts with `item` (`_authtoken`, `_auth_`) is a misspelling - // of it, and its value is just as secret. A closing quote ends a quoted ini key. + let mut whitespace = false; let mut offset = item.len(); - while offset < text.len() && crate::js_lexer::is_identifier_continue(text[offset] as i32) { - offset += 1; - } - if offset < text.len() && matches!(text[offset], b'"' | b'\'') { - offset += 1; - } while offset < text.len() && text[offset].is_ascii_whitespace() { offset += 1; + whitespace = true; } if offset == text.len() { return None; } let cont = crate::js_lexer::is_identifier_continue(text[offset] as i32); + // must be another identifier + if !whitespace && cont { + return None; + } + // `null` is not returned after this point. Redact to the next // newline if anything is unexpected if cont { @@ -2092,6 +2091,9 @@ pub(crate) mod strings_impl { if let Some(r) = starts_with_redacted_item(str, b"_auth") { return Some(r); } + if let Some(r) = starts_with_redacted_item(str, b"_authToken") { + return Some(r); + } if let Some(r) = starts_with_redacted_item(str, b"email") { return Some(r); } diff --git a/test/cli/install/redacted-config-logs.test.ts b/test/cli/install/redacted-config-logs.test.ts index 86e1e826f60e..2d80d8d895c0 100644 --- a/test/cli/install/redacted-config-logs.test.ts +++ b/test/cli/install/redacted-config-logs.test.ts @@ -213,9 +213,9 @@ describe.concurrent("redact", async () => { expected: "supplies no credentials", }, { - // A quoted key is a string literal, not an identifier, so it took a different - // path through the highlighter and the value came out verbatim under color. - // The misspelt option name is what makes a diagnostic print this line at all. + // The unknown-option warning names the option but never echoes the line, so no + // value can leak whatever the key's spelling: quoted, misspelt, or without the + // underscore. These rows pin that the warning prints and the value does not. title: "quoted _authToken key", npmrc: '"//registry.npmjs.org/:_authtoken"=npm_notarealtokenvalue', expected: "is not a known .npmrc option", @@ -234,16 +234,12 @@ describe.concurrent("redact", async () => { secret: "SUPERSECRETVALUE", }, { - // A typo that drops the underscore matches no redaction keyword, so the - // warning must not echo the line at all. title: "misspelt option without the underscore", npmrc: "//registry.npmjs.org/:authToken=npm_notarealtokenvalue", expected: "is not a known .npmrc option", secret: "npm_notarealtokenvalue", }, { - // Unquoted twin of the above: the identifier path must redact after a - // misspelt option too, since the warning prints the line. title: "misspelt _authToken key", npmrc: "//registry.npmjs.org/:_authtoken=npm_notarealtokenvalue", expected: "is not a known .npmrc option", From 934b54d96cd8b4448f4086b2c5b765835562a6d9 Mon Sep 17 00:00:00 2001 From: Alistair Smith Date: Thu, 27 Aug 2026 13:05:01 -0700 Subject: [PATCH 13/15] test: carry the part 1 rows through the rebase --- test/cli/install/bun-install.test.ts | 93 ++++++++++++- test/cli/install/npmrc.test.ts | 189 ++++++++++++++++++++++----- 2 files changed, 246 insertions(+), 36 deletions(-) diff --git a/test/cli/install/bun-install.test.ts b/test/cli/install/bun-install.test.ts index 0e5c066991cd..d9e0995a6329 100644 --- a/test/cli/install/bun-install.test.ts +++ b/test/cli/install/bun-install.test.ts @@ -1302,7 +1302,12 @@ describe.concurrent("bun-install", () => { // secret ships in the request path, where proxies and logs can see it. describe("credentials embedded in the registry URL", () => { // Returns the Authorization header and the request path the registry saw. - async function probeEmbedded(registryPath: string, authLines: (host: string) => string, userinfo = "") { + async function probeEmbedded( + registryPath: string, + authLines: (host: string) => string, + userinfo = "", + { cli = false }: { cli?: boolean } = {}, + ) { const seen: Array<{ path: string; auth: string | null }> = []; await using registry = Bun.serve({ port: 0, @@ -1313,14 +1318,15 @@ describe.concurrent("bun-install", () => { }, }); const host = `127.0.0.1:${registry.port}`; + const registryUrl = `http://${userinfo}${host}${registryPath}`; using dir = tempDir("npmrc-embedded-auth", { - ".npmrc": `@myorg:registry=http://${userinfo}${host}${registryPath}\n${authLines(host)}\n`, + ".npmrc": cli ? `${authLines(host)}\n` : `@myorg:registry=${registryUrl}\n${authLines(host)}\n`, "package.json": JSON.stringify({ name: "probe", version: "0.0.0", dependencies: { "@myorg/pkg": "1.0.0" } }), "home/.gitkeep": "", }); const home = join(String(dir), "home"); await using proc = Bun.spawn({ - cmd: [bunExe(), "install", "--no-cache"], + cmd: [bunExe(), "install", "--no-cache", ...(cli ? ["--registry", registryUrl] : [])], cwd: String(dir), env: { ...env, HOME: home, USERPROFILE: home, XDG_CONFIG_HOME: home }, stdout: "pipe", @@ -1336,6 +1342,87 @@ describe.concurrent("bun-install", () => { return seen[0]!; } + // The bunfig object form is the one way into the scope builder with a token already + // set, so it pins that the strip does not depend on the credential being adopted. + async function probeBunfig(registryPath: string, token: string) { + const seen: Array<{ path: string; auth: string | null }> = []; + await using registry = Bun.serve({ + port: 0, + hostname: "127.0.0.1", + fetch(req) { + seen.push({ path: new URL(req.url).pathname, auth: req.headers.get("authorization") }); + return new Response("not found", { status: 404 }); + }, + }); + const host = `127.0.0.1:${registry.port}`; + using dir = tempDir("bunfig-embedded-auth", { + "bunfig.toml": `[install.scopes]\nmyorg = { url = "http://${host}${registryPath}", token = "${token}" }\n`, + "package.json": JSON.stringify({ name: "probe", version: "0.0.0", dependencies: { "@myorg/pkg": "1.0.0" } }), + "home/.gitkeep": "", + }); + const home = join(String(dir), "home"); + await using proc = Bun.spawn({ + cmd: [bunExe(), "install", "--no-cache"], + cwd: String(dir), + env: { ...env, HOME: home, USERPROFILE: home, XDG_CONFIG_HOME: home }, + stdout: "pipe", + stderr: "pipe", + stdin: "ignore", + }); + const [, , exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ requests: seen.length, exitCode }).toEqual({ requests: 1, exitCode: 1 }); + return seen[0]!; + } + + // Bun's JFrog guide writes the credential as a path segment, `/npm/_auth=`. + // Base64 holds `/`, so the marker, not the last slash, must anchor the strip. + it("strips a slash-form _auth whose base64 value contains a slash", async () => { + const seen = await probeEmbedded("/api/npm/_auth=YWJj/ZGVm", () => ""); + expect(seen).toEqual({ path: "/api/npm/@myorg%2fpkg", auth: "Basic YWJj/ZGVm" }); + }); + + it("strips a slash-form _authToken whose value contains a slash", async () => { + const seen = await probeEmbedded("/api/_authToken=a/b", () => ""); + expect(seen).toEqual({ path: "/api/@myorg%2fpkg", auth: "Bearer a/b" }); + }); + + it("strips slash-form username and _password segments", async () => { + const seen = await probeEmbedded("/api/username=u/_password=p/q", () => ""); + expect(seen).toEqual({ path: "/api/@myorg%2fpkg", auth: `Basic ${Buffer.from("u:p/q").toString("base64")}` }); + }); + + it("leaves a plain `=` in a path segment alone", async () => { + const seen = await probeEmbedded("/a=b/c/", () => ""); + expect(seen).toEqual({ path: "/a=b/c/@myorg%2fpkg", auth: null }); + }); + + // `--registry` used to bypass the scope builder; with a trailing slash after the + // marker the token went out in every request path. + it("strips an embedded _authToken from a --registry URL with a trailing slash", async () => { + const seen = await probeEmbedded("/api/:_authToken=S/", () => "", "", { cli: true }); + expect(seen).toEqual({ path: "/api/@myorg%2fpkg", auth: "Bearer S" }); + }); + + it("keeps the .npmrc token of the same host over a --registry URL's embedded one", async () => { + const seen = await probeEmbedded( + "/api/:_authToken=S/", + host => `registry=http://${host}/\n//${host}/:_authToken=T`, + "", + { cli: true }, + ); + expect(seen).toEqual({ path: "/api/@myorg%2fpkg", auth: "Bearer T" }); + }); + + it("strips an embedded _auth when bunfig already supplies a token", async () => { + const seen = await probeBunfig("/api/:_auth=opaque-blob", "T"); + expect(seen).toEqual({ path: "/api/@myorg%2fpkg", auth: "Bearer T" }); + }); + + it("keeps bunfig's token over an embedded _authToken", async () => { + const seen = await probeBunfig("/api/:_authToken=EMBEDDED", "T"); + expect(seen).toEqual({ path: "/api/@myorg%2fpkg", auth: "Bearer T" }); + }); + it("strips an embedded _authToken from the path and uses it when .npmrc has none", async () => { const seen = await probeEmbedded("/api/:_authToken=EMBEDDED", () => ""); expect(seen).toEqual({ path: "/api/@myorg%2fpkg", auth: "Bearer EMBEDDED" }); diff --git a/test/cli/install/npmrc.test.ts b/test/cli/install/npmrc.test.ts index ee2ff0bbb1b1..58786c0b2e8b 100644 --- a/test/cli/install/npmrc.test.ts +++ b/test/cli/install/npmrc.test.ts @@ -602,8 +602,8 @@ registry=https://somehost.com/org1/npm/registry/ }); describe("credentials keyed to a bracketed IPv6 host", () => { - // Config keys are matched literally against the keys walked up from the registry - // URL, so the bracketed authority only has to survive the registry side's parse. + // Keys and registry URLs are both parsed as URLs and compared on host and path, so + // the bracketed authority has to survive both parses. test.each([ ["loopback with a port", "http://[::1]:4873/", "//[::1]:4873/"], ["loopback without a port", "http://[::1]/", "//[::1]/"], @@ -866,6 +866,124 @@ registry=https://:TOK@somehost.com/ // `Scope::from_api` decodes `_auth` solely to derive the identity `bun pm whoami` // prints; the credential itself is always forwarded verbatim. + test("an env registry on the same host keeps the .npmrc username and _password", async () => { + const blob = Buffer.from("alice:hunter2").toString("base64"); + const authorizations: (string | null)[] = []; + await using registry = Bun.serve({ + port: 0, + hostname: "127.0.0.1", + fetch(req) { + authorizations.push(req.headers.get("authorization")); + return Response.json({ + name: "pkg", + "dist-tags": { latest: "1.0.0" }, + versions: { "1.0.0": { name: "pkg", version: "1.0.0" } }, + }); + }, + }); + const host = `127.0.0.1:${registry.port}`; + using dir = tempDir("npmrc-env-registry-auth", { + "home/.gitkeep": "", + "package.json": JSON.stringify({ name: "probe", version: "0.0.0" }), + ".npmrc": `registry=http://${host}/\n//${host}/:username=alice\n//${host}/:_password=${Buffer.from("hunter2").toString("base64")}\n`, + }); + const homeDir = join(String(dir), "home"); + await using proc = Bun.spawn({ + cmd: [bunExe(), "pm", "view", "pkg", "version"], + cwd: String(dir), + env: { + ...env, + HOME: homeDir, + USERPROFILE: homeDir, + XDG_CONFIG_HOME: homeDir, + npm_config_registry: `http://${host}/`, + }, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, , exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ authorizations, stdout }).toEqual({ authorizations: [`Basic ${blob}`], stdout: "1.0.0\n" }); + expect(exitCode).toBe(0); + }); + test("an env registry on the same host keeps the registry URL's userinfo", async () => { + const blob = Buffer.from("alice:hunter2").toString("base64"); + const authorizations: (string | null)[] = []; + await using registry = Bun.serve({ + port: 0, + hostname: "127.0.0.1", + fetch(req) { + authorizations.push(req.headers.get("authorization")); + return Response.json({ + name: "pkg", + "dist-tags": { latest: "1.0.0" }, + versions: { "1.0.0": { name: "pkg", version: "1.0.0" } }, + }); + }, + }); + const host = `127.0.0.1:${registry.port}`; + using dir = tempDir("npmrc-env-registry-auth", { + "home/.gitkeep": "", + "package.json": JSON.stringify({ name: "probe", version: "0.0.0" }), + ".npmrc": `registry=http://alice:hunter2@${host}/\n`, + }); + const homeDir = join(String(dir), "home"); + await using proc = Bun.spawn({ + cmd: [bunExe(), "pm", "view", "pkg", "version"], + cwd: String(dir), + env: { + ...env, + HOME: homeDir, + USERPROFILE: homeDir, + XDG_CONFIG_HOME: homeDir, + npm_config_registry: `http://${host}/`, + }, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, , exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ authorizations, stdout }).toEqual({ authorizations: [`Basic ${blob}`], stdout: "1.0.0\n" }); + expect(exitCode).toBe(0); + }); + test("an env registry that downgrades https to http drops the .npmrc _auth", async () => { + const blob = Buffer.from("alice:hunter2").toString("base64"); + const authorizations: (string | null)[] = []; + await using registry = Bun.serve({ + port: 0, + hostname: "127.0.0.1", + fetch(req) { + authorizations.push(req.headers.get("authorization")); + return Response.json({ + name: "pkg", + "dist-tags": { latest: "1.0.0" }, + versions: { "1.0.0": { name: "pkg", version: "1.0.0" } }, + }); + }, + }); + const host = `127.0.0.1:${registry.port}`; + using dir = tempDir("npmrc-env-registry-auth", { + "home/.gitkeep": "", + "package.json": JSON.stringify({ name: "probe", version: "0.0.0" }), + ".npmrc": `registry=https://${host}/\n//${host}/:_auth=${blob}\n`, + }); + const homeDir = join(String(dir), "home"); + await using proc = Bun.spawn({ + cmd: [bunExe(), "pm", "view", "pkg", "version"], + cwd: String(dir), + env: { + ...env, + HOME: homeDir, + USERPROFILE: homeDir, + XDG_CONFIG_HOME: homeDir, + npm_config_registry: `http://${host}/`, + }, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, , exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ authorizations, stdout }).toEqual({ authorizations: [null], stdout: "1.0.0\n" }); + expect(exitCode).toBe(0); + }); + describe("bun pm whoami derives the username from _auth", () => { async function whoamiWith(files: Record) { using dir = tempDir("npmrc-whoami-auth", { @@ -887,37 +1005,42 @@ registry=https://:TOK@somehost.com/ return { stdout, stderr, exitCode }; } - function whoami(authValue: string) { - return whoamiWith({ - ".npmrc": `registry=https://registry.invalid/\n//registry.invalid/:_auth=${authValue}\n`, + // Answers `/-/whoami` with a fixed name and records the Authorization header, so a + // request that goes out is visible even when Bun cannot derive a name locally. + async function whoamiAgainstRegistry(authLine: (host: string) => string, userinfo = "") { + const authorizations: (string | null)[] = []; + await using registry = Bun.serve({ + port: 0, + hostname: "127.0.0.1", + fetch(req) { + authorizations.push(req.headers.get("authorization")); + return Response.json({ username: "from-registry" }); + }, }); + const host = `127.0.0.1:${registry.port}`; + const result = await whoamiWith({ + ".npmrc": `registry=http://${userinfo}${host}/\n${authLine(host)}\n`, + }); + return { ...result, authorizations }; } - test("a decodable _auth prints its username", async () => { - const { stdout, exitCode } = await whoami(Buffer.from("alice:s3cret").toString("base64")); - expect(stdout).toBe("alice\n"); + test("a decodable _auth prints its username without a request", async () => { + const blob = Buffer.from("alice:s3cret").toString("base64"); + const { stdout, exitCode, authorizations } = await whoamiAgainstRegistry(host => `//${host}/:_auth=${blob}`); + expect({ stdout, authorizations }).toEqual({ stdout: "alice\n", authorizations: [] }); expect(exitCode).toBe(0); }); - test("a non-decodable _auth carries no identity", async () => { - const { stdout, stderr, exitCode } = await whoami("!!not-base64!!"); - expect(stdout).toBe(""); - expect(stderr).toContain("missing authentication"); - expect(exitCode).toBe(1); - }); - - test("an _auth with a blank username carries no identity", async () => { - const { stdout, stderr, exitCode } = await whoami(Buffer.from(":s3cret").toString("base64")); - expect(stdout).toBe(""); - expect(stderr).toContain("missing authentication"); - expect(exitCode).toBe(1); - }); - - test("an _auth with a blank password carries no identity", async () => { - const { stdout, stderr, exitCode } = await whoami(Buffer.from("tok:").toString("base64")); - expect(stdout).toBe(""); - expect(stderr).toContain("missing authentication"); - expect(exitCode).toBe(1); + // No local identity in these values, so the credential goes to the registry as + // written and the registry answers; main gave up with "missing authentication". + test.each([ + ["opaque", "!!not-base64!!"], + ["blank username", Buffer.from(":s3cret").toString("base64")], + ["blank password", Buffer.from("tok:").toString("base64")], + ])("an _auth without a local identity asks the registry (%s)", async (_name, authValue) => { + const { stdout, exitCode, authorizations } = await whoamiAgainstRegistry(host => `//${host}/:_auth=${authValue}`); + expect({ stdout, authorizations }).toEqual({ stdout: "from-registry\n", authorizations: [`Basic ${authValue}`] }); + expect(exitCode).toBe(0); }); // The wire sends `Basic <_auth>` here (auth beats the username + password the @@ -927,12 +1050,12 @@ registry=https://:TOK@somehost.com/ ["opaque", "!!not-base64!!"], ["blank-password", Buffer.from("tok:").toString("base64")], ])("the registry URL's username/password do not leak an identity past _auth (%s)", async (_name, authValue) => { - const { stdout, stderr, exitCode } = await whoamiWith({ - ".npmrc": `registry=https://url-user:url-pass@registry.invalid/\n//registry.invalid/:_auth=${authValue}\n`, - }); - expect(stdout).toBe(""); - expect(stderr).toContain("missing authentication"); - expect(exitCode).toBe(1); + const { stdout, exitCode, authorizations } = await whoamiAgainstRegistry( + host => `//${host}/:_auth=${authValue}`, + "url-user:url-pass@", + ); + expect({ stdout, authorizations }).toEqual({ stdout: "from-registry\n", authorizations: [`Basic ${authValue}`] }); + expect(exitCode).toBe(0); }); // Credentials declared in bunfig.toml beat every .npmrc line for that registry, From cb37a4373134b2eb566f7ade9d713a383e213a7c Mon Sep 17 00:00:00 2001 From: Alistair Smith Date: Thu, 27 Aug 2026 13:32:05 -0700 Subject: [PATCH 14/15] ini: collect and collapse .npmrc credentials only; the package manager applies them once by npm's walk --- docs/pm/npmrc.mdx | 2 +- src/api/lib.rs | 2 +- src/ast/lib.rs | 29 ++ src/ini/lib.rs | 267 +++++++----------- src/install/PackageManager.rs | 19 +- .../PackageManager/PackageManagerOptions.rs | 37 +++ src/install/npm.rs | 59 ++++ src/install_jsc/ini_jsc.rs | 38 ++- src/options_types/schema.rs | 23 +- test/cli/install/bun-install.test.ts | 7 +- test/cli/install/npmrc.test.ts | 65 ++--- test/cli/install/redacted-config-logs.test.ts | 26 ++ 12 files changed, 349 insertions(+), 225 deletions(-) diff --git a/docs/pm/npmrc.mdx b/docs/pm/npmrc.mdx index c59babd3f2b0..7f2f4ef46458 100644 --- a/docs/pm/npmrc.mdx +++ b/docs/pm/npmrc.mdx @@ -84,7 +84,7 @@ Bun supports the following options: - `username` - `_password` (base64 encoded password) - `_auth` (sent as written in a `Basic` header; npm's form is base64 encoded username:password, for example `btoa(username + ":" + password)`) -- `email` +- `email` (accepted and ignored, as npm does) The equivalent `bunfig.toml` option is to add a key in [`install.scopes`](/runtime/bunfig#install-scopes): diff --git a/src/api/lib.rs b/src/api/lib.rs index 5ea6fdaf5719..1c8639c80dcf 100644 --- a/src/api/lib.rs +++ b/src/api/lib.rs @@ -9,7 +9,7 @@ // ────────────────────────────────────────────────────────────────────────── pub use bun_options_types::schema::api::{ - BunInstall, Ca, NodeLinker, NpmRegistry, NpmRegistryMap, PnpmMatcher, + BunInstall, Ca, NodeLinker, NpmRegistry, NpmRegistryMap, NpmUrlAuth, PnpmMatcher, }; // ────────────────────────────────────────────────────────────────────────── diff --git a/src/ast/lib.rs b/src/ast/lib.rs index af50e2755ac7..a6dfa440f847 100644 --- a/src/ast/lib.rs +++ b/src/ast/lib.rs @@ -1862,6 +1862,35 @@ impl Log { ) } + /// A warning placed at `loc` that never prints the source line: for a line whose + /// text may hold a secret the redactor cannot recognise. + #[cold] + pub fn add_warning_fmt_no_excerpt( + &mut self, + source: &Source, + loc: Loc, + args: fmt::Arguments<'_>, + ) { + if !Kind::Warn.should_print(self.level) { + return; + } + self.warnings += 1; + let mut location = Location::init_or_null(Some(source), Range { loc, len: 1 }); + if let Some(location) = location.as_mut() { + location.line_text = None; + } + let data = Data { + text: alloc_print(args), + location, + } + .clone_line_text(self.clone_line_text); + self.add_msg(Msg { + kind: Kind::Warn, + data, + ..Default::default() + }) + } + #[cold] pub fn add_warning_fmt_line_col( &mut self, diff --git a/src/ini/lib.rs b/src/ini/lib.rs index c1d7331616a4..a3c59b163cb6 100644 --- a/src/ini/lib.rs +++ b/src/ini/lib.rs @@ -126,7 +126,7 @@ bun_core::comptime_string_map! { } pub use draft::{ - ConfigIterator, Parser, ScopeItem, ScopeIterator, ToStringFormatter, load_npmrc, + ConfigIterator, Parser, RegistryKey, ScopeItem, ScopeIterator, ToStringFormatter, load_npmrc, load_npmrc_config, }; @@ -136,7 +136,7 @@ mod draft { use core::ptr; use bun_alloc::{AllocError, Arena, ArenaVec, ArenaVecExt as _}; - use bun_api::{self, BunInstall, NpmRegistry, npm_registry}; + use bun_api::{self, BunInstall, NpmRegistry, NpmUrlAuth, npm_registry}; use bun_ast::E::Rope; use bun_ast::{E, Expr, ExprData, StoreRef}; use bun_ast::{Loc, Log, Source}; @@ -1077,15 +1077,29 @@ mod draft { return Some(IniOption::None); } } - // `//host/...:word=` where `word` is none of the seven options: npm - // warns here, and silence would hide a typo like `_authtoken`. + // `//host/...:word=` where `word` is none of the seven options: a + // typo like `_authtoken` would otherwise be silent and its credential + // dropped. Only an option-shaped word with a string value qualifies, + // so a bare `//host:port` line or a missing `=` never names a value. if let Some(colon) = bun_core::strings::last_index_of_char(key, b':') .filter(|&colon| !bun_core::strings::contains_char(&key[colon..], b'/')) { - return Some(IniOption::Unknown { - suffix: Box::from(&key[colon + 1..]), - loc: keyexpr.loc, - }); + let word = &key[colon + 1..]; + let option_shaped = word + .first() + .is_some_and(|&c| c == b'_' || c.is_ascii_alphabetic()) + && word + .iter() + .all(|&c| c == b'_' || c == b'-' || c.is_ascii_alphanumeric()); + let has_string_value = prop + .value + .is_some_and(|value| value.as_utf8_string_literal().is_some()); + if option_shaped && has_string_value { + return Some(IniOption::Unknown { + suffix: Box::from(word), + loc: keyexpr.loc, + }); + } } } } @@ -1168,7 +1182,10 @@ mod draft { log: &mut *self.log, source: self.source, }; - break 'brk parser.parse_registry_url_string_impl(str_)?; + let mut registry = + parser.parse_registry_url_string_impl(str_)?; + registry.credentials_from_url = true; + break 'brk registry; } } return Ok(Some(IniOption::None)); @@ -1195,7 +1212,7 @@ mod draft { /// with its own credentials keeps them: project config beats `.npmrc`. pub fn load_npmrc_config( install: &mut BunInstall, - bunfig: &mut BunInstall, + bunfig: &BunInstall, env: &DotEnvLoader, auto_loaded: bool, npmrc_paths: &[&ZStr], @@ -1240,20 +1257,11 @@ mod draft { report_log(&mut log); } - let mut registries = resolve_credentials(install, &configs); + install.url_auth = collapse_url_auth(&configs); if !configs.is_empty() { - for registry in bunfig - .default_registry - .iter_mut() - .chain(bunfig.scoped.iter_mut().flat_map(|s| s.scopes.values_mut())) - { - if let Some(key) = RegistryKey::for_bunfig(registry) { - credentials(&configs, &key).apply(registry); - registries.push(key); - } - } + let registries = declared_registry_keys(install, bunfig); + diagnose_config(&configs, &sources, ®istries, &mut log); } - diagnose_config(&configs, &sources, ®istries, &mut log); report_log(&mut log); } @@ -1298,17 +1306,6 @@ mod draft { } } - /// The port a WHATWG URL drops from `host` for this scheme. - fn default_port_for(protocol: &[u8]) -> Option<&'static [u8]> { - if protocol.eq_ignore_ascii_case(b"https") { - Some(b"443") - } else if protocol.eq_ignore_ascii_case(b"http") { - Some(b"80") - } else { - None - } - } - fn url_or_default(url: &[u8]) -> &[u8] { if url.is_empty() { bun_install_types::NodeLinker::npm::Registry::DEFAULT_URL.as_bytes() @@ -1317,33 +1314,29 @@ mod draft { } } - /// A registry as the walk sees it. npm builds the key from a WHATWG URL, whose - /// `host` is lowercased and drops a default port; `bun_url` does neither, so the - /// authority is normalized here, the same way `normalize_key` folds config keys. - struct RegistryKey { - /// `/`: npm's key for the registry itself, and where the walk + /// A registry as the walk sees it. npm builds the key from a WHATWG URL: the host + /// lowercased, a default port dropped, the query left out. Built from the same + /// serialisation `Scope::set_url` stores, so the key and the request agree on the + /// authority; a URL WHATWG rejects (`$VAR`) falls back to the raw text. + pub struct RegistryKey { + /// `/`: npm's key for the registry itself, and where the walk /// starts (`regFetch` appends `/` to the registry URL and the first /// iteration of the walk strips it right back off). key: Box<[u8]>, } impl RegistryKey { - fn from_url(url_bytes: &[u8]) -> RegistryKey { - Self::from_parsed(&URL::parse(url_bytes)) - } - - fn from_parsed(url: &URL<'_>) -> RegistryKey { - let default_port = default_port_for(url.protocol); - let host = if default_port.is_some_and(|port| url.port == port) { - url.hostname - } else { - url.host + pub fn from_url(url_bytes: &[u8]) -> RegistryKey { + let owned = URL::from_string(&bun_core::String::borrow_utf8(url_bytes)).ok(); + let url = match &owned { + Some(owned) => owned.url(), + None => URL::parse(url_bytes), }; - let pathname = url.pathname.strip_suffix(b"/").unwrap_or(url.pathname); - let mut key = Vec::with_capacity(host.len() + pathname.len() + 1); - key.extend_from_slice(host); - key[..host.len()].make_ascii_lowercase(); - key.extend_from_slice(pathname); + let path = url.path.strip_suffix(b"/").unwrap_or(url.path); + let mut key = Vec::with_capacity(url.host.len() + path.len() + 1); + key.extend_from_slice(url.host); + key[..url.host.len()].make_ascii_lowercase(); + key.extend_from_slice(path); key.push(b'/'); RegistryKey { key: key.into_boxed_slice(), @@ -1352,7 +1345,7 @@ mod draft { /// npm's `regFromURI`: this key, then one component shorter each time, down /// to the bare host. For `h/a/` that is `h/a/`, `h/a`, `h/`, `h`. - fn walk(&self) -> impl Iterator> { + pub fn walk(&self) -> impl Iterator> { let mut next = Some(self.key.to_vec()); core::iter::from_fn(move || { let current = next.take()?; @@ -1407,11 +1400,6 @@ mod draft { username: &'a ConfigItem, password: &'a ConfigItem, }, - /// Bun-only: a half pair on the registry's own key layers over the URL's userinfo. - Partial { - username: Option<&'a ConfigItem>, - password: Option<&'a ConfigItem>, - }, } /// npm's `hasAuth`, keyed on byte equality with the config key. @@ -1427,32 +1415,6 @@ mod draft { Some(Auth::UserPass { username, password }) } - /// Every line that applies to one registry. - struct Credentials<'a> { - auth: Option>, - /// `email` is not part of npm's auth at all, so it never walks. - email: Option<&'a ConfigItem>, - } - - /// npm's `regFromURI`: walk the registry's config keys deepest-first until one - /// supplies auth. For `host=h, pathname=/a/` that is `h/a/`, `h/a`, `h/`, `h`. - fn credentials<'a>(configs: &'a [ConfigItem], registry: &RegistryKey) -> Credentials<'a> { - let mut auth = registry.walk().find_map(|key| has_auth(configs, &key)); - if auth.is_none() { - for key in [®istry.key[..], registry.unslashed()] { - let username = lookup_truthy(configs, key, ConfigOpt::Username); - let password = lookup_truthy(configs, key, ConfigOpt::_Password); - if username.is_some() || password.is_some() { - auth = Some(Auth::Partial { username, password }); - break; - } - } - } - let email = lookup_truthy(configs, ®istry.key, ConfigOpt::Email) - .or_else(|| lookup_truthy(configs, registry.unslashed(), ConfigOpt::Email)); - Credentials { auth, email } - } - /// npm's `Buffer.from(value, "base64")` never fails: invalid bytes are skipped and /// as much as possible is decoded. fn decode_password(value: &[u8]) -> Box<[u8]> { @@ -1462,76 +1424,67 @@ mod draft { decoded.into_boxed_slice() } - impl<'a> Credentials<'a> { - fn items(&self) -> impl Iterator { - let (a, b) = match &self.auth { - Some(Auth::Token(item) | Auth::Basic(item)) => (Some(*item), None), - Some(Auth::UserPass { username, password }) => (Some(*username), Some(*password)), - Some(Auth::Partial { username, password }) => (*username, *password), - None => (None, None), - }; - a.into_iter().chain(b).chain(self.email) - } - - fn apply(&self, v: &mut NpmRegistry) { - match &self.auth { - Some(Auth::Token(token)) => v.token.clone_from(&token.value), - // npm forwards `_auth` verbatim as `Basic `; `Scope::from_api` - // decodes it only to derive a username for `bun pm whoami`. - Some(Auth::Basic(auth)) => v.auth.clone_from(&auth.value), - Some(Auth::UserPass { username, password }) => { - v.username.clone_from(&username.value); - v.password = decode_password(&password.value); - } - Some(Auth::Partial { username, password }) => { - if let Some(username) = username { - v.username.clone_from(&username.value); - } - if let Some(password) = password { - v.password = decode_password(&password.value); - } - } - None => {} - } - if let Some(email) = self.email { - v.email.clone_from(&email.value); + /// What one config key supplies, in the shape `Scope::from_api` consumes. + fn apply_auth(auth: &Auth<'_>, v: &mut NpmRegistry) { + match auth { + Auth::Token(token) => v.token.clone_from(&token.value), + // npm forwards `_auth` verbatim as `Basic `; `Scope::from_api` + // decodes it only to derive a username for `bun pm whoami`. + Auth::Basic(auth) => v.auth.clone_from(&auth.value), + Auth::UserPass { username, password } => { + v.username.clone_from(&username.value); + v.password = decode_password(&password.value); } } } - /// Resolve the registries `.npmrc` itself declares (or npm's default, when it - /// declares none) against the fully-accumulated `configs`, exactly once. Returns - /// the registries resolved, for `diagnose_config`. - fn resolve_credentials(install: &mut BunInstall, configs: &[ConfigItem]) -> Vec { - let mut registries: Vec = Vec::new(); - if configs.is_empty() { - return registries; + /// npm's config map, reduced to one entry per key that carries a complete + /// credential. The package manager walks it for every registry it ends up with. + fn collapse_url_auth(configs: &[ConfigItem]) -> Vec { + let mut out: Vec = Vec::new(); + for conf_item in configs { + if out + .iter() + .any(|entry| *entry.key == *conf_item.registry_url) + { + continue; + } + let Some(auth) = has_auth(configs, &conf_item.registry_url) else { + continue; + }; + let mut credentials = NpmRegistry::default(); + apply_auth(&auth, &mut credentials); + out.push(NpmUrlAuth { + key: conf_item.registry_url.clone(), + credentials, + }); } + out + } + /// The keys of the registries `.npmrc` and `bunfig.toml` declare (or npm's default, + /// when neither declares one), for the diagnostics that name a registry. + fn declared_registry_keys(install: &BunInstall, bunfig: &BunInstall) -> Vec { let default_url: &[u8] = match &install.default_registry { Some(registry) => ®istry.url, None => b"", }; - let default_key = RegistryKey::from_url(url_or_default(default_url)); - let default_credentials = credentials(configs, &default_key); - if default_credentials.items().next().is_some() { - default_credentials.apply(install.default_registry.get_or_insert_with(|| { - NpmRegistry { - url: bun_install_types::NodeLinker::npm::Registry::DEFAULT_URL - .as_bytes() - .into(), - ..Default::default() - } - })); + let mut registries = vec![RegistryKey::from_url(url_or_default(default_url))]; + if let Some(scoped) = &install.scoped { + registries.extend( + scoped + .scopes + .values() + .iter() + .map(|v| RegistryKey::from_url(&v.url)), + ); } - registries.push(default_key); - - if let Some(scoped) = install.scoped.as_mut() { - for v in scoped.scopes.values_mut() { - let key = RegistryKey::from_url(&v.url); - credentials(configs, &key).apply(v); - registries.push(key); - } + for registry in bunfig + .default_registry + .iter() + .chain(bunfig.scoped.iter().flat_map(|s| s.scopes.values())) + { + registries.extend(RegistryKey::for_bunfig(registry)); } registries } @@ -1578,8 +1531,11 @@ mod draft { ) -> OOM<()> { let mut configs: Vec = Vec::new(); parse_npmrc_into(install, env, log, source, 0, &mut configs)?; - let registries = resolve_credentials(install, &configs); - diagnose_config(&configs, std::slice::from_ref(source), ®istries, log); + install.url_auth = collapse_url_auth(&configs); + if !configs.is_empty() { + let registries = declared_registry_keys(install, &BunInstall::default()); + diagnose_config(&configs, std::slice::from_ref(source), ®istries, log); + } Ok(()) } @@ -1608,8 +1564,9 @@ mod draft { log: &mut *log, source, }; - install.default_registry = - Some(p.parse_registry_url_string_impl(&Box::<[u8]>::from(str_))?); + let mut registry = p.parse_registry_url_string_impl(&Box::<[u8]>::from(str_))?; + registry.credentials_from_url = true; + install.default_registry = Some(registry); } } @@ -1836,17 +1793,11 @@ mod draft { IniOption::Unknown { suffix, loc } => { // No source excerpt: the value after `=` may be a secret under a // misspelt name the redactor cannot recognise (`:authToken=`). - let line = 1 + bun_core::strings::count_char( - &source.contents[..usize::try_from(loc.start).unwrap_or(0)], - b'\n', - ); - iter.log.add_warning_fmt( - None, - bun_ast::Loc::EMPTY, + iter.log.add_warning_fmt_no_excerpt( + source, + loc, format_args!( - "{}:{}: {} is not a known .npmrc option; ignoring this line", - bstr::BStr::new(source.path.text), - line, + "{} is not a known .npmrc option; ignoring this line", bstr::BStr::new(&suffix), ), ); diff --git a/src/install/PackageManager.rs b/src/install/PackageManager.rs index 515f9f9aab90..577ead1429d0 100644 --- a/src/install/PackageManager.rs +++ b/src/install/PackageManager.rs @@ -1375,6 +1375,7 @@ fn overlay_bunfig_install(install: &mut Api::BunInstall, bunfig: Api::BunInstall let Api::BunInstall { default_registry, scoped, + url_auth, lockfile_path, save_lockfile_path, cache_directory, @@ -1424,6 +1425,8 @@ fn overlay_bunfig_install(install: &mut Api::BunInstall, bunfig: Api::BunInstall } } + install.url_auth.extend(url_auth); + macro_rules! overlay { ($($field:ident),* $(,)?) => { $( if $field.is_some() { install.$field = $field; } )* @@ -1482,6 +1485,10 @@ pub fn init( cli: CommandLineArguments, subcommand: Subcommand, ) -> Result<(&'static mut PackageManager, Box<[u8]>), Error> { + // `.npmrc` is read below, before `Options::load` applies the log level. + if cli.log_level.is_silent() { + bun_ast::DEFAULT_LOG_LEVEL.store(bun_ast::Level::Err); + } if cli.global { // Non-consuming peek: `ctx.install` is // `Option>` borrowed via `&mut ContextData`; reborrow with @@ -1915,7 +1922,7 @@ pub fn init( { // npmrc < bunfig < CLI - let mut bunfig_install = ctx + let bunfig_install = ctx .install .take() .map_or_else(Api::BunInstall::default, |b| *b); @@ -1947,19 +1954,13 @@ pub fn init( if global_len > 0 { ini::load_npmrc_config( &mut install, - &mut bunfig_install, + &bunfig_install, env, true, &[ZStr::from_buf(&buf[..], global_len), &*npmrc_local], ); } else { - ini::load_npmrc_config( - &mut install, - &mut bunfig_install, - env, - true, - &[&*npmrc_local], - ); + ini::load_npmrc_config(&mut install, &bunfig_install, env, true, &[&*npmrc_local]); } overlay_bunfig_install(&mut install, bunfig_install); diff --git a/src/install/PackageManager/PackageManagerOptions.rs b/src/install/PackageManager/PackageManagerOptions.rs index 62d5662576e4..0819d566136a 100644 --- a/src/install/PackageManager/PackageManagerOptions.rs +++ b/src/install/PackageManager/PackageManagerOptions.rs @@ -40,6 +40,9 @@ pub struct Options { pub scope: Npm::registry::Scope, pub(crate) registries: Npm::registry::Map, + /// Every `.npmrc` credential key, for the registries whose URL is only known + /// after the config files were read. + pub(crate) url_auth: Vec, pub(crate) cache_directory: &'static [u8], pub enable: Enable, pub do_: Do, @@ -127,6 +130,7 @@ impl Default for Options { // Always assigned in `load()` before read. scope: Npm::registry::Scope::default(), registries: Npm::registry::Map::default(), + url_auth: Vec::new(), cache_directory: b"", enable: Enable::default(), do_: Do::default(), @@ -418,6 +422,25 @@ fn leak_static(s: &[u8]) -> &'static [u8] { } impl Options { + /// Give every scope that ended up without credentials the ones `.npmrc` + /// configures for its registry URL, by npm's key walk. A registry from + /// `bunfig.toml`, `--registry` or `$NPM_CONFIG_REGISTRY` is only known here, and + /// a credential from any of those sources outranks a `.npmrc` line. + fn fill_credentials_from_url_auth(&mut self) { + if self.url_auth.is_empty() { + return; + } + let url_auth = &self.url_auth; + for scope in core::iter::once(&mut self.scope).chain(self.registries.values_mut()) { + if scope.has_credentials() && !scope.credentials_from_url { + continue; + } + if let Some(credentials) = Npm::registry::UrlAuth::find(url_auth, &scope.url.url()) { + scope.copy_credentials_from(credentials); + } + } + } + pub(crate) fn load( &mut self, log: &mut bun_ast::Log, @@ -468,6 +491,12 @@ impl Options { } } + for url_auth in &config.url_auth { + if let Some(url_auth) = Npm::registry::UrlAuth::from_api(url_auth, env)? { + self.url_auth.push(url_auth); + } + } + if let Some(ca) = &config.ca { match ca { Api::Ca::List(ca_list) => { @@ -636,6 +665,7 @@ impl Options { if same_host_not_downgraded(&new_url, &self.scope.url.url()) { api_registry.token = core::mem::take(&mut self.scope.token); api_registry.auth = core::mem::take(&mut self.scope.auth); + api_registry.credentials_from_url = self.scope.credentials_from_url; } } self.scope = Npm::registry::Scope::from_api(b"", api_registry, env)?; @@ -654,6 +684,7 @@ impl Options { if same_host_not_downgraded(&new_url, &self.scope.url.url()) { api_registry.token = core::mem::take(&mut self.scope.token); api_registry.auth = core::mem::take(&mut self.scope.auth); + api_registry.credentials_from_url = self.scope.credentials_from_url; } } // Through the same builder as every other registry, so a credential @@ -673,6 +704,7 @@ impl Options { if let Some(token) = env.get(token_key) { if !token.is_empty() { self.scope.token = token.into(); + self.scope.credentials_from_url = false; break; } } @@ -728,6 +760,7 @@ impl Options { if !cli.token.is_empty() { self.scope.token = cli.token.into(); + self.scope.credentials_from_url = false; } if cli.no_save { @@ -924,6 +957,10 @@ impl Options { } // moved from `defer { ... }` after scope assignment (see note above). + // After every source that can set a registry URL (bunfig, .npmrc, environment, + // command line) has been applied. + self.fill_credentials_from_url_auth(); + self.did_override_default_scope = self.scope.url_hash != *Npm::registry::DEFAULT_URL_HASH; // The manifest cache is the data source for --prefer-offline/--offline; keep it on diff --git a/src/install/npm.rs b/src/install/npm.rs index 8132461f68bd..3f3e7d3ccce4 100644 --- a/src/install/npm.rs +++ b/src/install/npm.rs @@ -298,6 +298,12 @@ pub mod registry { // username and password combo, `user:pass` pub user: Box<[u8]>, + + /// The registry came from a `.npmrc` `registry=` line, so any credential in + /// its URL (userinfo, a `:_authToken=` segment) is the weakest source: a + /// `.npmrc` line for the URL replaces it, where a bunfig, env or CLI credential + /// stands. Cleared when an explicit token is set later. + pub credentials_from_url: bool, } /// yarn-style credentials embedded in a registry URL's pathname, e.g. @@ -428,6 +434,19 @@ pub mod registry { self.url_hash = Self::hash(strings::without_trailing_slash(self.url.href())); } + /// Whether requests to this scope carry an `Authorization` header. + pub(crate) fn has_credentials(&self) -> bool { + !self.token.is_empty() || !self.auth.is_empty() + } + + /// Take `other`'s credentials, leaving this scope's registry URL as is. + pub(crate) fn copy_credentials_from(&mut self, other: &Scope) { + self.token.clone_from(&other.token); + self.auth.clone_from(&other.auth); + self.user.clone_from(&other.user); + self.credentials_from_url = other.credentials_from_url; + } + pub(crate) fn get_name(name: &[u8]) -> &[u8] { if name.is_empty() || name[0] != b'@' { return name; @@ -555,6 +574,7 @@ pub mod registry { token: registry.token, auth, user, + credentials_from_url: registry.credentials_from_url, ..Default::default() }; scope.set_url(final_href); @@ -565,6 +585,45 @@ pub mod registry { // Keys are pre-hashed (`Scope::hash`), so don't re-hash them. pub type Map = HashMap>; + /// One `.npmrc` credential key with the credential it collapsed to, matched + /// against registry URLs with npm's walk (`regFromURI`): build the URL's own key, + /// then try it and every shorter key down to the bare host. Keys carry no scheme, + /// so a key applies to `http` and `https` alike. + pub(crate) struct UrlAuth { + /// npm's config key, already normalized by `bun_ini` (`host/path/`). + key: Box<[u8]>, + /// Only the credential fields are meaningful; `url` is empty. + credentials: Scope, + } + + impl UrlAuth { + pub(crate) fn from_api( + api: &api::NpmUrlAuth, + env: &mut DotEnv, + ) -> Result, AllocError> { + let credentials = Scope::from_api(b"", api.credentials.clone(), env)?; + if !credentials.has_credentials() { + return Ok(None); + } + Ok(Some(UrlAuth { + key: api.key.clone(), + credentials, + })) + } + + /// The credentials `.npmrc` configures for `url`: the first key on npm's walk + /// from `url` that has an entry, so `//host/a/b/` beats `//host/`. + pub(crate) fn find<'a>(list: &'a [UrlAuth], url: &URL) -> Option<&'a Scope> { + if list.is_empty() { + return None; + } + bun_ini::RegistryKey::from_url(url.href) + .walk() + .find_map(|key| list.iter().find(|entry| *entry.key == *key)) + .map(|entry| &entry.credentials) + } + } + pub(crate) enum PackageVersionResponse { Cached(PackageManifest), Fresh(PackageManifest), diff --git a/src/install_jsc/ini_jsc.rs b/src/install_jsc/ini_jsc.rs index e160b795c1d4..b65511b56cbb 100644 --- a/src/install_jsc/ini_jsc.rs +++ b/src/install_jsc/ini_jsc.rs @@ -83,13 +83,39 @@ impl IniTestingAPIs { if load_npmrc(&mut install, env, &mut log, &source).is_err() { return log.to_js(global, format_args!("error")); } + // The package manager applies `url_auth` in `Options::load`; the same walk + // here reports what a request to the default registry would carry. + let default_url: Box<[u8]> = match install.default_registry.as_ref() { + Some(registry) => registry.url.clone(), + None => Registry::DEFAULT_URL.as_bytes().into(), + }; + let found = bun_ini::RegistryKey::from_url(&default_url) + .walk() + .find_map(|key| install.url_auth.iter().find(|entry| *entry.key == *key)); + if let Some(found) = found { + let registry = install + .default_registry + .get_or_insert_with(|| bun_api::NpmRegistry { + url: default_url.clone(), + ..Default::default() + }); + if !registry.has_credentials() || registry.credentials_from_url { + registry.token = Box::default(); + registry.auth = Box::default(); + registry.username = Box::default(); + registry.password = Box::default(); + registry.token.clone_from(&found.credentials.token); + registry.auth.clone_from(&found.credentials.auth); + registry.username.clone_from(&found.credentials.username); + registry.password.clone_from(&found.credentials.password); + } + } let ( default_registry_url, default_registry_token, default_registry_username, default_registry_password, - default_registry_email, default_registry_auth, ) = 'brk: { let Some(default_registry) = install.default_registry.as_ref() else { @@ -99,7 +125,6 @@ impl IniTestingAPIs { BunString::EMPTY, BunString::EMPTY, BunString::EMPTY, - BunString::EMPTY, ); }; @@ -108,7 +133,6 @@ impl IniTestingAPIs { BunString::from_bytes(&default_registry.token), BunString::from_bytes(&default_registry.username), BunString::from_bytes(&default_registry.password), - BunString::from_bytes(&default_registry.email), BunString::from_bytes(&default_registry.auth), ) }; @@ -121,11 +145,10 @@ impl IniTestingAPIs { default_registry_token: BunString, default_registry_username: BunString, default_registry_password: BunString, - default_registry_email: BunString, default_registry_auth: BunString, } impl bun_jsc::js_object::PojoFields for Pojo { - const FIELD_COUNT: usize = 6; + const FIELD_COUNT: usize = 5; fn put_fields( &self, global: &JSGlobalObject, @@ -147,10 +170,6 @@ impl IniTestingAPIs { b"default_registry_password", self.default_registry_password.to_js(global)?, )?; - put( - b"default_registry_email", - self.default_registry_email.to_js(global)?, - )?; put( b"default_registry_auth", self.default_registry_auth.to_js(global)?, @@ -163,7 +182,6 @@ impl IniTestingAPIs { default_registry_token, default_registry_username, default_registry_password, - default_registry_email, default_registry_auth, }; Ok(bun_jsc::JSObject::create(&pojo, global)?.to_js()) diff --git a/src/options_types/schema.rs b/src/options_types/schema.rs index f1fe7c388730..26ab9dc70f97 100644 --- a/src/options_types/schema.rs +++ b/src/options_types/schema.rs @@ -151,8 +151,11 @@ pub mod api { /// `.npmrc`'s `_auth`, verbatim. npm never decodes it, so neither may we. /// Not read from `bunfig.toml`; it only carries the value to `Scope::from_api`. pub auth: Box<[u8]>, - /// email - pub email: Box<[u8]>, + /// The registry was declared by a `.npmrc` `registry=` line. A credential inside + /// that URL (userinfo, or a `:_authToken=` segment) is then the weakest source, + /// which any `.npmrc` line for the key replaces; a URL typed into `bunfig.toml`, + /// the environment or the command line keeps its credential. + pub credentials_from_url: bool, } impl NpmRegistry { @@ -203,6 +206,18 @@ pub mod api { /// same type. pub use bun_install_types::NodeLinker::{NodeLinker, PnpmMatcher}; + /// The credential one `.npmrc` key resolved to (`//host/path/:_authToken=...`, + /// `:_auth=`, or `:username=` + `:_password=`), kept past config loading so the + /// package manager can match it against a registry URL it only learns later + /// (`--registry`, `$NPM_CONFIG_REGISTRY`, a bunfig registry without credentials). + #[derive(Clone, Debug, Default)] + pub struct NpmUrlAuth { + /// npm's config key as written between `//` and `:=`, e.g. `host/path/`. + pub key: Box<[u8]>, + /// Only `token`, `auth`, `username` and `password` are set; `url` stays empty. + pub credentials: NpmRegistry, + } + /// Full field set. /// `Default` is every field `None`/empty. /// @@ -214,6 +229,10 @@ pub mod api { pub default_registry: Option, /// scoped pub scoped: Option, + /// One entry per `.npmrc` credential key that carries a complete credential, + /// after every file was collapsed into one map. `Options::load` walks it for + /// every registry that ended up without credentials. + pub url_auth: Vec, /// lockfile_path pub lockfile_path: Option>, /// save_lockfile_path diff --git a/test/cli/install/bun-install.test.ts b/test/cli/install/bun-install.test.ts index d9e0995a6329..4d46699bf298 100644 --- a/test/cli/install/bun-install.test.ts +++ b/test/cli/install/bun-install.test.ts @@ -1521,7 +1521,7 @@ describe.concurrent("bun-install", () => { // one the registry already stores (from its URL's userinfo) is Bun-only and stays // exact-path: an ancestor's stray `username=` must not rebind a deeper registry's // stored password to a new identity. - describe("a partial credential only layers over stored credentials at the registry's own path", () => { + describe("a lone username or _password supplies nothing, as in npm's hasAuth", () => { const stored = { registry: "default", userinfo: "url-user:url-pass@" } as const; const storedBasic = `Basic ${Buffer.from("url-user:url-pass").toString("base64")}`; @@ -1545,11 +1545,6 @@ describe.concurrent("bun-install", () => { }); expect(auth).toBe(`Bearer ${token}`); }); - - it("the registry's own path still layers a lone username over the stored password", async () => { - const auth = await probeAuthorization(host => `//${host}${registryPath}:username=npmrc-user`, stored); - expect(auth).toBe(`Basic ${Buffer.from("npmrc-user:url-pass").toString("base64")}`); - }); }); // A registry declared in `bunfig.toml` resolves through the same walk, unless diff --git a/test/cli/install/npmrc.test.ts b/test/cli/install/npmrc.test.ts index 58786c0b2e8b..2fa3f61baa29 100644 --- a/test/cli/install/npmrc.test.ts +++ b/test/cli/install/npmrc.test.ts @@ -289,7 +289,6 @@ registry=http://localhost:\${PORT}/ default_registry_token: string; default_registry_username: string; default_registry_password: string; - default_registry_email: string; }) => void, ) { const optionName = await Promise.all(options.map(async ([name, val]) => `${name} = ${val}`)); @@ -553,22 +552,15 @@ ${Object.keys(opts) }); }); - await makeTest([["email", "user@example.com"]], result => { - expect(result.default_registry_url).toEqual("https://registry.npmjs.org/"); - expect(result.default_registry_email).toEqual("user@example.com"); - }); - await makeTest( [ ["username", "testuser"], ["_password", "testpass"], - ["email", "test@example.com"], ], result => { expect(result.default_registry_url).toEqual("https://registry.npmjs.org/"); expect(result.default_registry_username).toEqual("testuser"); expect(result.default_registry_password).toEqual("testpass"); - expect(result.default_registry_email).toEqual("test@example.com"); }, ); @@ -619,7 +611,6 @@ registry=https://somehost.com/org1/npm/registry/ default_registry_token: "v6-token", default_registry_username: "", default_registry_password: "", - default_registry_email: "", default_registry_auth: "", }); }); @@ -633,7 +624,6 @@ registry=https://somehost.com/org1/npm/registry/ default_registry_token: "", default_registry_username: "v6-user", default_registry_password: "v6-password", - default_registry_email: "", default_registry_auth: "", }); @@ -643,7 +633,6 @@ registry=https://somehost.com/org1/npm/registry/ default_registry_token: "", default_registry_username: "", default_registry_password: "", - default_registry_email: "", default_registry_auth: auth, }); }); @@ -776,43 +765,27 @@ registry=https://somehost.com/org1/npm/registry/ expect(result.default_registry_username).toBe("bilbo"); expect(result.default_registry_password).toBe("verysecure"); }); - - // `email` is not part of npm's auth (`npm-registry-fetch`'s `getAuth` never reads - // it), so it does not walk: only a line naming the registry's own path applies. - test("an ancestor's email does not apply to a deeper registry", () => { - const result = loadNpmrc(` -registry=https://somehost.com/org1/npm/registry/ -//somehost.com/:email=bilbo@example.com -`); - expect(result.default_registry_email).toBe(""); - }); - - test("the registry's own email applies", () => { - const result = loadNpmrc(` -registry=https://somehost.com/org1/npm/registry/ -//somehost.com/:email=gandalf@example.com -//somehost.com/org1/npm/registry/:email=bilbo@example.com -`); - expect(result.default_registry_email).toBe("bilbo@example.com"); - }); }); describe("credentials that did not come from .npmrc survive resolution", () => { - test("an invalid _auth does not discard the registry URL's token", () => { + // A credential written into the registry URL is the weakest source: npm's + // `getAuth` never reads it, so any complete `.npmrc` line for the key replaces it. + test("an .npmrc _auth replaces the registry URL's token", () => { const result = loadNpmrc(` registry=https://:TOK@somehost.com/ //somehost.com/:_auth=not-valid-base64 `); - expect(result.default_registry_token).toBe("TOK"); + expect(result.default_registry_token).toBe(""); + expect(result.default_registry_auth).toBe("not-valid-base64"); }); - test("an .npmrc username/_password does not discard the registry URL's token", () => { + test("an .npmrc username/_password replaces the registry URL's token", () => { const result = loadNpmrc(` registry=https://:TOK@somehost.com/ //somehost.com/:username=gandalf //somehost.com/:_password=${Buffer.from("verysecure").toString("base64")} `); - expect(result.default_registry_token).toBe("TOK"); + expect(result.default_registry_token).toBe(""); expect(result.default_registry_username).toBe("gandalf"); expect(result.default_registry_password).toBe("verysecure"); }); @@ -944,7 +917,9 @@ registry=https://:TOK@somehost.com/ expect({ authorizations, stdout }).toEqual({ authorizations: [`Basic ${blob}`], stdout: "1.0.0\n" }); expect(exitCode).toBe(0); }); - test("an env registry that downgrades https to http drops the .npmrc _auth", async () => { + // A `.npmrc` key carries no scheme, so the line applies to the http registry too, as + // in npm; only a credential carried over from another registry refuses a downgrade. + test("an env registry that downgrades https to http still gets the .npmrc _auth", async () => { const blob = Buffer.from("alice:hunter2").toString("base64"); const authorizations: (string | null)[] = []; await using registry = Bun.serve({ @@ -980,7 +955,7 @@ registry=https://:TOK@somehost.com/ stderr: "pipe", }); const [stdout, , exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); - expect({ authorizations, stdout }).toEqual({ authorizations: [null], stdout: "1.0.0\n" }); + expect({ authorizations, stdout }).toEqual({ authorizations: [`Basic ${blob}`], stdout: "1.0.0\n" }); expect(exitCode).toBe(0); }); @@ -1218,7 +1193,7 @@ describe("the config key's authority is normalized like a WHATWG URL", () => { // Diagnostics printed while reading .npmrc must never echo a credential. describe(".npmrc diagnostics", () => { - async function stderrOf(npmrc: string, bunfig?: object) { + async function stderrOf(npmrc: string, bunfig?: object, args: string[] = []) { using dir = tempDir("npmrc-diagnostics", { ".npmrc": npmrc, "package.json": JSON.stringify({ name: "x", version: "1.0.0" }), @@ -1227,7 +1202,7 @@ describe(".npmrc diagnostics", () => { }); const home = join(String(dir), "home"); await using proc = Bun.spawn({ - cmd: [bunExe(), "install", "--no-cache"], + cmd: [bunExe(), "install", "--no-cache", ...args], cwd: String(dir), env: { ...env, HOME: home, USERPROFILE: home, XDG_CONFIG_HOME: home }, stdout: "pipe", @@ -1244,6 +1219,20 @@ describe(".npmrc diagnostics", () => { expect(stderr).not.toContain("SECRETTOKEN"); }); + it("names the file and line of an unknown option", async () => { + const stderr = await stderrOf(`registry=https://example.com/\n\n//example.com/:_authtoken=SECRETTOKEN\n`); + expect(stderr).toMatch(/_authtoken is not a known .npmrc option[^\n]*\n\s+at .npmrc:3:/); + expect(stderr).not.toContain("SECRETTOKEN"); + }); + + it("says nothing about an unknown option under --silent", async () => { + const stderr = await stderrOf(`registry=https://example.com/\n//example.com/:_authtoken=SECRETTOKEN\n`, undefined, [ + "--silent", + ]); + expect(stderr).not.toContain("not a known .npmrc option"); + expect(stderr).not.toContain("SECRETTOKEN"); + }); + it("says nothing about a key that matches once normalized", async () => { const stderr = await stderrOf(`registry=https://example.com/api/\n//Example.COM:443/:_authToken=SECRETTOKEN\n`); expect(stderr).toBe("No packages! Deleted empty lockfile\n"); diff --git a/test/cli/install/redacted-config-logs.test.ts b/test/cli/install/redacted-config-logs.test.ts index 2d80d8d895c0..0c0f9524220d 100644 --- a/test/cli/install/redacted-config-logs.test.ts +++ b/test/cli/install/redacted-config-logs.test.ts @@ -245,6 +245,32 @@ describe.concurrent("redact", async () => { expected: "is not a known .npmrc option", secret: "npm_notarealtokenvalue", }, + // Lines that are not `//host/…:option=value` never reach the unknown-option + // warning, since the word after the last colon could be the value itself. + { + title: "credential key without an equals sign", + npmrc: "//registry.npmjs.org/:_authToken", + expected: "", + secret: "_authToken", + }, + { + title: "credential key with a colon instead of an equals sign", + npmrc: "//registry.npmjs.org/:_authToken:npm_notarealtokenvalue", + expected: "", + secret: "npm_notarealtokenvalue", + }, + { + title: "bare host and port", + npmrc: "//localhost:4873", + expected: "", + secret: "4873", + }, + { + title: "bare bracketed IPv6 host", + npmrc: "//[::1]", + expected: "", + secret: "::1", + }, { // The most common .npmrc authoring mistake, and the value is always a live secret. // npm decodes _password with Buffer.from(v, "base64"), which never throws — it From 0bcb68a5f530c8c3ddaabe6396cc59ba7ba89f46 Mon Sep 17 00:00:00 2001 From: Alistair Smith Date: Thu, 27 Aug 2026 17:21:47 -0700 Subject: [PATCH 15/15] ini: key a one-character registry path; the unknown-option warning needs a /:word key --- src/ini/lib.rs | 13 ++++++++++++- test/cli/install/npmrc.test.ts | 6 ++++++ test/cli/install/redacted-config-logs.test.ts | 8 ++++++++ 3 files changed, 26 insertions(+), 1 deletion(-) diff --git a/src/ini/lib.rs b/src/ini/lib.rs index a3c59b163cb6..a748a8d68631 100644 --- a/src/ini/lib.rs +++ b/src/ini/lib.rs @@ -1081,8 +1081,12 @@ mod draft { // typo like `_authtoken` would otherwise be silent and its credential // dropped. Only an option-shaped word with a string value qualifies, // so a bare `//host:port` line or a missing `=` never names a value. + // The word sits right after the key's last `/` as `:word`, with no + // second colon: `//host/:_auth:dXNl…` (a `:` typed for `=`) would + // otherwise name the credential as the option. if let Some(colon) = bun_core::strings::last_index_of_char(key, b':') .filter(|&colon| !bun_core::strings::contains_char(&key[colon..], b'/')) + .filter(|&colon| colon > 0 && key[colon - 1] == b'/') { let word = &key[colon + 1..]; let option_shaped = word @@ -1332,7 +1336,14 @@ mod draft { Some(owned) => owned.url(), None => URL::parse(url_bytes), }; - let path = url.path.strip_suffix(b"/").unwrap_or(url.path); + // `pathname` carries the query; `path` is query-free but collapses a + // one-byte path such as `/r/` to `/`, so cut the query off `pathname`. + let pathname = url.pathname; + let path_end = + bun_core::strings::index_of_char_usize(pathname, b'?').unwrap_or(pathname.len()); + let path = pathname[..path_end] + .strip_suffix(b"/") + .unwrap_or(&pathname[..path_end]); let mut key = Vec::with_capacity(url.host.len() + path.len() + 1); key.extend_from_slice(url.host); key[..url.host.len()].make_ascii_lowercase(); diff --git a/test/cli/install/npmrc.test.ts b/test/cli/install/npmrc.test.ts index 2fa3f61baa29..54723179349c 100644 --- a/test/cli/install/npmrc.test.ts +++ b/test/cli/install/npmrc.test.ts @@ -1164,6 +1164,12 @@ describe("the config key's authority is normalized like a WHATWG URL", () => { // npm compares a hand-written key as written: a port in it stays, so `//host:443/` // is the key of `http://host:443/` (a TLS-terminating proxy) and not of `https://host/`. + // Bun's fast URL parser collapses a one-byte path (`/r/`) to `/`; the key must not. + it("keeps a one-character registry path in the key", () => { + expect(token(`registry=https://example.com/r/\n//example.com/r/:_authToken=T\n`)).toBe("T"); + expect(token(`registry=https://example.com/r/\n//example.com/:_authToken=T\n`)).toBe("T"); + }); + it("keeps a port written in a scheme-less key", () => { expect(token(`registry=http://example.com:443/api/\n//example.com:443/:_authToken=T\n`)).toBe("T"); expect(token(`registry=https://example.com/api/\n//example.com:443/:_authToken=T\n`)).toBe(""); diff --git a/test/cli/install/redacted-config-logs.test.ts b/test/cli/install/redacted-config-logs.test.ts index 0c0f9524220d..f22b48efa157 100644 --- a/test/cli/install/redacted-config-logs.test.ts +++ b/test/cli/install/redacted-config-logs.test.ts @@ -271,6 +271,14 @@ describe.concurrent("redact", async () => { expected: "", secret: "::1", }, + { + // `:` typed for `=`: the parser splits at the base64 padding, so the credential + // bytes sit where an option name would; the warning must not name them. + title: "credential key with a colon and a padded base64 value", + npmrc: "//registry.npmjs.org/:_auth:dXNlcjpwdw==", + expected: "", + secret: "dXNlcjpwdw", + }, { // The most common .npmrc authoring mistake, and the value is always a live secret. // npm decodes _password with Buffer.from(v, "base64"), which never throws — it