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
26 changes: 26 additions & 0 deletions docs/pm/npmrc.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,8 @@ Bun supports the following options:
- `_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` (accepted and ignored, as npm does)
- `certfile` (path to the client certificate for mutual TLS authentication)
- `keyfile` (path to the client private key for mutual TLS authentication)

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

Expand Down Expand Up @@ -239,6 +241,30 @@ cafile=/path/to/ca-bundle.crt

If both are set, only `cafile` is used. A `ca` or `cafile` in a later `.npmrc` or in `bunfig.toml`, or `--ca` / `--cafile` on the command line, replaces both.

### `certfile` and `keyfile`: Client certificate authentication

Configure a client certificate and private key for registries that require mutual TLS:

```ini .npmrc icon="npm"
//my-registry.example.com/:certfile=/path/to/client-cert.pem
//my-registry.example.com/:keyfile=/path/to/client-key.pem
```

Set both options in `.npmrc`; `bunfig.toml` has no equivalent certificate options. Relative paths are resolved against the directory containing the `.npmrc` file.

You can combine the certificate pair with `_authToken`:

```ini .npmrc icon="npm"
@myorg:registry=https://my-registry.example.com/
//my-registry.example.com/:certfile=/path/to/client-cert.pem
//my-registry.example.com/:keyfile=/path/to/client-key.pem
//my-registry.example.com/:_authToken=${NPM_TOKEN}
```

**Supported formats:** Certificate and key files must be in PEM format, the default and most common format. DER format is not supported.

**Security note:** Protect your `keyfile` (and any private keys) with appropriate filesystem permissions. Restrict access to the owner only (e.g., `chmod 600`), validate file ownership, avoid storing keys in version control, and prefer secure stores or OS keyrings when possible.

### `omit` and `include`: Control dependency types

Control which dependency types Bun installs:
Expand Down
10 changes: 9 additions & 1 deletion src/http/AsyncHTTP.rs
Original file line number Diff line number Diff line change
Expand Up @@ -522,6 +522,7 @@
http_proxy: Option<URL<'a>>,
proxy_settings: Option<Box<crate::ProxySettings>>,
redirect_type: FetchRedirect,
tls_props: Option<SSLConfigSharedPtr>,
) -> AsyncHTTP<'a> {
Self::init(
method,
Expand All @@ -534,6 +535,7 @@
Options {
http_proxy,
proxy_settings,
tls_props,
..Options::default()
},
)
Expand Down Expand Up @@ -723,7 +725,7 @@
// (`start_queued_task`), so any owned field that was already
// populated at that point — `request_headers`,
// `client.header_entries`, `client.proxy_headers`,
// `client.proxy_settings`, `client.tls_props` — is *shared*
// `client.proxy_settings` — is *shared*
// with the original and must NOT be dropped here; the original
// drops them when its `Box<AsyncHTTP>` is reclaimed. Only the
// state the clone built up itself during request processing is
Expand Down Expand Up @@ -769,6 +771,12 @@
client.close_proxy_tunnel(false);
debug_assert!(client.h2.is_none());
drop(core::mem::take(&mut client.custom_ssl_ctx));
// Clone-owned (`start_queued_task`); shared bits again for the copy-back.
drop(core::mem::take(&mut client.tls_props));
core::ptr::write(
&mut client.tls_props,

Check failure on line 777 in src/http/AsyncHTTP.rs

View workflow job for this annotation

GitHub Actions / cargo clippy

implicit borrow as raw pointer
core::ptr::read(&original_client.tls_props),

Check failure on line 778 in src/http/AsyncHTTP.rs

View workflow job for this annotation

GitHub Actions / cargo clippy

implicit borrow as raw pointer
);
// `state` was `Default` at `ptr::read` time and was
// populated by the clone (`on_start` → `client.start`); it
// owns the decompressor / compressed_body buffers.
Expand Down
3 changes: 3 additions & 0 deletions src/http/HTTPThread.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1145,6 +1145,9 @@ fn start_queued_task(
// SAFETY: freshly leaked; this thread is its sole owner until the request
// completes and `in_flight` gives the pointer back.
let cloned = unsafe { &mut *cloned_ptr };
// Redirects can replace TLS options, so the working copy owns a separate strong reference.
let shared_tls = core::mem::ManuallyDrop::new(cloned.async_http.client.tls_props.take());
cloned.async_http.client.tls_props = shared_tls.as_ref().cloned();
cloned.async_http.real = NonNull::new(http);
// Clear stale queue pointers - the clone inherited http.next and http.task.node.next
// which may point to other AsyncHTTP structs that could be freed before the callback
Expand Down
2 changes: 2 additions & 0 deletions src/http/InternalState.rs
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ pub struct InternalStateFlags {
pub(crate) received_last_chunk: bool,
pub(crate) did_set_content_encoding: bool,
pub(crate) is_redirect_pending: bool,
pub(crate) strip_tls_identity_on_redirect: bool,
pub(crate) is_libdeflate_fast_path_disabled: bool,
pub(crate) resend_request_body_on_redirect: bool,
/// Set when the TLS handshake completed but the user-supplied JS
Expand Down Expand Up @@ -108,6 +109,7 @@ impl InternalStateFlags {
received_last_chunk: false,
did_set_content_encoding: false,
is_redirect_pending: false,
strip_tls_identity_on_redirect: false,
is_libdeflate_fast_path_disabled: false,
resend_request_body_on_redirect: false,
is_waiting_for_cert_check: false,
Expand Down
19 changes: 18 additions & 1 deletion src/http/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ pub enum Protocol {
Http3,
}

/// Which redirects the request's credential headers survive.
/// Which redirects the request's credentials survive.
#[repr(u8)]
#[derive(Copy, Clone, PartialEq, Eq, Default)]
pub enum RedirectCredentialsPolicy {
Expand Down Expand Up @@ -2758,6 +2758,7 @@ impl<'a> HTTPClient<'a> {
self.fail(crate::Error::TooManyRedirects);
return;
}
self.strip_redirect_tls_identity();
self.state.reset();
bun_core::scoped_log!(fetch, "doRedirect state reset");
// also reset proxy to redirect
Expand All @@ -2769,6 +2770,16 @@ impl<'a> HTTPClient<'a> {
self.start(HTTPRequestBody::Bytes(request_body));
}

fn strip_redirect_tls_identity(&mut self) {
if self.state.flags.strip_tls_identity_on_redirect {
// The old socket must enter its pool with the TLS identity it was established with.
self.tls_props = self.tls_props.as_ref().map(|config| {
ssl_config::global_registry::intern(config.without_client_identity())
});
self.custom_ssl_ctx = None;
}
}

/// Re-resolve `http_proxy` against the post-redirect `self.url`. The
/// decision was made once on the JS thread at request creation; without
/// this, a redirect into a `no_proxy`-exempt host would still be sent via
Expand Down Expand Up @@ -4482,6 +4493,7 @@ impl<'a> HTTPClient<'a> {
self.fail(crate::Error::TooManyRedirects);
return;
}
self.strip_redirect_tls_identity();
self.state.reset();
self.flags.proxy_tunneling = false;
self.flags.protocol = Protocol::Http1_1;
Expand Down Expand Up @@ -5340,6 +5352,11 @@ impl<'a> HTTPClient<'a> {
}
}
}
// The client certificate follows the credential headers under the install policy.
self.state.flags.strip_tls_identity_on_redirect = !hop.same_origin
&& !hop.https_upgrade
&& self.flags.redirect_credentials == RedirectCredentialsPolicy::HttpsUpgrade
&& self.tls_props.is_some();
self.state.flags.is_redirect_pending = true;
if self.method.has_request_body() {
self.state.flags.resend_request_body_on_redirect = true;
Expand Down
10 changes: 10 additions & 0 deletions src/http/ssl_config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,16 @@ impl SSLConfig {
copy
}

pub(crate) fn without_client_identity(&self) -> SSLConfig {
let mut config = self.clone();
free_string(&mut config.cert_file_name);
free_string(&mut config.key_file_name);
free_string(&mut config.passphrase);
free_strings(&mut config.cert);
free_strings(&mut config.key);
config
}

pub(crate) fn is_same(&self, other: &SSLConfig) -> bool {
macro_rules! eq_cstr {
($f:ident) => {
Expand Down
44 changes: 19 additions & 25 deletions src/ini/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1260,23 +1260,30 @@ mod draft {
decoded.into_boxed_slice()
}

/// Follows the order of npm's `hasAuth`, without `certfile` and `keyfile` (no mTLS), which
/// would stop the walk.
/// Follows the order of npm's `hasAuth`; a complete certificate pair also stops the walk.
fn credentials_of_key(configs: &[ConfigItem], key: &[u8]) -> Option<NpmRegistry> {
// npm's `opts[k]` truthiness test, applied after the collapse, so a later `username=`
// clears an earlier one.
let truthy =
|opt| Some(&lookup(configs, key, opt)?.value).filter(|value| !value.is_empty());
let mut credentials = NpmRegistry::default();
if let (Some(certfile), Some(keyfile)) =
(truthy(ConfigOpt::Certfile), truthy(ConfigOpt::Keyfile))
{
credentials.certfile.clone_from(certfile);
credentials.keyfile.clone_from(keyfile);
}
if let Some(token) = truthy(ConfigOpt::_AuthToken) {
credentials.token.clone_from(token);
} else if let Some(auth) = truthy(ConfigOpt::_Auth) {
credentials.auth.clone_from(auth);
} else {
credentials
.username
.clone_from(truthy(ConfigOpt::Username)?);
credentials.password = decode_password(truthy(ConfigOpt::_Password)?);
} else if let (Some(username), Some(password)) =
(truthy(ConfigOpt::Username), truthy(ConfigOpt::_Password))
{
credentials.username.clone_from(username);
credentials.password = decode_password(password);
} else if credentials.certfile.is_empty() {
return None;
}
Some(credentials)
}
Expand Down Expand Up @@ -1574,28 +1581,15 @@ mod draft {
}
continue;
};
if matches!(optname, ConfigOpt::Certfile | ConfigOpt::Keyfile) {
// `put` stamps the key with the value's loc, so walk back over `<key>=` to the option name.
let optname_loc = match keyexpr.loc.to_nullable() {
Some(loc) => Loc {
start: loc.start - i32::try_from(key.len() - colon).expect("int cast"),
},
None => keyexpr.loc,
};
bun_ast::add_warning_pretty!(
log,
Some(source),
optname_loc,
"<b>{}<r> is not supported; ignoring this .npmrc option",
<&'static str>::from(optname),
);
continue;
}
// Credentials are resolved later, once, over the lines of every `.npmrc`.
configs.push(ConfigItem {
registry_url: normalize_key(&key[2..colon]),
optname,
value: value.into(),
value: if matches!(optname, ConfigOpt::Certfile | ConfigOpt::Keyfile) {
BunInstall::resolve_path(dir, value)
} else {
value.into()
},
loc: keyexpr.loc,
source_idx,
});
Expand Down
10 changes: 10 additions & 0 deletions src/install/NetworkTask.rs
Original file line number Diff line number Diff line change
Expand Up @@ -731,6 +731,7 @@ impl NetworkTask {
http::FetchRedirect::Follow,
AsyncHTTPOptions {
http_proxy,
tls_props: pm.registry_tls_config(scope),
proxy_settings: pm.proxy_settings(),
idle_timeout_seconds: pm.idle_timeout_seconds(),
..Default::default()
Expand Down Expand Up @@ -921,8 +922,17 @@ impl NetworkTask {
// the identical pattern in `for_manifest` above.
let url = URL::parse(unsafe { bun_ptr::detach_lifetime(&self.url_buf) });

let tls_props = match authorization {
Authorization::AllowAuthorization => pm
.options
.tarball_credentials(scope, &url, normalized)
.and_then(|registry| pm.registry_tls_config(registry)),
Authorization::NoAuthorization => None,
};

let mut http_options = AsyncHTTPOptions {
http_proxy: pm.http_proxy(&url),
tls_props,
proxy_settings: pm.proxy_settings(),
idle_timeout_seconds: pm.idle_timeout_seconds(),
..Default::default()
Expand Down
32 changes: 32 additions & 0 deletions src/install/PackageManager.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1147,6 +1147,36 @@ impl PackageManager {
.then_some(30)
}

pub fn registry_tls_config(
&self,
registry: &crate::npm::registry::Scope,
) -> Option<http::ssl_config::SharedPtr> {
if registry.certfile.is_empty() && registry.keyfile.is_empty() {
return None;
}
let mut config = http::ssl_config::SSLConfig::default();
config.requires_custom_request_ctx = true;
if !registry.certfile.is_empty() {
config.cert_file_name = bun_core::dupe_z(&registry.certfile);
}
if !registry.keyfile.is_empty() {
config.key_file_name = bun_core::dupe_z(&registry.keyfile);
}
if let Some(cafile) = holder::ABS_CA_FILE_NAME.get() {
config.ca_file_name = bun_core::dupe_z(cafile.as_bytes());
}
if !self.options.ca.is_empty() {
config.ca = Some(
self.options
.ca
.iter()
.map(|ca| bun_core::dupe_z(ca))
.collect(),
);
}
Some(http::ssl_config::global_registry::intern(config))
}

/// A blocking request, with the proxy and the certificate check the environment asks for.
/// Send it with `send_with_retries`, unless the registry must not receive the request twice.
pub fn registry_request<'a>(
Expand All @@ -1156,6 +1186,7 @@ impl PackageManager {
headers: http::headers::EntryList,
headers_buf: &'a [u8],
body: &'a [u8],
tls_props: Option<http::ssl_config::SharedPtr>,
) -> http::AsyncHTTP<'a> {
let http_proxy = self.http_proxy(&url);
let mut req = http::AsyncHTTP::init_sync(
Expand All @@ -1167,6 +1198,7 @@ impl PackageManager {
http_proxy,
self.proxy_settings(),
http::FetchRedirect::Follow,
tls_props,
);
req.client.flags.reject_unauthorized = self.tls_reject_unauthorized();
req.client.flags.redirect_credentials = http::RedirectCredentialsPolicy::HttpsUpgrade;
Expand Down
9 changes: 7 additions & 2 deletions src/install/PackageManager/PackageManagerOptions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -336,8 +336,13 @@ impl Options {
fn fill_scope_from_url_auth(url_auth: &[Npm::registry::UrlAuth], scope: &mut Npm::registry::Scope) {
let own = Npm::registry::UrlAuth::find_entry(url_auth, &scope.url.url());
scope.url_auth_key = own.map(|entry| entry.key.clone());
if let Some(entry) = own.filter(|_| !scope.has_credentials() || scope.declared_in_npmrc) {
scope.copy_credentials_from(&entry.credentials);
if let Some(entry) = own {
if !scope.has_credentials() || scope.declared_in_npmrc {
scope.copy_credentials_from(&entry.credentials);
} else {
scope.certfile.clone_from(&entry.credentials.certfile);
scope.keyfile.clone_from(&entry.credentials.keyfile);
}
}
}

Expand Down
20 changes: 17 additions & 3 deletions src/install/npm.rs
Original file line number Diff line number Diff line change
Expand Up @@ -96,8 +96,14 @@ pub fn whoami(manager: &mut PackageManager) -> Result<Vec<u8>, WhoamiError> {
let url = URL::parse(&print_buf);
let header_buf: &[u8] = headers.content.written_slice();

let mut req =
manager.registry_request(http::Method::GET, url, headers.entries, header_buf, b"");
let mut req = manager.registry_request(
http::Method::GET,
url,
headers.entries,
header_buf,
b"",
manager.registry_tls_config(registry),
);

let res = match manager.send_with_retries(&mut req, &mut response_buf) {
Ok(res) => res,
Expand Down Expand Up @@ -251,6 +257,8 @@ pub mod registry {

/// The `.npmrc` key this registry's URL resolves to, once every source was applied.
pub(crate) url_auth_key: Option<Box<[u8]>>,
pub certfile: Box<[u8]>,
pub keyfile: Box<[u8]>,
}

/// After `:` in yarn's shape (`/api/:_authToken=T`), after `/` in JFrog's (`/npm/_auth=<b64>`).
Expand Down Expand Up @@ -383,13 +391,17 @@ pub mod registry {
}

pub(crate) fn has_credentials(&self) -> bool {
!self.token.is_empty() || !self.auth.is_empty()
!self.token.is_empty()
|| !self.auth.is_empty()
|| (!self.certfile.is_empty() && !self.keyfile.is_empty())
}

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.certfile.clone_from(&other.certfile);
self.keyfile.clone_from(&other.keyfile);
self.declared_in_npmrc = other.declared_in_npmrc;
}

Expand Down Expand Up @@ -577,6 +589,8 @@ pub mod registry {
token: registry.token,
auth,
user,
certfile: registry.certfile,
keyfile: registry.keyfile,
declared_in_npmrc: registry.declared_in_npmrc,
..Default::default()
};
Expand Down
Loading
Loading