Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/pm/npmrc.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ Configuration is loaded in this order, with later sources overriding earlier one
4. `BUN_CONFIG_REGISTRY` / `NPM_CONFIG_REGISTRY` and `BUN_CONFIG_TOKEN` / `NPM_CONFIG_TOKEN` environment variables
5. Command-line flags such as `--registry`

Bun matches credentials in `.npmrc` (`//<registry>/:_authToken`, etc.) to registries by host and path, even if you set the registry URL itself in `bunfig.toml`.
Bun matches credentials in `.npmrc` (`//<registry>/:_authToken`, etc.) by host and path to the URLs it requests, so they apply no matter where you set the registry URL itself (`.npmrc`, `bunfig.toml`, an environment variable or `--registry`). Bun also uses them for tarballs a registry serves from a different host than its own, as long as that host has its own `//<host>/:_authToken` line; it never sends a registry's credentials to another host.

Values may reference environment variables. Bun replaces `${NAME}` with the variable's value, or leaves it as-is if the variable is unset. `${NAME?}` becomes an empty string if unset.

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
23 changes: 22 additions & 1 deletion src/ini/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,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};
Expand Down Expand Up @@ -1183,6 +1183,26 @@ mod draft {
RegistryCredential::Email(email) => registry.email.clone_from(email),
}
}

/// Record this line under its `//host/path/` prefix so the package
/// manager can also resolve it by request URL (see `NpmUrlAuth`).
fn apply_to_url_auth(&self, url_auth: &mut Vec<NpmUrlAuth>) {
let entry = match url_auth
.iter_mut()
.find(|entry| entry.host == self.host && entry.pathname == self.pathname)
{
Some(entry) => entry,
None => {
url_auth.push(NpmUrlAuth {
host: self.host.clone(),
pathname: self.pathname.clone(),
credentials: NpmRegistry::default(),
});
url_auth.last_mut().expect("just pushed")
}
};
self.apply_to(&mut entry.credentials);
}
}

// ──────────────────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -1594,6 +1614,7 @@ mod draft {
continue;
}
if let Some(auth) = RegistryAuth::from_config_item(conf_item, iter.log, source) {
auth.apply_to_url_auth(&mut install.url_auth);
configs.push(auth);
}
}
Expand Down
90 changes: 46 additions & 44 deletions src/install/NetworkTask.rs
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,15 @@ pub struct NetworkTask {
// into `callback`; owning avoids that at the cost of one copy per tarball download.
pub(crate) url_buf: Box<[u8]>,
pub(crate) header_buf: Box<[u8]>,
/// Whether this request was allowed to carry registry credentials at all
/// (`github:` and URL tarball dependencies are not). Read back when a
/// 401/403 is reported, so the suggested fix is one that would apply.
pub(crate) authorization: Authorization,
/// Whether a tarball request went out with an `Authorization` header from
/// any source (registry credentials, a `.npmrc` line for its host, or the
/// URL's own userinfo). A 401/403 after that is a rejection, not missing
/// configuration.
pub(crate) sent_authorization: bool,
pub(crate) retried: u16,
pub(crate) response_buffer: MutableString,
// BACKREF: PackageManager owns this task via `preallocated_network_tasks`.
Expand Down Expand Up @@ -599,6 +608,8 @@ impl NetworkTask {
}
}

self.authorization = Authorization::AllowAuthorization;

let mut header_builder = HeaderBuilder::default();

count_auth(&mut header_builder, scope);
Expand Down Expand Up @@ -722,6 +733,15 @@ impl NetworkTask {
Ok(())
}

/// Moves the fully written header block into `self.header_buf` and returns
/// the view of it that the HTTP thread reads.
fn store_header_buf(&mut self, header_builder: &mut HeaderBuilder) -> &'static [u8] {
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) }
}

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 +843,40 @@ 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()
self.authorization = authorization;
// The empty-`tarball_url` branch above built the URL from `scope.url`,
// so it is same-origin and gets the scope's credentials.
let credentials = match authorization {
Authorization::NoAuthorization => None,
Authorization::AllowAuthorization => pm
.options
.tarball_credentials(scope, &URL::parse(&self.url_buf)),
};

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: &'static [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""
};
self.sent_authorization = !header_buf.is_empty();

// SAFETY: lifetime extension — `url_buf` is a heap allocation owned by
// `*self`, which outlives the HTTP request. `AsyncHTTP::init` demands a
Expand Down Expand Up @@ -1018,6 +1018,8 @@ impl NetworkTask {
addr_of_mut!((*slot).response).write(HTTPClientResult::default());
addr_of_mut!((*slot).url_buf).write(Box::default());
addr_of_mut!((*slot).header_buf).write(Box::default());
addr_of_mut!((*slot).authorization).write(Authorization::NoAuthorization);
addr_of_mut!((*slot).sent_authorization).write(false);
addr_of_mut!((*slot).retried).write(0);
addr_of_mut!((*slot).next).write(bun_threading::Link::new());
addr_of_mut!((*slot).tarball_stream).write(None);
Expand Down
3 changes: 3 additions & 0 deletions src/install/PackageManager.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1377,6 +1377,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 @@ -1425,6 +1426,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
Loading
Loading