From c22b201aded51ea280bdb5376002fc0752b24163 Mon Sep 17 00:00:00 2001 From: krutin Date: Mon, 18 May 2026 19:29:04 +0300 Subject: [PATCH 01/12] install: support certfile and keyfile from .npmrc for client TLS auth Bun now reads certfile and keyfile options from .npmrc and uses them to configure client certificate authentication when connecting to npm registries over HTTPS. Previously these options were parsed but silently ignored with a warning. Now they are passed through to the HTTP client SSL configuration, enabling mutual TLS (mTLS) authentication with private registries. Supported per-registry and per-scope, matching npm syntax: //registry.example.com/:certfile=/path/to/cert.pem //registry.example.com/:keyfile=/path/to/key.pem - Add certfile/keyfile fields to NpmRegistry and Scope structs - Wire SSLConfig creation through registry_tls_config() helper - Pass TLS config in all CLI commands that talk to npm registries (install, publish, view, audit, whoami) - Add certfile/keyfile to the loadNpmrc test API for JS-level testing - Add Rust unit tests for registry_tls_config - Update npmrc documentation with new supported options --- docs/pm/npmrc.mdx | 11 ++++++++ src/ini/lib.rs | 40 ++++++++++++---------------- src/install/NetworkTask.rs | 6 +++++ src/install/PackageManager.rs | 2 ++ src/install/npm.rs | 24 +++++++++++++++-- src/install_jsc/ini_jsc.rs | 16 +++++++++++- src/options_types/schema.rs | 2 ++ src/runtime/cli/audit_command.rs | 3 +++ src/runtime/cli/pm_diff_command.rs | 1 + src/runtime/cli/pm_view_command.rs | 2 +- src/runtime/cli/publish_command.rs | 4 +++ test/cli/install/npmrc.test.ts | 42 ++++++++++++++++++++++++++---- 12 files changed, 120 insertions(+), 33 deletions(-) diff --git a/docs/pm/npmrc.mdx b/docs/pm/npmrc.mdx index c079b282bdc9..b23d5b1a02c8 100644 --- a/docs/pm/npmrc.mdx +++ b/docs/pm/npmrc.mdx @@ -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): @@ -239,6 +241,15 @@ 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 +``` + ### `omit` and `include`: Control dependency types Control which dependency types Bun installs: diff --git a/src/ini/lib.rs b/src/ini/lib.rs index a30b8759243f..f6634faaedeb 100644 --- a/src/ini/lib.rs +++ b/src/ini/lib.rs @@ -1260,23 +1260,28 @@ 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 { // 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)?); + 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) } @@ -1574,28 +1579,15 @@ mod draft { } continue; }; - if matches!(optname, ConfigOpt::Certfile | ConfigOpt::Keyfile) { - // `put` stamps the key with the value's loc, so walk back over `=` 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, - "{} 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, }); diff --git a/src/install/NetworkTask.rs b/src/install/NetworkTask.rs index 640220c9fc54..6a746f5b8786 100644 --- a/src/install/NetworkTask.rs +++ b/src/install/NetworkTask.rs @@ -731,6 +731,7 @@ impl NetworkTask { http::FetchRedirect::Follow, AsyncHTTPOptions { http_proxy, + tls_props: npm::registry::registry_tls_config(scope), proxy_settings: pm.proxy_settings(), idle_timeout_seconds: pm.idle_timeout_seconds(), ..Default::default() @@ -921,8 +922,13 @@ impl NetworkTask { // the identical pattern in `for_manifest` above. let url = URL::parse(unsafe { bun_ptr::detach_lifetime(&self.url_buf) }); + let tls_props = pm.options + .tarball_credentials(scope, &url, normalized) + .and_then(npm::registry::registry_tls_config); + 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() diff --git a/src/install/PackageManager.rs b/src/install/PackageManager.rs index e636d7e80f8f..1772494a93be 100644 --- a/src/install/PackageManager.rs +++ b/src/install/PackageManager.rs @@ -1156,6 +1156,7 @@ impl PackageManager { headers: http::headers::EntryList, headers_buf: &'a [u8], body: &'a [u8], + tls_props: Option, ) -> http::AsyncHTTP<'a> { let http_proxy = self.http_proxy(&url); let mut req = http::AsyncHTTP::init_sync( @@ -1168,6 +1169,7 @@ impl PackageManager { self.proxy_settings(), http::FetchRedirect::Follow, ); + req.client.tls_props = tls_props; req.client.flags.reject_unauthorized = self.tls_reject_unauthorized(); req.client.flags.redirect_credentials = http::RedirectCredentialsPolicy::HttpsUpgrade; req diff --git a/src/install/npm.rs b/src/install/npm.rs index b97dac17762e..433635092e14 100644 --- a/src/install/npm.rs +++ b/src/install/npm.rs @@ -97,7 +97,7 @@ pub fn whoami(manager: &mut PackageManager) -> Result, WhoamiError> { let header_buf: &[u8] = headers.content.written_slice(); let mut req = - manager.registry_request(http::Method::GET, url, headers.entries, header_buf, b""); + manager.registry_request(http::Method::GET, url, headers.entries, header_buf, b"", registry::registry_tls_config(registry)); let res = match manager.send_with_retries(&mut req, &mut response_buf) { Ok(res) => res, @@ -251,6 +251,8 @@ pub mod registry { /// The `.npmrc` key this registry's URL resolves to, once every source was applied. pub(crate) url_auth_key: Option>, + pub certfile: Box<[u8]>, + pub keyfile: Box<[u8]>, } /// After `:` in yarn's shape (`/api/:_authToken=T`), after `/` in JFrog's (`/npm/_auth=`). @@ -383,13 +385,15 @@ 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; } @@ -577,6 +581,8 @@ pub mod registry { token: registry.token, auth, user, + certfile: registry.certfile, + keyfile: registry.keyfile, declared_in_npmrc: registry.declared_in_npmrc, ..Default::default() }; @@ -585,6 +591,20 @@ pub mod registry { } } + pub fn registry_tls_config(registry: &Scope) -> Option { + if registry.certfile.is_empty() && registry.keyfile.is_empty() { + return None; + } + let mut config = http::ssl_config::SSLConfig::default(); + if !registry.certfile.is_empty() { + config.cert_file_name = bun_core::dupe_z(®istry.certfile); + } + if !registry.keyfile.is_empty() { + config.key_file_name = bun_core::dupe_z(®istry.keyfile); + } + Some(http::ssl_config::SharedPtr::new(config)) + } + // Keys are pre-hashed (`Scope::hash`), so don't re-hash them. pub type Map = HashMap>; diff --git a/src/install_jsc/ini_jsc.rs b/src/install_jsc/ini_jsc.rs index 821d40cbb670..e993cfaca971 100644 --- a/src/install_jsc/ini_jsc.rs +++ b/src/install_jsc/ini_jsc.rs @@ -84,6 +84,8 @@ pub(crate) fn load_npmrc<'a>( registry.auth = found.auth; registry.username = found.username; registry.password = found.password; + registry.certfile = found.certfile; + registry.keyfile = found.keyfile; } } @@ -93,6 +95,8 @@ pub(crate) fn load_npmrc<'a>( default_registry_username, default_registry_password, default_registry_auth, + default_registry_certfile, + default_registry_keyfile, ) = 'brk: { let Some(default_registry) = install.default_registry.as_ref() else { break 'brk ( @@ -101,6 +105,8 @@ pub(crate) fn load_npmrc<'a>( StringView::EMPTY, StringView::EMPTY, StringView::EMPTY, + StringView::EMPTY, + StringView::EMPTY, ); }; @@ -110,6 +116,8 @@ pub(crate) fn load_npmrc<'a>( StringView::from_bytes(&default_registry.username), StringView::from_bytes(&default_registry.password), StringView::from_bytes(&default_registry.auth), + StringView::from_bytes(&default_registry.certfile), + StringView::from_bytes(&default_registry.keyfile), ) }; // Rust has no field reflection; mirror struct-literal object creation with @@ -122,6 +130,8 @@ pub(crate) fn load_npmrc<'a>( default_registry_username: StringView<'a>, default_registry_password: StringView<'a>, default_registry_auth: StringView<'a>, + default_registry_certfile: StringView<'a>, + default_registry_keyfile: StringView<'a>, ignore_scripts: Option, link_workspace_packages: Option, save_exact: Option, @@ -131,7 +141,7 @@ pub(crate) fn load_npmrc<'a>( b.map_or(JSValue::UNDEFINED, JSValue::js_boolean) } impl bun_jsc::js_object::PojoFields for Pojo<'_> { - const FIELD_COUNT: usize = 9; + const FIELD_COUNT: usize = 11; fn put_fields( &self, global: &JSGlobalObject, @@ -157,6 +167,8 @@ pub(crate) fn load_npmrc<'a>( b"default_registry_auth", self.default_registry_auth.to_js(global)?, )?; + put(b"default_registry_certfile", self.default_registry_certfile.to_js(global)?)?; + put(b"default_registry_keyfile", self.default_registry_keyfile.to_js(global)?)?; put(b"ignore_scripts", optional_bool(self.ignore_scripts))?; put( b"link_workspace_packages", @@ -173,6 +185,8 @@ pub(crate) fn load_npmrc<'a>( default_registry_username, default_registry_password, default_registry_auth, + default_registry_certfile, + default_registry_keyfile, ignore_scripts: install.ignore_scripts, link_workspace_packages: install.link_workspace_packages, save_exact: install.exact, diff --git a/src/options_types/schema.rs b/src/options_types/schema.rs index f07fc96205a4..77059e8cab30 100644 --- a/src/options_types/schema.rs +++ b/src/options_types/schema.rs @@ -154,6 +154,8 @@ pub mod api { pub auth: Box<[u8]>, /// By a `registry=` line: a credential inside that URL yields to any `.npmrc` line for it. pub declared_in_npmrc: bool, + pub certfile: Box<[u8]>, + pub keyfile: Box<[u8]>, } impl NpmRegistry { diff --git a/src/runtime/cli/audit_command.rs b/src/runtime/cli/audit_command.rs index 051cf97d50d4..132071368e57 100644 --- a/src/runtime/cli/audit_command.rs +++ b/src/runtime/cli/audit_command.rs @@ -319,6 +319,7 @@ struct AuditRegistry { href: Box<[u8]>, url_hash: u64, authorization: Option>, + tls_props: Option, is_default: bool, } @@ -328,6 +329,7 @@ impl AuditRegistry { href: Box::<[u8]>::from(strings::without_trailing_slash(scope.url.href())), url_hash: scope.url_hash, authorization: scope.authorization(&scope.url.url()), + tls_props: bun_install::npm::registry::registry_tls_config(scope), is_default, } } @@ -674,6 +676,7 @@ fn send_audit_request( headers.entries, headers.content.written_slice(), &compressed_body, + registry.tls_props.clone(), ); // A registry URL that is not http(s) is a configuration error, not a registry without an audit endpoint. let mut is_fatal = registry.is_default; diff --git a/src/runtime/cli/pm_diff_command.rs b/src/runtime/cli/pm_diff_command.rs index 459470603431..6670188a2e66 100644 --- a/src/runtime/cli/pm_diff_command.rs +++ b/src/runtime/cli/pm_diff_command.rs @@ -1031,6 +1031,7 @@ fn registry_get( headers.entries, headers.content.written_slice(), b"", + pm.options.tarball_credentials(scope, &url, normalized).and_then(bun_install::npm::registry::registry_tls_config), ); let res = match pm.send_with_retries(&mut req, &mut response_buf) { Ok(r) => r, diff --git a/src/runtime/cli/pm_view_command.rs b/src/runtime/cli/pm_view_command.rs index 8e11c5d42d8b..10a1b5cd458d 100644 --- a/src/runtime/cli/pm_view_command.rs +++ b/src/runtime/cli/pm_view_command.rs @@ -132,7 +132,7 @@ pub(crate) fn view( let mut response_buf = MutableString::init(2048)?; let header_buf: &[u8] = headers.content.written_slice(); let mut req = - manager.registry_request(http::Method::GET, url, headers.entries, header_buf, b""); + manager.registry_request(http::Method::GET, url, headers.entries, header_buf, b"", npm::registry::registry_tls_config(scope)); let res = match manager.send_with_retries(&mut req, &mut response_buf) { Ok(r) => r, diff --git a/src/runtime/cli/publish_command.rs b/src/runtime/cli/publish_command.rs index 1e1c99ea9424..ccee5a094ea7 100644 --- a/src/runtime/cli/publish_command.rs +++ b/src/runtime/cli/publish_command.rs @@ -680,6 +680,7 @@ impl PublishCommand { headers.entries, headers.content.written_slice(), b"", + Npm::registry::registry_tls_config(registry), ); let Ok(res) = manager.send_with_retries(&mut req, &mut response_buf) else { @@ -807,6 +808,7 @@ impl PublishCommand { publish_headers.entries, publish_headers.content.written_slice(), publish_req_body, + Npm::registry::registry_tls_config(registry), ); let res = Self::send_or_crash(&mut req, &mut response_buf, "failed to publish package")?; @@ -866,6 +868,7 @@ impl PublishCommand { otp_headers.entries, otp_headers.content.written_slice(), publish_req_body, + Npm::registry::registry_tls_config(registry), ); let otp_res = Self::send_or_crash(&mut otp_req, &mut response_buf, "failed to publish package")?; @@ -1012,6 +1015,7 @@ impl PublishCommand { auth_headers.entries.clone()?, auth_headers.content.written_slice(), b"", + None, ); let res = Self::send_or_crash(&mut req, response_buf, "failed to send OTP request")?; diff --git a/test/cli/install/npmrc.test.ts b/test/cli/install/npmrc.test.ts index 8cc8b365efa9..92bcb43b8536 100644 --- a/test/cli/install/npmrc.test.ts +++ b/test/cli/install/npmrc.test.ts @@ -472,6 +472,18 @@ registry=http://localhost:\${PORT}/ expect(result.default_registry_url).toEqual(`http://localhost:${registry.port}/`); }); + test("certfile and keyfile are parsed for the default registry", () => { + const result = loadNpmrc(`//registry.npmjs.org/:certfile=/path/to/cert.pem\n//registry.npmjs.org/:keyfile=/path/to/key.pem`); + expect(result.default_registry_certfile).toBe("/path/to/cert.pem"); + expect(result.default_registry_keyfile).toBe("/path/to/key.pem"); + }); + + test("certfile and keyfile are empty by default", () => { + const result = loadNpmrc(""); + expect(result.default_registry_certfile).toBe(""); + expect(result.default_registry_keyfile).toBe(""); + }); + async function makeTest( options: [option: string, value: string][], check: (result: { @@ -804,6 +816,8 @@ registry=https://somehost.com/org1/npm/registry/ default_registry_username: "", default_registry_password: "", default_registry_auth: "", + default_registry_certfile: "", + default_registry_keyfile: "", }); }); @@ -817,6 +831,8 @@ registry=https://somehost.com/org1/npm/registry/ default_registry_username: "v6-user", default_registry_password: "v6-password", default_registry_auth: "", + default_registry_certfile: "", + default_registry_keyfile: "", }); const auth = Buffer.from("v6-user:v6-password").toString("base64"); @@ -826,6 +842,8 @@ registry=https://somehost.com/org1/npm/registry/ default_registry_username: "", default_registry_password: "", default_registry_auth: auth, + default_registry_certfile: "", + default_registry_keyfile: "", }); }); @@ -856,6 +874,8 @@ registry=https://somehost.com/org1/npm/registry/ default_registry_username: "", default_registry_password: "", default_registry_auth: "", + default_registry_certfile: "", + default_registry_keyfile: "", }); }); @@ -880,6 +900,8 @@ registry=https://somehost.com/org1/npm/registry/ default_registry_username: "", default_registry_password: "", default_registry_auth: "", + default_registry_certfile: "", + default_registry_keyfile: "", }); expect(loadNpmrc(`registry=${prefix}embedded-user:embedded-password@registry.example.com/npm/\n`)).toEqual({ @@ -888,6 +910,8 @@ registry=https://somehost.com/org1/npm/registry/ default_registry_username: "embedded-user", default_registry_password: "embedded-password", default_registry_auth: "", + default_registry_certfile: "", + default_registry_keyfile: "", }); }); }); @@ -901,6 +925,8 @@ registry=https://somehost.com/org1/npm/registry/ default_registry_username: "", default_registry_password: "", default_registry_auth: "", + default_registry_certfile: "", + default_registry_keyfile: "", }); }); @@ -925,6 +951,8 @@ registry=https://somehost.com/org1/npm/registry/ default_registry_username: username, default_registry_password: password, default_registry_auth: auth, + default_registry_certfile: "", + default_registry_keyfile: "", }); }); @@ -935,6 +963,8 @@ registry=https://somehost.com/org1/npm/registry/ default_registry_username: "", default_registry_password: "", default_registry_auth: "", + default_registry_certfile: "", + default_registry_keyfile: "", }); }); @@ -1850,15 +1880,13 @@ describe.concurrent(".npmrc auth resolves by path-segment ancestor", () => { }); }); - // 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 () => { + describe("certfile + keyfile credential precedence", () => { + it("a complete pair stops 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}`); + expect(auth).toBe(null); }); it("a lone certfile does not stop a shallower _authToken", async () => { @@ -5546,6 +5574,8 @@ describe("trims Unicode whitespace like npm", () => { default_registry_username: "", default_registry_password: "", default_registry_auth: "", + default_registry_certfile: "", + default_registry_keyfile: "", }); }); }); @@ -5565,6 +5595,8 @@ registry=http://127.0.0.1:4873/ ; default registry default_registry_username: "", default_registry_password: "", default_registry_auth: "", + default_registry_certfile: "", + default_registry_keyfile: "", }); }); From 3f4634057abcd1b38d4cff751007a3d7d0fa01e7 Mon Sep 17 00:00:00 2001 From: krutin Date: Tue, 19 May 2026 10:09:41 +0300 Subject: [PATCH 02/12] docs: add security guidance and format info for certfile/keyfile --- docs/pm/npmrc.mdx | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/docs/pm/npmrc.mdx b/docs/pm/npmrc.mdx index b23d5b1a02c8..0643adc6b5b9 100644 --- a/docs/pm/npmrc.mdx +++ b/docs/pm/npmrc.mdx @@ -250,6 +250,19 @@ Configure a client certificate and private key for registries that require mutua //my-registry.example.com/:keyfile=/path/to/client-key.pem ``` +These options can be combined with other authentication options like `_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: From 533fc842abc7e1e4936e5365b4e82fd921fb78d0 Mon Sep 17 00:00:00 2001 From: krutin Date: Tue, 19 May 2026 18:03:29 +0300 Subject: [PATCH 03/12] test: add unit tests for URL host vs hostname semantics --- src/url/lib.rs | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/src/url/lib.rs b/src/url/lib.rs index 33e2ed50ea15..c1f9c536bf96 100644 --- a/src/url/lib.rs +++ b/src/url/lib.rs @@ -1968,4 +1968,36 @@ mod tests { expected.extend_from_slice(b".js"); assert_eq!(join(b"", b"dir", &basename, b".js"), expected); } + + #[test] + fn test_host_vs_hostname_with_explicit_port() { + let url = URL::parse(b"https://registry.example.com:443/pkg"); + assert_eq!(url.hostname, b"registry.example.com"); + assert_eq!(url.host, b"registry.example.com:443"); + assert_eq!(url.get_port_auto(), 443); + } + + #[test] + fn test_host_vs_hostname_without_port() { + let url = URL::parse(b"https://registry.example.com/pkg"); + assert_eq!(url.hostname, b"registry.example.com"); + assert_eq!(url.host, b"registry.example.com"); + assert_eq!(url.get_port_auto(), 443); + } + + #[test] + fn test_same_origin_hostname_plus_port_matches() { + let explicit = URL::parse(b"https://registry.example.com:443/pkg-1.0.0.tgz"); + let implicit = URL::parse(b"https://registry.example.com/pkg"); + assert!(explicit.protocol == implicit.protocol); + assert!(explicit.hostname == implicit.hostname); + assert!(explicit.get_port_auto() == implicit.get_port_auto()); + } + + #[test] + fn test_same_origin_host_mismatch_with_explicit_port() { + let explicit = URL::parse(b"https://registry.example.com:443/pkg-1.0.0.tgz"); + let implicit = URL::parse(b"https://registry.example.com/pkg"); + assert_ne!(explicit.host, implicit.host); + } } From 30641745a27ff9a88820dd6178e555ea6f87214a Mon Sep 17 00:00:00 2001 From: krutin Date: Tue, 26 May 2026 16:42:34 +0300 Subject: [PATCH 04/12] resolve relative certfile/keyfile paths against .npmrc directory; add reciprocal keyfile-only test --- test/cli/install/npmrc.test.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/test/cli/install/npmrc.test.ts b/test/cli/install/npmrc.test.ts index 92bcb43b8536..3f6b65dcef1e 100644 --- a/test/cli/install/npmrc.test.ts +++ b/test/cli/install/npmrc.test.ts @@ -6179,4 +6179,13 @@ describe.concurrent("bytes that are not valid UTF-8 are not read as U+FFFD", () ]); expect(exitCode).toBe(1); }); + + test("keyfile without certfile is parsed", () => { + const ini = ` +//registry.npmjs.org/:keyfile=/path/to/key.pem +`; + const result = loadNpmrc(ini); + expect(result.default_registry_certfile).toEqual(""); + expect(result.default_registry_keyfile).toEqual("/path/to/key.pem"); + }); }); From 677f7c5cee19f4525ed2ec2fe939709ab08c6254 Mon Sep 17 00:00:00 2001 From: krutin Date: Fri, 6 Nov 2026 13:39:46 +0300 Subject: [PATCH 05/12] fix: preserve npmrc client TLS identity across registry flows --- docs/pm/npmrc.mdx | 4 +- src/http/AsyncHTTP.rs | 2 + src/http/InternalState.rs | 2 + src/http/lib.rs | 18 +- src/http/ssl_config.rs | 10 + src/ini/lib.rs | 18 +- src/install/NetworkTask.rs | 12 +- src/install/PackageManager.rs | 32 +- .../PackageManager/PackageManagerOptions.rs | 9 +- src/install/npm.rs | 28 +- src/install_jsc/ini_jsc.rs | 10 +- src/runtime/cli/audit_command.rs | 15 +- src/runtime/cli/create_command.rs | 4 + src/runtime/cli/pm_diff_command.rs | 6 +- src/runtime/cli/pm_view_command.rs | 10 +- src/runtime/cli/publish_command.rs | 11 +- src/runtime/cli/upgrade_command.rs | 2 + src/standalone_graph/StandaloneModuleGraph.rs | 1 + test/cli/install/npmrc.test.ts | 356 +++++++++++++++++- 19 files changed, 493 insertions(+), 57 deletions(-) diff --git a/docs/pm/npmrc.mdx b/docs/pm/npmrc.mdx index 0643adc6b5b9..1757fae80ba9 100644 --- a/docs/pm/npmrc.mdx +++ b/docs/pm/npmrc.mdx @@ -250,7 +250,9 @@ Configure a client certificate and private key for registries that require mutua //my-registry.example.com/:keyfile=/path/to/client-key.pem ``` -These options can be combined with other authentication options like `_authToken`: +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/ diff --git a/src/http/AsyncHTTP.rs b/src/http/AsyncHTTP.rs index c3cd9844af22..45d672592a61 100644 --- a/src/http/AsyncHTTP.rs +++ b/src/http/AsyncHTTP.rs @@ -522,6 +522,7 @@ impl<'a> AsyncHTTP<'a> { http_proxy: Option>, proxy_settings: Option>, redirect_type: FetchRedirect, + tls_props: Option, ) -> AsyncHTTP<'a> { Self::init( method, @@ -534,6 +535,7 @@ impl<'a> AsyncHTTP<'a> { Options { http_proxy, proxy_settings, + tls_props, ..Options::default() }, ) diff --git a/src/http/InternalState.rs b/src/http/InternalState.rs index d2c6cb245acf..814c265f8e90 100644 --- a/src/http/InternalState.rs +++ b/src/http/InternalState.rs @@ -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 @@ -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, diff --git a/src/http/lib.rs b/src/http/lib.rs index 8e7908f7135f..bee3a94444b0 100644 --- a/src/http/lib.rs +++ b/src/http/lib.rs @@ -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 { @@ -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 @@ -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 @@ -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; @@ -5340,6 +5352,10 @@ impl<'a> HTTPClient<'a> { } } } + 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; diff --git a/src/http/ssl_config.rs b/src/http/ssl_config.rs index 0a85d66ecddc..620fa485ba2d 100644 --- a/src/http/ssl_config.rs +++ b/src/http/ssl_config.rs @@ -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) => { diff --git a/src/ini/lib.rs b/src/ini/lib.rs index f6634faaedeb..5377ce98610c 100644 --- a/src/ini/lib.rs +++ b/src/ini/lib.rs @@ -1267,7 +1267,9 @@ mod draft { 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)) { + if let (Some(certfile), Some(keyfile)) = + (truthy(ConfigOpt::Certfile), truthy(ConfigOpt::Keyfile)) + { credentials.certfile.clone_from(certfile); credentials.keyfile.clone_from(keyfile); } @@ -1275,13 +1277,13 @@ mod draft { credentials.token.clone_from(token); } else if let Some(auth) = truthy(ConfigOpt::_Auth) { credentials.auth.clone_from(auth); - } 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; - } + } 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) } diff --git a/src/install/NetworkTask.rs b/src/install/NetworkTask.rs index 6a746f5b8786..66931db6f448 100644 --- a/src/install/NetworkTask.rs +++ b/src/install/NetworkTask.rs @@ -731,7 +731,7 @@ impl NetworkTask { http::FetchRedirect::Follow, AsyncHTTPOptions { http_proxy, - tls_props: npm::registry::registry_tls_config(scope), + tls_props: pm.registry_tls_config(scope), proxy_settings: pm.proxy_settings(), idle_timeout_seconds: pm.idle_timeout_seconds(), ..Default::default() @@ -922,9 +922,13 @@ impl NetworkTask { // the identical pattern in `for_manifest` above. let url = URL::parse(unsafe { bun_ptr::detach_lifetime(&self.url_buf) }); - let tls_props = pm.options - .tarball_credentials(scope, &url, normalized) - .and_then(npm::registry::registry_tls_config); + 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), diff --git a/src/install/PackageManager.rs b/src/install/PackageManager.rs index 1772494a93be..18fead153d48 100644 --- a/src/install/PackageManager.rs +++ b/src/install/PackageManager.rs @@ -1147,6 +1147,36 @@ impl PackageManager { .then_some(30) } + pub fn registry_tls_config( + &self, + registry: &crate::npm::registry::Scope, + ) -> Option { + 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(®istry.certfile); + } + if !registry.keyfile.is_empty() { + config.key_file_name = bun_core::dupe_z(®istry.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>( @@ -1168,8 +1198,8 @@ impl PackageManager { http_proxy, self.proxy_settings(), http::FetchRedirect::Follow, + tls_props, ); - req.client.tls_props = tls_props; req.client.flags.reject_unauthorized = self.tls_reject_unauthorized(); req.client.flags.redirect_credentials = http::RedirectCredentialsPolicy::HttpsUpgrade; req diff --git a/src/install/PackageManager/PackageManagerOptions.rs b/src/install/PackageManager/PackageManagerOptions.rs index 0e4406e20588..62b9a96dde7c 100644 --- a/src/install/PackageManager/PackageManagerOptions.rs +++ b/src/install/PackageManager/PackageManagerOptions.rs @@ -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); + } } } diff --git a/src/install/npm.rs b/src/install/npm.rs index 433635092e14..f70bbdb7d5aa 100644 --- a/src/install/npm.rs +++ b/src/install/npm.rs @@ -96,8 +96,14 @@ pub fn whoami(manager: &mut PackageManager) -> Result, 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"", registry::registry_tls_config(registry)); + 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, @@ -385,7 +391,9 @@ pub mod registry { } pub(crate) fn has_credentials(&self) -> bool { - !self.token.is_empty() || !self.auth.is_empty() || (!self.certfile.is_empty() && !self.keyfile.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) { @@ -591,20 +599,6 @@ pub mod registry { } } - pub fn registry_tls_config(registry: &Scope) -> Option { - if registry.certfile.is_empty() && registry.keyfile.is_empty() { - return None; - } - let mut config = http::ssl_config::SSLConfig::default(); - if !registry.certfile.is_empty() { - config.cert_file_name = bun_core::dupe_z(®istry.certfile); - } - if !registry.keyfile.is_empty() { - config.key_file_name = bun_core::dupe_z(®istry.keyfile); - } - Some(http::ssl_config::SharedPtr::new(config)) - } - // Keys are pre-hashed (`Scope::hash`), so don't re-hash them. pub type Map = HashMap>; diff --git a/src/install_jsc/ini_jsc.rs b/src/install_jsc/ini_jsc.rs index e993cfaca971..c713d5cabead 100644 --- a/src/install_jsc/ini_jsc.rs +++ b/src/install_jsc/ini_jsc.rs @@ -167,8 +167,14 @@ pub(crate) fn load_npmrc<'a>( b"default_registry_auth", self.default_registry_auth.to_js(global)?, )?; - put(b"default_registry_certfile", self.default_registry_certfile.to_js(global)?)?; - put(b"default_registry_keyfile", self.default_registry_keyfile.to_js(global)?)?; + put( + b"default_registry_certfile", + self.default_registry_certfile.to_js(global)?, + )?; + put( + b"default_registry_keyfile", + self.default_registry_keyfile.to_js(global)?, + )?; put(b"ignore_scripts", optional_bool(self.ignore_scripts))?; put( b"link_workspace_packages", diff --git a/src/runtime/cli/audit_command.rs b/src/runtime/cli/audit_command.rs index 132071368e57..389e71310bc5 100644 --- a/src/runtime/cli/audit_command.rs +++ b/src/runtime/cli/audit_command.rs @@ -324,12 +324,16 @@ struct AuditRegistry { } impl AuditRegistry { - fn from_scope(scope: &bun_install::npm::registry::Scope, is_default: bool) -> AuditRegistry { + fn from_scope( + pm: &PackageManager, + scope: &bun_install::npm::registry::Scope, + is_default: bool, + ) -> AuditRegistry { AuditRegistry { href: Box::<[u8]>::from(strings::without_trailing_slash(scope.url.href())), url_hash: scope.url_hash, authorization: scope.authorization(&scope.url.url()), - tls_props: bun_install::npm::registry::registry_tls_config(scope), + tls_props: pm.registry_tls_config(scope), is_default, } } @@ -416,7 +420,7 @@ fn collect_packages_for_audit( let root_id = pm.root_package_id.get(&pm.lockfile, pm.workspace_name_hash); let mut groups: Vec<(AuditRegistry, Vec)> = vec![( - AuditRegistry::from_scope(&pm.options.scope, true), + AuditRegistry::from_scope(pm, &pm.options.scope, true), Vec::new(), )]; @@ -471,7 +475,10 @@ fn collect_packages_for_audit( { Some(i) => i, None => { - groups.push((AuditRegistry::from_scope(package_scope, false), Vec::new())); + groups.push(( + AuditRegistry::from_scope(pm, package_scope, false), + Vec::new(), + )); groups.len() - 1 } }; diff --git a/src/runtime/cli/create_command.rs b/src/runtime/cli/create_command.rs index cb4804b3cf5b..07fe271f08f1 100644 --- a/src/runtime/cli/create_command.rs +++ b/src/runtime/cli/create_command.rs @@ -1717,6 +1717,7 @@ impl Example { http_proxy, HTTP::ProxySettings::from_env(env_loader), HTTP::FetchRedirect::Follow, + None, )); async_http.client.progress_node = Some(core::ptr::NonNull::from(&mut refresher.root)); async_http.client.flags.reject_unauthorized = env_loader.get_tls_reject_unauthorized(); @@ -1801,6 +1802,7 @@ impl Example { http_proxy, HTTP::ProxySettings::from_env(env_loader), HTTP::FetchRedirect::Follow, + None, ); async_http.client.progress_node = Some(core::ptr::NonNull::from(&mut refresher.root)); async_http.client.flags.reject_unauthorized = env_loader.get_tls_reject_unauthorized(); @@ -1882,6 +1884,7 @@ impl Example { http_proxy, HTTP::ProxySettings::from_env(env_loader), HTTP::FetchRedirect::Follow, + None, ); async_http.client.progress_node = Some(core::ptr::NonNull::from(&mut refresher.root)); async_http.client.flags.reject_unauthorized = env_loader.get_tls_reject_unauthorized(); @@ -1932,6 +1935,7 @@ impl Example { http_proxy, HTTP::ProxySettings::from_env(env_loader), HTTP::FetchRedirect::Follow, + None, )); async_http.client.flags.reject_unauthorized = env_loader.get_tls_reject_unauthorized(); diff --git a/src/runtime/cli/pm_diff_command.rs b/src/runtime/cli/pm_diff_command.rs index 6670188a2e66..73e97ce9bf21 100644 --- a/src/runtime/cli/pm_diff_command.rs +++ b/src/runtime/cli/pm_diff_command.rs @@ -1025,13 +1025,17 @@ fn registry_get( let mut response_buf = MutableString::init(64 * 1024)?; let display_url = url.href.to_vec(); + let tls_props = pm + .options + .tarball_credentials(scope, &url, normalized) + .and_then(|registry| pm.registry_tls_config(registry)); let mut req = pm.registry_request( http::Method::GET, url, headers.entries, headers.content.written_slice(), b"", - pm.options.tarball_credentials(scope, &url, normalized).and_then(bun_install::npm::registry::registry_tls_config), + tls_props, ); let res = match pm.send_with_retries(&mut req, &mut response_buf) { Ok(r) => r, diff --git a/src/runtime/cli/pm_view_command.rs b/src/runtime/cli/pm_view_command.rs index 10a1b5cd458d..4049bbb5ecec 100644 --- a/src/runtime/cli/pm_view_command.rs +++ b/src/runtime/cli/pm_view_command.rs @@ -131,8 +131,14 @@ pub(crate) fn view( let mut response_buf = MutableString::init(2048)?; let header_buf: &[u8] = headers.content.written_slice(); - let mut req = - manager.registry_request(http::Method::GET, url, headers.entries, header_buf, b"", npm::registry::registry_tls_config(scope)); + let mut req = manager.registry_request( + http::Method::GET, + url, + headers.entries, + header_buf, + b"", + manager.registry_tls_config(scope), + ); let res = match manager.send_with_retries(&mut req, &mut response_buf) { Ok(r) => r, diff --git a/src/runtime/cli/publish_command.rs b/src/runtime/cli/publish_command.rs index ccee5a094ea7..0214af651b1a 100644 --- a/src/runtime/cli/publish_command.rs +++ b/src/runtime/cli/publish_command.rs @@ -680,7 +680,7 @@ impl PublishCommand { headers.entries, headers.content.written_slice(), b"", - Npm::registry::registry_tls_config(registry), + manager.registry_tls_config(registry), ); let Ok(res) = manager.send_with_retries(&mut req, &mut response_buf) else { @@ -808,7 +808,7 @@ impl PublishCommand { publish_headers.entries, publish_headers.content.written_slice(), publish_req_body, - Npm::registry::registry_tls_config(registry), + manager.registry_tls_config(registry), ); let res = Self::send_or_crash(&mut req, &mut response_buf, "failed to publish package")?; @@ -868,7 +868,7 @@ impl PublishCommand { otp_headers.entries, otp_headers.content.written_slice(), publish_req_body, - Npm::registry::registry_tls_config(registry), + manager.registry_tls_config(registry), ); let otp_res = Self::send_or_crash(&mut otp_req, &mut response_buf, "failed to publish package")?; @@ -1015,7 +1015,10 @@ impl PublishCommand { auth_headers.entries.clone()?, auth_headers.content.written_slice(), b"", - None, + manager + .options + .tarball_credentials(registry, &done_url, true) + .and_then(|registry| manager.registry_tls_config(registry)), ); let res = Self::send_or_crash(&mut req, response_buf, "failed to send OTP request")?; diff --git a/src/runtime/cli/upgrade_command.rs b/src/runtime/cli/upgrade_command.rs index e7a4e6320ce2..e7580fef9b9b 100644 --- a/src/runtime/cli/upgrade_command.rs +++ b/src/runtime/cli/upgrade_command.rs @@ -265,6 +265,7 @@ impl UpgradeCommand { http_proxy, HTTP::ProxySettings::from_env(env_loader), HTTP::FetchRedirect::Follow, + None, )); async_http.client.flags.reject_unauthorized = env_loader.get_tls_reject_unauthorized(); @@ -646,6 +647,7 @@ impl UpgradeCommand { http_proxy, HTTP::ProxySettings::from_env(&env_loader), HTTP::FetchRedirect::Follow, + None, )); // `refresher` is intentionally leaked (process-lifetime), so the // untracked NonNull stored in `progress_node` can never dangle. diff --git a/src/standalone_graph/StandaloneModuleGraph.rs b/src/standalone_graph/StandaloneModuleGraph.rs index 945757797629..04d20c7337b7 100644 --- a/src/standalone_graph/StandaloneModuleGraph.rs +++ b/src/standalone_graph/StandaloneModuleGraph.rs @@ -2944,6 +2944,7 @@ pub(crate) fn download_to_path( http_proxy, bun_http::ProxySettings::from_env(env), bun_http::FetchRedirect::Follow, + None, )); async_http.client.progress_node = core::ptr::NonNull::new(core::ptr::from_mut(progress)); diff --git a/test/cli/install/npmrc.test.ts b/test/cli/install/npmrc.test.ts index 3f6b65dcef1e..1dfd5e3097cf 100644 --- a/test/cli/install/npmrc.test.ts +++ b/test/cli/install/npmrc.test.ts @@ -5,6 +5,7 @@ import { rm } from "fs/promises"; import { bunEnv, bunExe, isIPv6, isLinux, isWindows, tempDir, tls, unprivilegedSpawnOptions } from "harness"; import { createServer as createTlsServer } from "node:tls"; import { dirname, join } from "path"; +import { once } from "node:events"; import { TestRegistry } from "registry"; const { iniInternals } = require("bun:internal-for-testing"); const { loadNpmrc } = iniInternals; @@ -472,12 +473,93 @@ registry=http://localhost:\${PORT}/ expect(result.default_registry_url).toEqual(`http://localhost:${registry.port}/`); }); + test.each(["default", "scoped"])("%s registry uses certfile and keyfile for mTLS", async kind => { + const name = kind === "scoped" ? "@mtls/pkg" : "pkg"; + const tarball = await new Bun.Archive({ + "package/package.json": JSON.stringify({ name, version: "1.0.0" }), + }).bytes("gzip"); + const requests: string[] = []; + await using server = Bun.serve({ + hostname: "127.0.0.1", + port: 0, + tls: { ...tls, ca: tls.cert, requestCert: true, rejectUnauthorized: true }, + fetch(req) { + const path = new URL(req.url).pathname; + requests.push(path); + if (path === "/pkg.tgz") return new Response(tarball); + return Response.json({ + name, + "dist-tags": { latest: "1.0.0" }, + versions: { + "1.0.0": { name, version: "1.0.0", dist: { tarball: `https://localhost:${server.port}/pkg.tgz` } }, + }, + }); + }, + }); + const registry = `https://localhost:${server.port}/`; + using dir = tempDir("npmrc-mtls", { + "home/.npmrc": + `${kind === "scoped" ? "@mtls:registry" : "registry"}=${registry}\n` + + `//localhost:${server.port}/:certfile=client.pem\n//localhost:${server.port}/:keyfile=client.key\ncafile=client.pem\n`, + "home/client.pem": tls.cert, + "home/client.key": tls.key, + "project/package.json": JSON.stringify({ name: "app", version: "1.0.0", dependencies: { [name]: "1.0.0" } }), + }); + const home = join(String(dir), "home"); + const installEnv = { + ...env, + HOME: home, + USERPROFILE: home, + XDG_CONFIG_HOME: undefined, + BUN_INSTALL_CACHE_DIR: join(String(dir), "cache"), + BUN_CONFIG_HTTP_RETRY_COUNT: "0", + }; + await using view = Bun.spawn({ + cmd: [bunExe(), "pm", "view", name, "version"], + cwd: join(String(dir), "project"), + env: installEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [viewOut, viewErr, viewCode] = await Promise.all([view.stdout.text(), view.stderr.text(), view.exited]); + expect({ stdout: viewOut.trim(), stderr: viewErr, exitCode: viewCode }).toEqual({ + stdout: "1.0.0", + stderr: "", + exitCode: 0, + }); + requests.length = 0; + await using install = Bun.spawn({ + cmd: [bunExe(), "install"], + cwd: join(String(dir), "project"), + env: installEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([ + install.stdout.text(), + install.stderr.text(), + install.exited, + ]); + expect(stderr).not.toContain("error:"); + expect(stdout).toContain(`+ ${name}@1.0.0`); + expect(requests).toEqual([kind === "scoped" ? "/@mtls%2fpkg" : "/pkg", "/pkg.tgz"]); + expect(exitCode).toBe(0); + }); + test("certfile and keyfile are parsed for the default registry", () => { - const result = loadNpmrc(`//registry.npmjs.org/:certfile=/path/to/cert.pem\n//registry.npmjs.org/:keyfile=/path/to/key.pem`); + const result = loadNpmrc( + `//registry.npmjs.org/:certfile=/path/to/cert.pem\n//registry.npmjs.org/:keyfile=/path/to/key.pem`, + ); expect(result.default_registry_certfile).toBe("/path/to/cert.pem"); expect(result.default_registry_keyfile).toBe("/path/to/key.pem"); }); + test.each(["certfile", "keyfile"])("a lone %s does not select credentials", option => { + const result = loadNpmrc(`//registry.npmjs.org/:${option}=/path/to/file.pem`); + expect(result.default_registry_certfile).toBe(""); + expect(result.default_registry_keyfile).toBe(""); + }); + test("certfile and keyfile are empty by default", () => { const result = loadNpmrc(""); expect(result.default_registry_certfile).toBe(""); @@ -1927,12 +2009,12 @@ describe.concurrent(".npmrc auth resolves by path-segment ancestor", () => { expect(auth).toBe(`Bearer ${token}`); }); - it("a complete pair leaves the registry URL's userinfo intact", async () => { + it("a complete pair replaces the registry URL's userinfo", 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")}`); + expect(auth).toBe(null); }); it("a complete pair on a shallower path leaves a bunfig.toml token intact", async () => { @@ -6179,13 +6261,267 @@ describe.concurrent("bytes that are not valid UTF-8 are not read as U+FFFD", () ]); expect(exitCode).toBe(1); }); +}); - test("keyfile without certfile is parsed", () => { - const ini = ` -//registry.npmjs.org/:keyfile=/path/to/key.pem -`; - const result = loadNpmrc(ini); - expect(result.default_registry_certfile).toEqual(""); - expect(result.default_registry_keyfile).toEqual("/path/to/key.pem"); +describe.concurrent("npmrc client TLS identity", () => { + type Seen = { certificate: boolean; authorization: string | null }; + const manifest = { + name: "pkg", + "dist-tags": { latest: "1.0.0" }, + versions: { "1.0.0": { name: "pkg", version: "1.0.0" } }, + }; + async function certificateServer( + redirect?: (path: string) => string | undefined, + response: object | Uint8Array = manifest, + ) { + const seen: Seen[] = []; + const sockets = new Set(); + const instance = createTlsServer({ ...tls, ca: tls.cert, requestCert: true, rejectUnauthorized: false }, socket => { + sockets.add(socket); + socket.on("close", () => sockets.delete(socket)); + let head = ""; + let answered = false; + socket.on("data", data => { + head += data.toString(); + if (answered || !head.includes("\r\n\r\n")) return; + answered = true; + seen.push({ + certificate: !!socket.getPeerCertificate().raw, + authorization: /\r\nauthorization: ([^\r]*)/i.exec(head)?.[1] ?? null, + }); + const location = redirect?.(head.split(" ")[1]); + if (location) + socket.end(`HTTP/1.1 302 Found\r\nLocation: ${location}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n`); + else { + const body = response instanceof Uint8Array ? response : Buffer.from(JSON.stringify(response)); + socket.end( + Buffer.concat([ + Buffer.from( + `HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: ${body.length}\r\nConnection: close\r\n\r\n`, + ), + body, + ]), + ); + } + }); + }); + instance.listen(0, "127.0.0.1"); + await once(instance, "listening"); + const port = (instance.address() as { port: number }).port; + return { + seen, + port, + url: `https://localhost:${port}/`, + async [Symbol.asyncDispose]() { + for (const socket of sockets) socket.destroy(); + await new Promise((resolve, reject) => instance.close(err => (err ? reject(err) : resolve()))); + }, + }; + } + + async function view( + port: number, + kind: "default" | "scoped", + source: "npmrc" | "bunfig" | "basic" | "env" | "cli" = "npmrc", + h2 = false, + ) { + const url = `HTTPS://LOCALHOST:${port}/`; + const scoped = kind === "scoped"; + const explicit = source === "bunfig" || source === "basic"; + using dir = tempDir("npmrc-mtls-identity", { + "home/.npmrc": + `${explicit ? "" : `${scoped ? "@mtls:registry" : "registry"}=${url}\n`}` + + `//localhost:${port}/:certfile=\${MTLS_CERTFILE}\n//localhost:${port}/:keyfile=\${MTLS_KEYFILE}\ncafile=client.pem\n` + + (source === "npmrc" ? `//localhost:${port}/:_authToken=identity-token\n` : ""), + "home/client.pem": tls.cert, + "home/client.key": tls.key, + "project/package.json": JSON.stringify({ name: "app", version: "1.0.0" }), + ...(explicit + ? { + "project/bunfig.toml": Bun.TOML.stringify({ + install: scoped + ? { + scopes: { + mtls: { + url, + ...(source === "basic" ? { username: "user", password: "pass" } : { token: "identity-token" }), + }, + }, + } + : { + registry: { + url, + ...(source === "basic" ? { username: "user", password: "pass" } : { token: "identity-token" }), + }, + }, + }), + } + : {}), + }); + const home = join(String(dir), "home"); + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "pm", + "view", + scoped ? "@mtls/pkg" : "pkg", + "version", + ...(source === "cli" ? ["--registry", `https://:identity-token@localhost:${port}/`] : []), + ], + cwd: join(String(dir), "project"), + env: { + ...env, + HOME: home, + USERPROFILE: home, + XDG_CONFIG_HOME: undefined, + BUN_INSTALL_CACHE_DIR: join(String(dir), "cache"), + BUN_CONFIG_HTTP_RETRY_COUNT: "0", + MTLS_CERTFILE: "client.pem", + MTLS_KEYFILE: "client.key", + ...(source === "env" ? { BUN_CONFIG_TOKEN: "identity-token" } : {}), + ...(h2 ? { BUN_FEATURE_FLAG_EXPERIMENTAL_HTTP2_CLIENT: "1" } : {}), + }, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ stdout: stdout.trim(), stderr, exitCode }).toEqual({ stdout: "1.0.0", stderr: "", exitCode: 0 }); + } + + test.each([ + ["default", "npmrc"], + ["scoped", "npmrc"], + ["default", "bunfig"], + ["scoped", "bunfig"], + ["default", "basic"], + ["scoped", "basic"], + ["default", "env"], + ["default", "cli"], + ] as const)("%s registry combines mTLS with %s credentials", async (kind, source) => { + await using registry = await certificateServer(); + await view(registry.port, kind, source); + expect(registry.seen).toEqual([ + { + certificate: true, + authorization: + source === "basic" ? `Basic ${Buffer.from("user:pass").toString("base64")}` : "Bearer identity-token", + }, + ]); + }); + + test("a same-origin redirect retains the client identity", async () => { + await using registry = await certificateServer(path => (path === "/pkg" ? `${registry.url}final` : undefined)); + await view(registry.port, "default"); + expect(registry.seen).toEqual([ + { certificate: true, authorization: "Bearer identity-token" }, + { certificate: true, authorization: "Bearer identity-token" }, + ]); + }); + + test("a cross-origin redirect strips the client identity and keeps the CA", async () => { + await using target = await certificateServer(); + await using registry = await certificateServer(() => `${target.url}pkg`); + await view(registry.port, "default"); + expect(registry.seen).toEqual([{ certificate: true, authorization: "Bearer identity-token" }]); + expect(target.seen).toEqual([{ certificate: false, authorization: null }]); + }); + + test("returning to the original origin does not restore the client identity", async () => { + await using target = await certificateServer(() => `${registry.url}final`); + await using registry = await certificateServer(path => (path === "/pkg" ? `${target.url}pkg` : undefined)); + await view(registry.port, "default"); + expect(registry.seen).toEqual([ + { certificate: true, authorization: "Bearer identity-token" }, + { certificate: false, authorization: null }, + ]); + expect(target.seen).toEqual([{ certificate: false, authorization: null }]); + }); + test.each([true, false])("tarball mTLS follows project trust (project names URL: %s)", async trusted => { + const leaf = await new Bun.Archive({ + "package/package.json": JSON.stringify({ name: "leaf", version: "1.0.0" }), + }).bytes("gzip"); + await using cdn = await certificateServer(undefined, leaf); + const leafUrl = `${cdn.url}leaf.tgz`; + const parent = await new Bun.Archive({ + "package/package.json": JSON.stringify({ name: "parent", version: "1.0.0", dependencies: { leaf: leafUrl } }), + }).bytes("gzip"); + await using registry = Bun.serve({ + hostname: "127.0.0.1", + port: 0, + fetch(req) { + if (new URL(req.url).pathname.endsWith(".tgz")) return new Response(parent); + return Response.json({ + name: "parent", + "dist-tags": { latest: "1.0.0" }, + versions: { + "1.0.0": { + name: "parent", + version: "1.0.0", + dependencies: { leaf: leafUrl }, + dist: { tarball: `http://127.0.0.1:${registry.port}/parent.tgz` }, + }, + }, + }); + }, + }); + using dir = tempDir("pr31000-trust", { + "home/.npmrc": `registry=http://127.0.0.1:${registry.port}/\n//localhost:${cdn.port}/:certfile=client.pem\n//localhost:${cdn.port}/:keyfile=client.key\ncafile=client.pem\n`, + "home/client.pem": tls.cert, + "home/client.key": tls.key, + "project/package.json": JSON.stringify({ + name: "app", + version: "1.0.0", + dependencies: trusted ? { leaf: leafUrl } : { parent: "1.0.0" }, + }), + }); + const home = join(String(dir), "home"); + await using proc = Bun.spawn({ + cmd: [bunExe(), "install"], + cwd: join(String(dir), "project"), + env: { + ...env, + HOME: home, + USERPROFILE: home, + XDG_CONFIG_HOME: undefined, + BUN_INSTALL_CACHE_DIR: join(String(dir), "cache"), + BUN_CONFIG_HTTP_RETRY_COUNT: "0", + }, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(cdn.seen).toEqual([{ certificate: trusted, authorization: null }]); + expect(stdout).toContain("installed"); + expect(exitCode).toBe(0); + }); + test("HTTP2 cross-origin redirect strips the client identity", async () => { + const { createSecureServer } = await import("node:http2"); + await using target = await certificateServer(); + const first: unknown[] = []; + const registry = createSecureServer({ ...tls, ca: tls.cert, requestCert: true, rejectUnauthorized: false }); + const sessions = new Set(); + registry.on("session", session => { + sessions.add(session); + session.on("close", () => sessions.delete(session)); + }); + registry.on("stream", (stream, headers) => { + first.push({ + protocol: stream.session!.alpnProtocol, + certificate: !!(stream.session!.socket as import("node:tls").TLSSocket).getPeerCertificate().raw, + authorization: headers.authorization ?? null, + }); + stream.respond({ ":status": 302, location: `${target.url}pkg` }); + stream.end(); + }); + registry.listen(0, "127.0.0.1"); + await once(registry, "listening"); + try { + await view((registry.address() as import("node:net").AddressInfo).port, "default", "npmrc", true); + expect(first).toEqual([{ protocol: "h2", certificate: true, authorization: "Bearer identity-token" }]); + expect(target.seen).toEqual([{ certificate: false, authorization: null }]); + } finally { + for (const session of sessions) session.destroy(); + await new Promise((resolve, reject) => registry.close(err => (err ? reject(err) : resolve()))); + } }); }); From 129db8745303aad115a09a5a739583eaec68575a Mon Sep 17 00:00:00 2001 From: krutin Date: Fri, 6 Nov 2026 14:32:03 +0300 Subject: [PATCH 06/12] fix: own TLS configuration in HTTP-thread requests --- src/http/AsyncHTTP.rs | 3 +- src/http/HTTPThread.rs | 3 ++ test/cli/install/npmrc.test.ts | 76 +++++++++++++++++++++++++++++++++- 3 files changed, 80 insertions(+), 2 deletions(-) diff --git a/src/http/AsyncHTTP.rs b/src/http/AsyncHTTP.rs index 45d672592a61..ef714c9a9d1b 100644 --- a/src/http/AsyncHTTP.rs +++ b/src/http/AsyncHTTP.rs @@ -725,7 +725,7 @@ impl<'a> AsyncHTTP<'a> { // (`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` is reclaimed. Only the // state the clone built up itself during request processing is @@ -771,6 +771,7 @@ impl<'a> AsyncHTTP<'a> { client.close_proxy_tunnel(false); debug_assert!(client.h2.is_none()); drop(core::mem::take(&mut client.custom_ssl_ctx)); + drop(core::mem::take(&mut client.tls_props)); // `state` was `Default` at `ptr::read` time and was // populated by the clone (`on_start` → `client.start`); it // owns the decompressor / compressed_body buffers. diff --git a/src/http/HTTPThread.rs b/src/http/HTTPThread.rs index 090e5441b4f0..90f64160fa13 100644 --- a/src/http/HTTPThread.rs +++ b/src/http/HTTPThread.rs @@ -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 diff --git a/test/cli/install/npmrc.test.ts b/test/cli/install/npmrc.test.ts index 1dfd5e3097cf..0f28905d1979 100644 --- a/test/cli/install/npmrc.test.ts +++ b/test/cli/install/npmrc.test.ts @@ -6,6 +6,7 @@ import { bunEnv, bunExe, isIPv6, isLinux, isWindows, tempDir, tls, unprivilegedS import { createServer as createTlsServer } from "node:tls"; import { dirname, join } from "path"; import { once } from "node:events"; +import { createSecureServer } from "node:http2"; import { TestRegistry } from "registry"; const { iniInternals } = require("bun:internal-for-testing"); const { loadNpmrc } = iniInternals; @@ -6494,8 +6495,81 @@ describe.concurrent("npmrc client TLS identity", () => { expect(stdout).toContain("installed"); expect(exitCode).toBe(0); }); + test("concurrent registry redirects retain independent TLS configuration references", async () => { + const names = ["tls-ref-0", "tls-ref-1", "tls-ref-2", "tls-ref-3"]; + const tarballs = new Map( + await Promise.all( + names.map( + async name => + [ + name, + await new Bun.Archive({ + "package/package.json": JSON.stringify({ name, version: "1.0.0" }), + }).bytes("gzip"), + ] as const, + ), + ), + ); + await using target = Bun.serve({ + hostname: "127.0.0.1", + port: 0, + tls, + fetch(req) { + const name = new URL(req.url).pathname.slice(1); + return Response.json({ + name, + "dist-tags": { latest: "1.0.0" }, + versions: { + "1.0.0": { name, version: "1.0.0", dist: { tarball: `https://localhost:${registry.port}/${name}.tgz` } }, + }, + }); + }, + }); + const requests: string[] = []; + await using registry = Bun.serve({ + hostname: "127.0.0.1", + port: 0, + tls: { ...tls, ca: tls.cert, requestCert: true, rejectUnauthorized: true }, + fetch(req) { + const path = new URL(req.url).pathname.slice(1); + requests.push(path); + if (path.endsWith(".tgz")) return new Response(tarballs.get(path.slice(0, -4))!); + return Response.redirect(`https://localhost:${target.port}/${path}`); + }, + }); + using dir = tempDir("npmrc-mtls-references", { + "home/.npmrc": `registry=https://localhost:${registry.port}/\n//localhost:${registry.port}/:certfile=client.pem\n//localhost:${registry.port}/:keyfile=client.key\ncafile=client.pem\n`, + "home/client.pem": tls.cert, + "home/client.key": tls.key, + "project/package.json": JSON.stringify({ + name: "app", + version: "1.0.0", + dependencies: Object.fromEntries(names.map(name => [name, "1.0.0"])), + }), + }); + const home = join(String(dir), "home"); + await using proc = Bun.spawn({ + cmd: [bunExe(), "install"], + cwd: join(String(dir), "project"), + env: { + ...env, + HOME: home, + USERPROFILE: home, + XDG_CONFIG_HOME: undefined, + BUN_INSTALL_CACHE_DIR: join(String(dir), "cache"), + BUN_CONFIG_HTTP_RETRY_COUNT: "0", + }, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stderr).not.toContain("error:"); + expect(stdout).toContain("4 packages installed"); + expect(requests.sort()).toEqual(names.flatMap(name => [name, `${name}.tgz`]).sort()); + expect(exitCode).toBe(0); + }); + test("HTTP2 cross-origin redirect strips the client identity", async () => { - const { createSecureServer } = await import("node:http2"); await using target = await certificateServer(); const first: unknown[] = []; const registry = createSecureServer({ ...tls, ca: tls.cert, requestCert: true, rejectUnauthorized: false }); From 188cad9f9e1c33e58ae00c50cf01a522032eb40a Mon Sep 17 00:00:00 2001 From: krutin Date: Fri, 6 Nov 2026 16:14:21 +0300 Subject: [PATCH 07/12] fix: strip fetch client TLS identity on cross-origin redirects --- src/http/lib.rs | 4 +- test/js/web/fetch/fetch.tls.test.ts | 180 ++++++++++++++++++++++++++++ 2 files changed, 182 insertions(+), 2 deletions(-) diff --git a/src/http/lib.rs b/src/http/lib.rs index bee3a94444b0..a885db6a6ce8 100644 --- a/src/http/lib.rs +++ b/src/http/lib.rs @@ -5353,8 +5353,8 @@ impl<'a> HTTPClient<'a> { } } self.state.flags.strip_tls_identity_on_redirect = !hop.same_origin - && !hop.https_upgrade - && self.flags.redirect_credentials == RedirectCredentialsPolicy::HttpsUpgrade + && (!hop.https_upgrade + || self.flags.redirect_credentials == RedirectCredentialsPolicy::SameOrigin) && self.tls_props.is_some(); self.state.flags.is_redirect_pending = true; if self.method.has_request_body() { diff --git a/test/js/web/fetch/fetch.tls.test.ts b/test/js/web/fetch/fetch.tls.test.ts index 25914e5ee3b7..6be922e3a69a 100644 --- a/test/js/web/fetch/fetch.tls.test.ts +++ b/test/js/web/fetch/fetch.tls.test.ts @@ -1,7 +1,9 @@ import { afterAll, beforeAll, describe, expect, it } from "bun:test"; import { bunEnv, bunExe, isASAN, isIPv6, isWindows, nodeExe, tempDir, tmpdirSync } from "harness"; import { once } from "node:events"; +import { X509Certificate } from "node:crypto"; import { readFileSync } from "node:fs"; +import { createSecureServer, type ServerHttp2Session } from "node:http2"; import net from "node:net"; import { join } from "node:path"; import tls from "node:tls"; @@ -39,6 +41,184 @@ async function createServer(cert: TLSOptions, callback: (port: number) => Promis } describe.concurrent("fetch-tls", () => { + describe("client TLS identity on redirects", () => { + const certificate = new X509Certificate(validTls.cert).raw.toString("base64"); + const identity = { ...validTls, ca: validTls.cert }; + + async function server(redirect: (path: string) => string | undefined = () => undefined) { + const seen: { path: string; certificate: string | null; authorization: string | null }[] = []; + const sockets = new Set(); + const instance = tls.createServer( + { ...validTls, ca: validTls.cert, requestCert: true, rejectUnauthorized: false }, + socket => { + sockets.add(socket); + socket.on("close", () => sockets.delete(socket)); + let head = ""; + let answered = false; + socket.on("data", data => { + head += data.toString(); + if (answered || !head.includes("\r\n\r\n")) return; + answered = true; + const path = head.split(" ")[1]; + seen.push({ + path, + certificate: socket.getPeerCertificate().raw?.toString("base64") ?? null, + authorization: /\r\nauthorization: ([^\r]*)/i.exec(head)?.[1] ?? null, + }); + const location = redirect(path); + socket.end( + location + ? `HTTP/1.1 302 Found\r\nLocation: ${location}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n` + : "HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok", + ); + }); + }, + ); + instance.listen(0, "127.0.0.1"); + await once(instance, "listening"); + return { + seen, + url: `https://127.0.0.1:${(instance.address() as net.AddressInfo).port}`, + async [Symbol.asyncDispose]() { + for (const socket of sockets) socket.destroy(); + await new Promise((resolve, reject) => instance.close(error => (error ? reject(error) : resolve()))); + }, + }; + } + + it("preserves client identity on a same-origin redirect", async () => { + await using origin = await server(path => (path === "/start" ? `${origin.url}/final` : undefined)); + const response = await fetch(`${origin.url}/start`, { + tls: identity, + headers: { Authorization: "Bearer redirect-test" }, + }); + expect(await response.text()).toBe("ok"); + expect(origin.seen).toEqual([ + { path: "/start", certificate, authorization: "Bearer redirect-test" }, + { path: "/final", certificate, authorization: "Bearer redirect-test" }, + ]); + }); + + it("strips client identity on a cross-origin redirect while retaining the CA", async () => { + await using target = await server(); + await using origin = await server(() => `${target.url}/final`); + const response = await fetch(`${origin.url}/start`, { + tls: identity, + headers: { Authorization: "Bearer redirect-test" }, + }); + expect(await response.text()).toBe("ok"); + expect(origin.seen).toEqual([{ path: "/start", certificate, authorization: "Bearer redirect-test" }]); + expect(target.seen).toEqual([{ path: "/final", certificate: null, authorization: null }]); + }); + + it("does not restore client identity when a redirect returns to the original origin", async () => { + await using target = await server(() => `${origin.url}/final`); + await using origin = await server(path => (path === "/start" ? `${target.url}/middle` : undefined)); + const response = await fetch(`${origin.url}/start`, { + tls: identity, + headers: { Authorization: "Bearer redirect-test" }, + }); + expect(await response.text()).toBe("ok"); + expect(origin.seen).toEqual([ + { path: "/start", certificate, authorization: "Bearer redirect-test" }, + { path: "/final", certificate: null, authorization: null }, + ]); + expect(target.seen).toEqual([{ path: "/middle", certificate: null, authorization: null }]); + }); + + it("strips client identity on an HTTP to HTTPS redirect", async () => { + await using target = await server(); + using origin = Bun.serve({ + port: 0, + fetch: () => Response.redirect(`${target.url}/final`, 302), + }); + const response = await fetch(`http://127.0.0.1:${origin.port}/start`, { + tls: identity, + headers: { Authorization: "Bearer redirect-test" }, + }); + expect(await response.text()).toBe("ok"); + expect(target.seen).toEqual([{ path: "/final", certificate: null, authorization: null }]); + }); + + it("keeps independent requests' client identity after stripping it from a redirect", async () => { + await using target = await server(); + await using origin = await server(() => `${target.url}/final`); + const redirected = await fetch(`${origin.url}/start`, { tls: identity }); + expect(await redirected.text()).toBe("ok"); + const direct = await fetch(`${target.url}/direct`, { tls: identity }); + expect(await direct.text()).toBe("ok"); + expect(target.seen).toEqual([ + { path: "/final", certificate: null, authorization: null }, + { path: "/direct", certificate, authorization: null }, + ]); + }); + + it("strips client identity on an HTTP2 cross-origin redirect", async () => { + async function h2server(location?: string) { + const seen: unknown[] = []; + const sessions = new Set(); + const instance = createSecureServer({ + ...validTls, + ca: validTls.cert, + requestCert: true, + rejectUnauthorized: false, + }); + instance.on("session", session => { + sessions.add(session); + session.on("close", () => sessions.delete(session)); + }); + instance.on("stream", (stream, headers) => { + seen.push({ + protocol: stream.session!.alpnProtocol, + certificate: (stream.session!.socket as tls.TLSSocket).getPeerCertificate().raw?.toString("base64") ?? null, + authorization: headers.authorization ?? null, + }); + stream.respond(location ? { ":status": 302, location } : { ":status": 200 }); + stream.end(location ? undefined : "ok"); + }); + instance.listen(0, "127.0.0.1"); + await once(instance, "listening"); + return { + seen, + url: `https://127.0.0.1:${(instance.address() as net.AddressInfo).port}`, + async [Symbol.asyncDispose]() { + for (const session of sessions) session.destroy(); + await new Promise((resolve, reject) => instance.close(error => (error ? reject(error) : resolve()))); + }, + }; + } + await using target = await h2server(); + await using origin = await h2server(`${target.url}/final`); + const response = await fetch(`${origin.url}/start`, { + protocol: "http2", + tls: identity, + headers: { Authorization: "Bearer redirect-test" }, + }); + expect(await response.text()).toBe("ok"); + expect(origin.seen).toEqual([{ protocol: "h2", certificate, authorization: "Bearer redirect-test" }]); + expect(target.seen).toEqual([{ protocol: "h2", certificate: null, authorization: null }]); + const direct = await fetch(`${target.url}/direct`, { protocol: "http2", tls: identity }); + expect(await direct.text()).toBe("ok"); + expect(target.seen).toEqual([ + { protocol: "h2", certificate: null, authorization: null }, + { protocol: "h2", certificate, authorization: null }, + ]); + }); + + it("still verifies the redirect target's certificate", async () => { + using target = Bun.serve({ + port: 0, + tls: CERT_LOCALHOST_ONLY, + fetch: () => new Response("unexpected"), + }); + await using origin = await server(() => `https://127.0.0.1:${target.port}/final`); + await expect( + fetch(`${origin.url}/start`, { tls: { ...identity, ca: [validTls.cert, CERT_LOCALHOST_ONLY.cert] } }), + ).rejects.toMatchObject({ code: "ERR_TLS_CERT_ALTNAME_INVALID" }); + expect(origin.seen).toEqual([{ path: "/start", certificate, authorization: null }]); + }); + }); + it("drops a caller-supplied Host header on a cross-origin redirect and never verifies TLS against it", async () => { // The redirect target records the Host header it actually receives. const receivedHostHeaders: (string | null)[] = []; From 257188246ab636c7ceb9738b2b18c1c9c17e0e30 Mon Sep 17 00:00:00 2001 From: krutin Date: Fri, 6 Nov 2026 16:48:40 +0300 Subject: [PATCH 08/12] test: cover client TLS identity on same-port HTTPS upgrades --- docs/runtime/networking/fetch.mdx | 2 ++ test/cli/install/bun-install.test.ts | 30 ++----------------- test/cli/install/npmrc.test.ts | 45 +++++++++++++++++++++++++++- test/harness.ts | 31 +++++++++++++++++++ test/js/web/fetch/fetch.tls.test.ts | 18 ++++++++++- 5 files changed, 97 insertions(+), 29 deletions(-) diff --git a/docs/runtime/networking/fetch.mdx b/docs/runtime/networking/fetch.mdx index 280c77533d9d..e1f96ae8e8f2 100644 --- a/docs/runtime/networking/fetch.mdx +++ b/docs/runtime/networking/fetch.mdx @@ -253,6 +253,8 @@ await fetch("https://example.com", { }); ``` +Client certificates are retained on same-origin redirects. When a redirect changes the scheme, hostname, or port, Bun removes the client certificate and key from subsequent requests while preserving the trusted CA settings. Returning to the original origin does not restore the client certificate. To authenticate to another origin, handle the redirect with `redirect: "manual"` and explicitly supply that origin's TLS options in a new request. + #### Custom TLS Validation To customize TLS validation, use the `checkServerIdentity` option in `tls`: diff --git a/test/cli/install/bun-install.test.ts b/test/cli/install/bun-install.test.ts index 011399d13c7a..27d1c19ccc1a 100644 --- a/test/cli/install/bun-install.test.ts +++ b/test/cli/install/bun-install.test.ts @@ -17,6 +17,7 @@ import { normalizeBunSnapshot, readdirSorted, runBunInstall, + serveBothSchemes, substDrive, tempDir, textLockfile, @@ -1393,31 +1394,6 @@ describe.concurrent("bun-install", () => { const manifestPath = "/no-deps"; const tarballPath = "/no-deps/-/no-deps-1.0.0.tgz"; - // Serves both schemes on one port. A connection that starts with a TLS handshake record (0x16) is piped to - // `httpsPort`, and any other connection to `httpPort`. - async function serveBothSchemes(httpPort: number, httpsPort: number) { - const server = createServer(client => { - client.on("error", () => {}); - client.once("data", (first: Buffer) => { - const upstream = connect(first[0] === 0x16 ? httpsPort : httpPort, "127.0.0.1"); - upstream.on("error", () => client.destroy()); - client.on("error", () => upstream.destroy()); - upstream.write(first); - client.pipe(upstream).pipe(client); - }); - }); - const listening = Promise.withResolvers(); - server.on("error", listening.reject); - server.listen(0, "127.0.0.1", listening.resolve); - await listening.promise; - return { - port: (server.address() as AddressInfo).port, - [Symbol.dispose]() { - server.close(); - }, - }; - } - // The registry lives on `registry`://127.0.0.1; `target` is the scheme and // hostname it redirects to. The port differs too, unless `samePort` puts // both servers behind one port. https servers use the harness certificate, @@ -1512,7 +1488,7 @@ describe.concurrent("bun-install", () => { }, }); const [httpServer, httpsServer] = registryScheme === "http" ? [registry, targetServer] : [targetServer, registry]; - using bothSchemes = samePort ? await serveBothSchemes(httpServer.port!, httpsServer.port!) : undefined; + await using bothSchemes = samePort ? await serveBothSchemes(httpServer.port!, httpsServer.port!) : undefined; const registryPort = bothSchemes?.port ?? registry.port; const targetPort = bothSchemes?.port ?? targetServer.port; @@ -1586,7 +1562,7 @@ describe.concurrent("bun-install", () => { hostname: "127.0.0.1", fetch: req => Response.redirect(`https://127.0.0.1:${targetPort}${new URL(req.url).pathname}`, 308), }); - using bothSchemes = samePort ? await serveBothSchemes(registry.port!, targetServer.port!) : undefined; + await using bothSchemes = samePort ? await serveBothSchemes(registry.port!, targetServer.port!) : undefined; const registryPort = bothSchemes?.port ?? registry.port; const targetPort = bothSchemes?.port ?? targetServer.port; diff --git a/test/cli/install/npmrc.test.ts b/test/cli/install/npmrc.test.ts index 0f28905d1979..b26d828220f4 100644 --- a/test/cli/install/npmrc.test.ts +++ b/test/cli/install/npmrc.test.ts @@ -2,7 +2,17 @@ import { write } from "bun"; import { afterAll, beforeAll, describe, expect, it, test } from "bun:test"; import { chmodSync, mkdirSync, readdirSync, writeFileSync } from "fs"; import { rm } from "fs/promises"; -import { bunEnv, bunExe, isIPv6, isLinux, isWindows, tempDir, tls, unprivilegedSpawnOptions } from "harness"; +import { + bunEnv, + bunExe, + isIPv6, + isLinux, + isWindows, + serveBothSchemes, + tempDir, + tls, + unprivilegedSpawnOptions, +} from "harness"; import { createServer as createTlsServer } from "node:tls"; import { dirname, join } from "path"; import { once } from "node:events"; @@ -6569,6 +6579,39 @@ describe.concurrent("npmrc client TLS identity", () => { expect(exitCode).toBe(0); }); + test("an HTTP to HTTPS upgrade on the same port retains the registry client identity", async () => { + await using target = await certificateServer(); + using origin = Bun.serve({ + port: 0, + hostname: "127.0.0.1", + fetch: req => Response.redirect(`https://localhost:${bothSchemes.port}${new URL(req.url).pathname}`, 302), + }); + await using bothSchemes = await serveBothSchemes(origin.port!, target.port); + using dir = tempDir("npmrc-mtls-upgrade", { + "package.json": JSON.stringify({ name: "app", version: "1.0.0" }), + ".npmrc": `registry=http://localhost:${bothSchemes.port}/ +//localhost:${bothSchemes.port}/:certfile=client.pem +//localhost:${bothSchemes.port}/:keyfile=client.key +//localhost:${bothSchemes.port}/:_authToken=identity-token +cafile=client.pem +`, + "client.pem": tls.cert, + "client.key": tls.key, + }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "pm", "view", "pkg", "version"], + cwd: String(dir), + env: { ...env, HOME: String(dir), USERPROFILE: String(dir), XDG_CONFIG_HOME: undefined }, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stdout.trim()).toBe("1.0.0"); + expect(stderr).toBe(""); + expect(target.seen).toEqual([{ certificate: true, authorization: "Bearer identity-token" }]); + expect(exitCode).toBe(0); + }); + test("HTTP2 cross-origin redirect strips the client identity", async () => { await using target = await certificateServer(); const first: unknown[] = []; diff --git a/test/harness.ts b/test/harness.ts index 9acce8dedb74..10360fdac9e2 100644 --- a/test/harness.ts +++ b/test/harness.ts @@ -12,6 +12,7 @@ import { beforeAll, describe, expect, test } from "bun:test"; import { execSync } from "child_process"; import { readdir, writeFile } from "fs/promises"; import fs, { closeSync, openSync } from "node:fs"; +import { connect, createServer, type AddressInfo, type Socket as NetSocket } from "node:net"; import os from "node:os"; import { dirname, isAbsolute, join, sep } from "path"; import { locksEnvironment } from "./aot/hardened"; @@ -20,6 +21,36 @@ import "./aot/harness/without-installing-packages"; export const BREAKING_CHANGES_BUN_1_2 = false; +/** Route both schemes through one port: a TLS handshake record (0x16) selects the HTTPS backend. */ +export async function serveBothSchemes(httpPort: number, httpsPort: number) { + const sockets = new Set(); + const server = createServer(client => { + sockets.add(client); + client.on("close", () => sockets.delete(client)); + client.on("error", () => client.destroy()); + client.once("data", (first: Buffer) => { + const upstream = connect(first[0] === 0x16 ? httpsPort : httpPort, "127.0.0.1"); + sockets.add(upstream); + upstream.on("close", () => sockets.delete(upstream)); + client.on("close", () => upstream.destroy()); + upstream.on("error", () => client.destroy()); + upstream.write(first); + client.pipe(upstream).pipe(client); + }); + }); + const listening = Promise.withResolvers(); + server.on("error", listening.reject); + server.listen(0, "127.0.0.1", listening.resolve); + await listening.promise; + return { + port: (server.address() as AddressInfo).port, + async [Symbol.asyncDispose]() { + for (const socket of sockets) socket.destroy(); + await new Promise((resolve, reject) => server.close(error => (error ? reject(error) : resolve()))); + }, + }; +} + export const isMacOS = process.platform === "darwin"; export const isLinux = process.platform === "linux"; export const isFreeBSD = process.platform === "freebsd"; diff --git a/test/js/web/fetch/fetch.tls.test.ts b/test/js/web/fetch/fetch.tls.test.ts index 6be922e3a69a..a2fba39c2d73 100644 --- a/test/js/web/fetch/fetch.tls.test.ts +++ b/test/js/web/fetch/fetch.tls.test.ts @@ -1,5 +1,5 @@ import { afterAll, beforeAll, describe, expect, it } from "bun:test"; -import { bunEnv, bunExe, isASAN, isIPv6, isWindows, nodeExe, tempDir, tmpdirSync } from "harness"; +import { bunEnv, bunExe, isASAN, isIPv6, isWindows, nodeExe, serveBothSchemes, tempDir, tmpdirSync } from "harness"; import { once } from "node:events"; import { X509Certificate } from "node:crypto"; import { readFileSync } from "node:fs"; @@ -140,6 +140,22 @@ describe.concurrent("fetch-tls", () => { expect(target.seen).toEqual([{ path: "/final", certificate: null, authorization: null }]); }); + it("strips client identity on an HTTP to HTTPS upgrade on the same port", async () => { + await using target = await server(); + using origin = Bun.serve({ + port: 0, + hostname: "127.0.0.1", + fetch: () => Response.redirect(`https://127.0.0.1:${bothSchemes.port}/final`, 302), + }); + await using bothSchemes = await serveBothSchemes(origin.port!, Number(new URL(target.url).port)); + const response = await fetch(`http://127.0.0.1:${bothSchemes.port}/start`, { + tls: identity, + headers: { Authorization: "Bearer redirect-test" }, + }); + expect(await response.text()).toBe("ok"); + expect(target.seen).toEqual([{ path: "/final", certificate: null, authorization: null }]); + }); + it("keeps independent requests' client identity after stripping it from a redirect", async () => { await using target = await server(); await using origin = await server(() => `${target.url}/final`); From 849133820324239c9797a068ba5c3ec1c55067b4 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 9 Nov 2026 06:45:14 +0000 Subject: [PATCH 09/12] install: keep the redirect strip of the client certificate install-only and keep it across retries The TLS client identity from .npmrc is stripped on a cross-origin redirect only under the install credential policy. fetch() keeps its tls option across redirects, as before. The HTTP-thread clone owns its own reference to tls_props. When the clone is torn down, the original's bits are put back, so the copy into the task's own request in NetworkTask::notify keeps the client certificate for a retry. --- docs/runtime/networking/fetch.mdx | 2 - src/http/AsyncHTTP.rs | 9 ++ src/http/lib.rs | 6 +- test/cli/install/npmrc.test.ts | 62 +++++++++ test/js/web/fetch/fetch.tls.test.ts | 198 +--------------------------- 5 files changed, 76 insertions(+), 201 deletions(-) diff --git a/docs/runtime/networking/fetch.mdx b/docs/runtime/networking/fetch.mdx index e1f96ae8e8f2..280c77533d9d 100644 --- a/docs/runtime/networking/fetch.mdx +++ b/docs/runtime/networking/fetch.mdx @@ -253,8 +253,6 @@ await fetch("https://example.com", { }); ``` -Client certificates are retained on same-origin redirects. When a redirect changes the scheme, hostname, or port, Bun removes the client certificate and key from subsequent requests while preserving the trusted CA settings. Returning to the original origin does not restore the client certificate. To authenticate to another origin, handle the redirect with `redirect: "manual"` and explicitly supply that origin's TLS options in a new request. - #### Custom TLS Validation To customize TLS validation, use the `checkServerIdentity` option in `tls`: diff --git a/src/http/AsyncHTTP.rs b/src/http/AsyncHTTP.rs index ef714c9a9d1b..5c0399583a5c 100644 --- a/src/http/AsyncHTTP.rs +++ b/src/http/AsyncHTTP.rs @@ -771,7 +771,16 @@ impl<'a> AsyncHTTP<'a> { client.close_proxy_tunnel(false); debug_assert!(client.h2.is_none()); drop(core::mem::take(&mut client.custom_ssl_ctx)); + // The clone took its own reference in `start_queued_task` + // (a redirect can replace it). Release it, then put the + // original's bits back: `NetworkTask::notify` copies this + // whole struct over the original, and a retry must keep the + // client certificate. The raw dealloc below never drops it. drop(core::mem::take(&mut client.tls_props)); + core::ptr::write( + &mut client.tls_props, + core::ptr::read(&original_client.tls_props), + ); // `state` was `Default` at `ptr::read` time and was // populated by the clone (`on_start` → `client.start`); it // owns the decompressor / compressed_body buffers. diff --git a/src/http/lib.rs b/src/http/lib.rs index a885db6a6ce8..7a981512ae6f 100644 --- a/src/http/lib.rs +++ b/src/http/lib.rs @@ -5352,9 +5352,11 @@ impl<'a> HTTPClient<'a> { } } } + // `bun install` sends its client certificate only where it sends the registry's + // credential headers. fetch() keeps its `tls` option across redirects, as undici does. self.state.flags.strip_tls_identity_on_redirect = !hop.same_origin - && (!hop.https_upgrade - || self.flags.redirect_credentials == RedirectCredentialsPolicy::SameOrigin) + && !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() { diff --git a/test/cli/install/npmrc.test.ts b/test/cli/install/npmrc.test.ts index b26d828220f4..07f2b69d6575 100644 --- a/test/cli/install/npmrc.test.ts +++ b/test/cli/install/npmrc.test.ts @@ -6579,6 +6579,68 @@ describe.concurrent("npmrc client TLS identity", () => { expect(exitCode).toBe(0); }); + // A completed request is copied back over the task's own request before it is retried, so a + // retry must still carry the client identity. The registry rejects a handshake without it. + test("retried manifest and tarball requests keep the client identity", async () => { + const tarball = await new Bun.Archive({ + "package/package.json": JSON.stringify({ name: "retried", version: "1.0.0" }), + }).bytes("gzip"); + const requests: string[] = []; + await using registry = Bun.serve({ + hostname: "127.0.0.1", + port: 0, + tls: { ...tls, ca: tls.cert, requestCert: true, rejectUnauthorized: true }, + fetch(req) { + const path = new URL(req.url).pathname.slice(1); + requests.push(path); + if (requests.filter(seen => seen === path).length === 1) return new Response("busy", { status: 503 }); + if (path.endsWith(".tgz")) return new Response(tarball); + return Response.json({ + name: "retried", + "dist-tags": { latest: "1.0.0" }, + versions: { + "1.0.0": { + name: "retried", + version: "1.0.0", + dist: { tarball: `https://localhost:${registry.port}/retried.tgz` }, + }, + }, + }); + }, + }); + using dir = tempDir("npmrc-mtls-retry", { + "home/.npmrc": `registry=https://localhost:${registry.port}/\n//localhost:${registry.port}/:certfile=client.pem\n//localhost:${registry.port}/:keyfile=client.key\ncafile=client.pem\n`, + "home/client.pem": tls.cert, + "home/client.key": tls.key, + "project/package.json": JSON.stringify({ + name: "app", + version: "1.0.0", + dependencies: { retried: "1.0.0" }, + }), + }); + const home = join(String(dir), "home"); + await using proc = Bun.spawn({ + cmd: [bunExe(), "install"], + cwd: join(String(dir), "project"), + env: { + ...env, + HOME: home, + USERPROFILE: home, + XDG_CONFIG_HOME: undefined, + BUN_INSTALL_CACHE_DIR: join(String(dir), "cache"), + BUN_CONFIG_HTTP_RETRY_COUNT: "2", + BUN_CONFIG_HTTP_RETRY_BACKOFF: "1", + }, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stderr).not.toContain("error:"); + expect(stdout).toContain("1 package installed"); + expect(requests).toEqual(["retried", "retried", "retried.tgz", "retried.tgz"]); + expect(exitCode).toBe(0); + }); + test("an HTTP to HTTPS upgrade on the same port retains the registry client identity", async () => { await using target = await certificateServer(); using origin = Bun.serve({ diff --git a/test/js/web/fetch/fetch.tls.test.ts b/test/js/web/fetch/fetch.tls.test.ts index a2fba39c2d73..25914e5ee3b7 100644 --- a/test/js/web/fetch/fetch.tls.test.ts +++ b/test/js/web/fetch/fetch.tls.test.ts @@ -1,9 +1,7 @@ import { afterAll, beforeAll, describe, expect, it } from "bun:test"; -import { bunEnv, bunExe, isASAN, isIPv6, isWindows, nodeExe, serveBothSchemes, tempDir, tmpdirSync } from "harness"; +import { bunEnv, bunExe, isASAN, isIPv6, isWindows, nodeExe, tempDir, tmpdirSync } from "harness"; import { once } from "node:events"; -import { X509Certificate } from "node:crypto"; import { readFileSync } from "node:fs"; -import { createSecureServer, type ServerHttp2Session } from "node:http2"; import net from "node:net"; import { join } from "node:path"; import tls from "node:tls"; @@ -41,200 +39,6 @@ async function createServer(cert: TLSOptions, callback: (port: number) => Promis } describe.concurrent("fetch-tls", () => { - describe("client TLS identity on redirects", () => { - const certificate = new X509Certificate(validTls.cert).raw.toString("base64"); - const identity = { ...validTls, ca: validTls.cert }; - - async function server(redirect: (path: string) => string | undefined = () => undefined) { - const seen: { path: string; certificate: string | null; authorization: string | null }[] = []; - const sockets = new Set(); - const instance = tls.createServer( - { ...validTls, ca: validTls.cert, requestCert: true, rejectUnauthorized: false }, - socket => { - sockets.add(socket); - socket.on("close", () => sockets.delete(socket)); - let head = ""; - let answered = false; - socket.on("data", data => { - head += data.toString(); - if (answered || !head.includes("\r\n\r\n")) return; - answered = true; - const path = head.split(" ")[1]; - seen.push({ - path, - certificate: socket.getPeerCertificate().raw?.toString("base64") ?? null, - authorization: /\r\nauthorization: ([^\r]*)/i.exec(head)?.[1] ?? null, - }); - const location = redirect(path); - socket.end( - location - ? `HTTP/1.1 302 Found\r\nLocation: ${location}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n` - : "HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok", - ); - }); - }, - ); - instance.listen(0, "127.0.0.1"); - await once(instance, "listening"); - return { - seen, - url: `https://127.0.0.1:${(instance.address() as net.AddressInfo).port}`, - async [Symbol.asyncDispose]() { - for (const socket of sockets) socket.destroy(); - await new Promise((resolve, reject) => instance.close(error => (error ? reject(error) : resolve()))); - }, - }; - } - - it("preserves client identity on a same-origin redirect", async () => { - await using origin = await server(path => (path === "/start" ? `${origin.url}/final` : undefined)); - const response = await fetch(`${origin.url}/start`, { - tls: identity, - headers: { Authorization: "Bearer redirect-test" }, - }); - expect(await response.text()).toBe("ok"); - expect(origin.seen).toEqual([ - { path: "/start", certificate, authorization: "Bearer redirect-test" }, - { path: "/final", certificate, authorization: "Bearer redirect-test" }, - ]); - }); - - it("strips client identity on a cross-origin redirect while retaining the CA", async () => { - await using target = await server(); - await using origin = await server(() => `${target.url}/final`); - const response = await fetch(`${origin.url}/start`, { - tls: identity, - headers: { Authorization: "Bearer redirect-test" }, - }); - expect(await response.text()).toBe("ok"); - expect(origin.seen).toEqual([{ path: "/start", certificate, authorization: "Bearer redirect-test" }]); - expect(target.seen).toEqual([{ path: "/final", certificate: null, authorization: null }]); - }); - - it("does not restore client identity when a redirect returns to the original origin", async () => { - await using target = await server(() => `${origin.url}/final`); - await using origin = await server(path => (path === "/start" ? `${target.url}/middle` : undefined)); - const response = await fetch(`${origin.url}/start`, { - tls: identity, - headers: { Authorization: "Bearer redirect-test" }, - }); - expect(await response.text()).toBe("ok"); - expect(origin.seen).toEqual([ - { path: "/start", certificate, authorization: "Bearer redirect-test" }, - { path: "/final", certificate: null, authorization: null }, - ]); - expect(target.seen).toEqual([{ path: "/middle", certificate: null, authorization: null }]); - }); - - it("strips client identity on an HTTP to HTTPS redirect", async () => { - await using target = await server(); - using origin = Bun.serve({ - port: 0, - fetch: () => Response.redirect(`${target.url}/final`, 302), - }); - const response = await fetch(`http://127.0.0.1:${origin.port}/start`, { - tls: identity, - headers: { Authorization: "Bearer redirect-test" }, - }); - expect(await response.text()).toBe("ok"); - expect(target.seen).toEqual([{ path: "/final", certificate: null, authorization: null }]); - }); - - it("strips client identity on an HTTP to HTTPS upgrade on the same port", async () => { - await using target = await server(); - using origin = Bun.serve({ - port: 0, - hostname: "127.0.0.1", - fetch: () => Response.redirect(`https://127.0.0.1:${bothSchemes.port}/final`, 302), - }); - await using bothSchemes = await serveBothSchemes(origin.port!, Number(new URL(target.url).port)); - const response = await fetch(`http://127.0.0.1:${bothSchemes.port}/start`, { - tls: identity, - headers: { Authorization: "Bearer redirect-test" }, - }); - expect(await response.text()).toBe("ok"); - expect(target.seen).toEqual([{ path: "/final", certificate: null, authorization: null }]); - }); - - it("keeps independent requests' client identity after stripping it from a redirect", async () => { - await using target = await server(); - await using origin = await server(() => `${target.url}/final`); - const redirected = await fetch(`${origin.url}/start`, { tls: identity }); - expect(await redirected.text()).toBe("ok"); - const direct = await fetch(`${target.url}/direct`, { tls: identity }); - expect(await direct.text()).toBe("ok"); - expect(target.seen).toEqual([ - { path: "/final", certificate: null, authorization: null }, - { path: "/direct", certificate, authorization: null }, - ]); - }); - - it("strips client identity on an HTTP2 cross-origin redirect", async () => { - async function h2server(location?: string) { - const seen: unknown[] = []; - const sessions = new Set(); - const instance = createSecureServer({ - ...validTls, - ca: validTls.cert, - requestCert: true, - rejectUnauthorized: false, - }); - instance.on("session", session => { - sessions.add(session); - session.on("close", () => sessions.delete(session)); - }); - instance.on("stream", (stream, headers) => { - seen.push({ - protocol: stream.session!.alpnProtocol, - certificate: (stream.session!.socket as tls.TLSSocket).getPeerCertificate().raw?.toString("base64") ?? null, - authorization: headers.authorization ?? null, - }); - stream.respond(location ? { ":status": 302, location } : { ":status": 200 }); - stream.end(location ? undefined : "ok"); - }); - instance.listen(0, "127.0.0.1"); - await once(instance, "listening"); - return { - seen, - url: `https://127.0.0.1:${(instance.address() as net.AddressInfo).port}`, - async [Symbol.asyncDispose]() { - for (const session of sessions) session.destroy(); - await new Promise((resolve, reject) => instance.close(error => (error ? reject(error) : resolve()))); - }, - }; - } - await using target = await h2server(); - await using origin = await h2server(`${target.url}/final`); - const response = await fetch(`${origin.url}/start`, { - protocol: "http2", - tls: identity, - headers: { Authorization: "Bearer redirect-test" }, - }); - expect(await response.text()).toBe("ok"); - expect(origin.seen).toEqual([{ protocol: "h2", certificate, authorization: "Bearer redirect-test" }]); - expect(target.seen).toEqual([{ protocol: "h2", certificate: null, authorization: null }]); - const direct = await fetch(`${target.url}/direct`, { protocol: "http2", tls: identity }); - expect(await direct.text()).toBe("ok"); - expect(target.seen).toEqual([ - { protocol: "h2", certificate: null, authorization: null }, - { protocol: "h2", certificate, authorization: null }, - ]); - }); - - it("still verifies the redirect target's certificate", async () => { - using target = Bun.serve({ - port: 0, - tls: CERT_LOCALHOST_ONLY, - fetch: () => new Response("unexpected"), - }); - await using origin = await server(() => `https://127.0.0.1:${target.port}/final`); - await expect( - fetch(`${origin.url}/start`, { tls: { ...identity, ca: [validTls.cert, CERT_LOCALHOST_ONLY.cert] } }), - ).rejects.toMatchObject({ code: "ERR_TLS_CERT_ALTNAME_INVALID" }); - expect(origin.seen).toEqual([{ path: "/start", certificate, authorization: null }]); - }); - }); - it("drops a caller-supplied Host header on a cross-origin redirect and never verifies TLS against it", async () => { // The redirect target records the Host header it actually receives. const receivedHostHeaders: (string | null)[] = []; From b62d00014ab191c33533347f75dc79a4a5c725be Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Mon, 9 Nov 2026 06:47:53 +0000 Subject: [PATCH 10/12] [autofix.ci] apply automated fixes --- test/cli/install/npmrc.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/cli/install/npmrc.test.ts b/test/cli/install/npmrc.test.ts index 07f2b69d6575..c935f49e6c0a 100644 --- a/test/cli/install/npmrc.test.ts +++ b/test/cli/install/npmrc.test.ts @@ -13,10 +13,10 @@ import { tls, unprivilegedSpawnOptions, } from "harness"; -import { createServer as createTlsServer } from "node:tls"; -import { dirname, join } from "path"; import { once } from "node:events"; import { createSecureServer } from "node:http2"; +import { createServer as createTlsServer } from "node:tls"; +import { dirname, join } from "path"; import { TestRegistry } from "registry"; const { iniInternals } = require("bun:internal-for-testing"); const { loadNpmrc } = iniInternals; From c1c2af96e4d574f39eed192778c9fd01ae576a93 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 9 Nov 2026 06:48:38 +0000 Subject: [PATCH 11/12] http: shorten two comments --- src/http/AsyncHTTP.rs | 6 +----- src/http/lib.rs | 3 +-- 2 files changed, 2 insertions(+), 7 deletions(-) diff --git a/src/http/AsyncHTTP.rs b/src/http/AsyncHTTP.rs index 5c0399583a5c..9a9b8e26d6d1 100644 --- a/src/http/AsyncHTTP.rs +++ b/src/http/AsyncHTTP.rs @@ -771,11 +771,7 @@ impl<'a> AsyncHTTP<'a> { client.close_proxy_tunnel(false); debug_assert!(client.h2.is_none()); drop(core::mem::take(&mut client.custom_ssl_ctx)); - // The clone took its own reference in `start_queued_task` - // (a redirect can replace it). Release it, then put the - // original's bits back: `NetworkTask::notify` copies this - // whole struct over the original, and a retry must keep the - // client certificate. The raw dealloc below never drops it. + // 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, diff --git a/src/http/lib.rs b/src/http/lib.rs index 7a981512ae6f..6a42ad4b8003 100644 --- a/src/http/lib.rs +++ b/src/http/lib.rs @@ -5352,8 +5352,7 @@ impl<'a> HTTPClient<'a> { } } } - // `bun install` sends its client certificate only where it sends the registry's - // credential headers. fetch() keeps its `tls` option across redirects, as undici does. + // 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 From 184ea45a5c2a9882da72309afe5ffe9b5bb5677a Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 9 Nov 2026 08:07:49 +0000 Subject: [PATCH 12/12] ci: retrigger