Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
42 commits
Select commit Hold shift + click to select a range
db529b6
install: send .npmrc _auth verbatim and never leave a registry URL cr…
alii Aug 25, 2026
35065d9
install: keep .npmrc _auth across a same-host env registry override; …
alii Aug 25, 2026
cec67de
test: pin verbatim _auth with values a base64 round trip would change
alii Aug 26, 2026
08c04d2
install: strip slash-form credential segments too, route --registry t…
alii Aug 27, 2026
7ef54db
install: split the merged doc comment on the Authorization builders
alii Aug 27, 2026
a6783cf
install: resolve .npmrc credentials by npm's key walk, normalise keys…
alii Aug 25, 2026
fdf6b9b
ini: a known option with a non-string value is not a typo; redact quo…
alii Aug 25, 2026
10a2d0e
test: isolate the non-string .npmrc value test from the ambient ~/.npmrc
alii Aug 25, 2026
cebf8f8
bun_core: drop the now-redundant exact redaction keyword match; ini: …
alii Aug 25, 2026
0d4a4be
ini: the unknown-option warning names the file and line instead of ec…
alii Aug 27, 2026
47851d9
ini: keep the port of a scheme-less .npmrc key as written, like npm
alii Aug 27, 2026
36dc113
bun_core: drop the redaction widening the excerpt-free warning made u…
alii Aug 27, 2026
639aab5
test: carry the part 1 rows through the rebase
alii Aug 27, 2026
384c7e5
ini: collect and collapse .npmrc credentials only; the package manage…
alii Aug 27, 2026
015ac92
ini: key a one-character registry path; the unknown-option warning ne…
alii Aug 28, 2026
e602ecf
install: match embedded credential markers case-insensitively and str…
alii Aug 28, 2026
2c12799
install: search the credential marker with strings::last_index_of_any
alii Aug 28, 2026
fd25827
install: resolve .npmrc credentials per request and scope a registry'…
alii Aug 25, 2026
56660f9
install: a --registry or env registry inherits the default registry's…
alii Aug 25, 2026
cb02753
install: a tarball on the registry's origin keeps the registry's cred…
alii Aug 25, 2026
0060a78
install: a .npmrc line specific to the tarball's path beats the regis…
alii Aug 25, 2026
9f1ebd3
install: compare the tarball's .npmrc key with the one the registry r…
alii Aug 26, 2026
36dc6f2
install: no .npmrc credentials for an http tarball named by an https …
alii Aug 26, 2026
6d0bf4e
install: a .npmrc line's credential never goes over plaintext to anot…
alii Aug 26, 2026
eb68f7d
test: serve the string-prefix tarball host over https so the key walk…
alii Aug 27, 2026
d2509c6
install: a --registry or env registry under the default keeps its tok…
alii Aug 27, 2026
1c012db
install: keep the registry's credentials for a dot-segment tarball on…
alii Aug 27, 2026
1cfe734
test: put the query on the manifest's dist.tarball URL
alii Aug 27, 2026
8fe13cb
install: a .npmrc line is looked up for a tarball's resolved path; on…
alii Aug 28, 2026
30c72c0
install: a literal backslash in a tarball path is opaque to the .npmr…
alii Aug 28, 2026
5d159e9
install: no .npmrc line for a tarball URL the fast parser and the WHA…
alii Aug 28, 2026
903730a
install: a tarball with a dot segment gets no .npmrc line of its own;…
alii Aug 29, 2026
74edafa
install: a control byte in a tarball path makes it non-canonical for …
alii Aug 29, 2026
f20cfe6
install: parse dist.tarball once with the WHATWG parser; the request …
Jarred-Sumner Aug 29, 2026
1cefb13
test: import node:tls at module scope
Jarred-Sumner Aug 29, 2026
725cf58
install: warn only on a misspelt credential option; request an unsett…
alii Aug 31, 2026
871c956
test: the HTTP client's InvalidURL is what refuses a tarball URL with…
Jarred-Sumner Aug 31, 2026
087434a
install: match an embedded credential marker as spelled and strip a m…
alii Sep 1, 2026
fe9476d
install: tie the borrow store_header_buf returns to self (#41131)
robobun Sep 1, 2026
f0b5a3b
server: hoist get_remote_socket_info's body out of the generic Reques…
Jarred-Sumner Sep 1, 2026
8dac149
Merge branch 'main' into claude/npmrc-credential-hygiene
alii Sep 2, 2026
dc5dd3c
Merge branch 'main' into claude/npmrc-credential-hygiene
alii Sep 14, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions docs/pm/npmrc.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -83,8 +83,8 @@ Bun supports the following options:
- `_authToken`
- `username`
- `_password` (base64 encoded password)
- `_auth` (base64 encoded username:password, for example `btoa(username + ":" + password)`)
- `email`
- `_auth` (sent as written in a `Basic` header; npm's form is base64 encoded username:password, for example `btoa(username + ":" + password)`)
- `email` (accepted and ignored, as npm does)

The equivalent `bunfig.toml` option is to add a key in [`install.scopes`](/runtime/bunfig#install-scopes):

Expand Down
2 changes: 1 addition & 1 deletion src/api/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
// ──────────────────────────────────────────────────────────────────────────

pub use bun_options_types::schema::api::{
BunInstall, Ca, NodeLinker, NpmRegistry, NpmRegistryMap, PnpmMatcher,
BunInstall, Ca, NodeLinker, NpmRegistry, NpmRegistryMap, NpmUrlAuth, PnpmMatcher,
};

// ──────────────────────────────────────────────────────────────────────────
Expand Down
29 changes: 29 additions & 0 deletions src/ast/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1867,6 +1867,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,
Expand Down
682 changes: 425 additions & 257 deletions src/ini/lib.rs

Large diffs are not rendered by default.

108 changes: 50 additions & 58 deletions src/install/NetworkTask.rs
Original file line number Diff line number Diff line change
Expand Up @@ -387,26 +387,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");
}

Expand Down Expand Up @@ -716,6 +709,13 @@ impl NetworkTask {
Ok(())
}

/// Moves the fully written header block into `self.header_buf` and returns a view of it.
fn store_header_buf<'a>(&'a mut self, header_builder: &mut HeaderBuilder) -> &'a [u8] {
debug_assert_eq!(header_builder.content.len, header_builder.content.cap);
self.header_buf = header_builder.content.move_to_slice();
&self.header_buf
}

pub(crate) fn get_completion_callback(&mut self) -> HTTPClientResultCallback {
// `HTTPClientResultCallback::new`
// performs type erasure over a `fn(*mut T, *mut AsyncHTTP, _)`.
Expand Down Expand Up @@ -823,60 +823,52 @@ impl NetworkTask {
None => None,
};

// Only attach the registry `Authorization` header when the tarball URL
// origin matches the configured registry scope origin. The npm manifest
// is registry-controlled, so a malicious registry could otherwise point
// the tarball at an attacker-controlled host and receive the scope
// credentials. The empty-`tarball_url` branch builds the URL from
// `scope.url.href()`, so its origin matches and authorized downloads
// keep working.
// Compare (protocol, hostname, effective port) rather than the raw
// `URL.origin` slice — `origin` is a borrowed prefix of the input
// string and is not normalized for default ports, so a tarball URL of
// `https://host:443/...` would not byte-match a `.npmrc` registry of
// `https://host/...` even though they are the same origin. Some
// registries emit `dist.tarball` URLs with the default port spelled
// out; without normalization those installs lose the `Authorization`
// header and fail with 401.
let send_auth = matches!(authorization, Authorization::AllowAuthorization) && {
let tarball = URL::parse(&self.url_buf);
let registry = scope.url.url();
tarball.protocol == registry.protocol
&& tarball.hostname == registry.hostname
&& tarball.get_port_auto() == registry.get_port_auto()
// One parse decides the authority, the wire path and the `.npmrc` key; a URL it cannot settle is requested as written with no line.
let normalized = match npm::registry::normalize_tarball_url(&self.url_buf) {
Some(normalized) => {
self.url_buf = normalized;
true
}
None => false,
};

let credentials = match authorization {
Authorization::NoAuthorization => None,
Authorization::AllowAuthorization => {
pm.options
.tarball_credentials(scope, &URL::parse(&self.url_buf), normalized)
}
};

self.response_buffer = MutableString::init_empty();

let mut header_builder = HeaderBuilder::default();

if send_auth {
count_auth(&mut header_builder, scope);
}

// Registry credentials win over URL userinfo, as in npm.
let url_authorization = match url_authorization {
Some(value) if header_builder.header_count == 0 => {
// Configured credentials win over the URL's userinfo, as in npm.
let header_buf: &[u8] = match (credentials, url_authorization) {
(Some(credentials), _) => {
count_auth(&mut header_builder, credentials);
header_builder.allocate()?;
append_auth(&mut header_builder, credentials);
self.store_header_buf(&mut header_builder)
}
(None, Some(value)) => {
header_builder.count("Authorization", &value);
Some(value)
header_builder.allocate()?;
header_builder.append("Authorization", &value);
self.store_header_buf(&mut header_builder)
}
_ => None,
};

let header_buf: &'static [u8] = if header_builder.header_count > 0 {
header_builder.allocate()?;
match &url_authorization {
Some(value) => header_builder.append("Authorization", value),
None => append_auth(&mut header_builder, scope),
(None, None) => {
self.header_buf = Box::default();
b""
}
debug_assert_eq!(header_builder.content.len, header_builder.content.cap);
self.header_buf = header_builder.content.move_to_slice();
// SAFETY: `self.header_buf` outlives the request; it is freed when the slot returns to the pool.
unsafe { bun_ptr::detach_lifetime(&*self.header_buf) }
} else {
self.header_buf = Box::default();
b""
};
// SAFETY: lifetime extension. `header_buf` is `b""` or a view of the heap
// allocation `self.header_buf` owns, which is freed only when the slot returns
// to the pool, after the request completes. `AsyncHTTP::init` demands a
// `'static` borrow because the HTTP thread reads it concurrently, as for
// `url_buf` below.
let header_buf: &'static [u8] = unsafe { bun_ptr::detach_lifetime(header_buf) };

// SAFETY: lifetime extension — `url_buf` is a heap allocation owned by
// `*self`, which outlives the HTTP request. `AsyncHTTP::init` demands a
Expand Down
19 changes: 13 additions & 6 deletions src/install/PackageManager.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1382,6 +1382,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,
Expand Down Expand Up @@ -1431,6 +1432,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; } )*
Expand Down Expand Up @@ -1489,6 +1492,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<Box<BunInstall>>` borrowed via `&mut ContextData`; reborrow with
Expand Down Expand Up @@ -1922,7 +1929,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);
Expand Down Expand Up @@ -1951,18 +1958,18 @@ pub fn init(
}
}

let registry_auth = if global_len > 0 {
if global_len > 0 {
ini::load_npmrc_config(
&mut install,
&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, &bunfig_install, env, true, &[&*npmrc_local]);
}

ini::apply_registry_auth(&mut bunfig_install, &registry_auth);
overlay_bunfig_install(&mut install, bunfig_install);
ctx.install = Some(Box::new(install));
}
Expand Down
Loading
Loading