From a084de031628c3e9ea88641206108aedc9a4523d Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 6 Sep 2026 13:36:07 +0000 Subject: [PATCH 1/5] tls: apply tls.DEFAULT_CIPHERS to every TLS context built without ciphers An SSLConfig built without a `ciphers` option falls back to the list assigned through `tls.DEFAULT_CIPHERS`, in the one native place every TLS consumer goes through (tls.createServer, https.createServer, createSecureContext, Bun.serve, Bun.listen). #21545 added this fallback in SSLConfig.fromJS and the bindgen port (#23169) dropped it, so only tls.connect (which passes the default explicitly) still honoured it. A default list with no TLS 1.2 cipher left raises the protocol floor to TLS 1.3, like Node's configSecureContext. --- src/jsc/VirtualMachine.rs | 8 +++ src/runtime/socket/SSLConfig.rs | 22 ++++++++ test/js/node/tls/node-tls-server.test.ts | 69 ++++++++++++++++++++++++ 3 files changed, 99 insertions(+) diff --git a/src/jsc/VirtualMachine.rs b/src/jsc/VirtualMachine.rs index 962044c4b021..bc6fb85c2a91 100644 --- a/src/jsc/VirtualMachine.rs +++ b/src/jsc/VirtualMachine.rs @@ -3623,6 +3623,14 @@ impl VirtualMachine { self.transpiler.env_mut().get_tls_reject_unauthorized() } + /// The TLS 1.2 cipher list assigned through `tls.DEFAULT_CIPHERS = ...` + /// (TLS 1.3 suite names already stripped), or `None` when never assigned. + pub fn tls_default_ciphers(&self) -> Option<&[u8]> { + self.rare_data + .as_deref() + .and_then(RareData::tls_default_ciphers) + } + /// Registers a spawned subprocess with the auto-killer. pub fn on_subprocess_spawn(&mut self, process: core::ptr::NonNull) { self.auto_killer.on_subprocess_spawn(process); diff --git a/src/runtime/socket/SSLConfig.rs b/src/runtime/socket/SSLConfig.rs index 3823142abe5f..2e5814481911 100644 --- a/src/runtime/socket/SSLConfig.rs +++ b/src/runtime/socket/SSLConfig.rs @@ -252,6 +252,8 @@ impl SSLConfigFromJs for SSLConfig { result.ssl_ciphers = zbox_into_raw(&ciphers.to_owned_slice_z()); result.is_using_default_ciphers = false; result.requires_custom_request_ctx = true; + } else { + apply_default_ciphers(vm, &mut result); } result.client_renegotiation_limit = generated.client_renegotiation_limit; @@ -271,9 +273,29 @@ impl SSLConfigFromJs for SSLConfig { pub fn tls_true_defaults(vm: &VirtualMachine) -> SSLConfig { let mut cfg = SSLConfig::zero(); cfg.reject_unauthorized = vm.get_tls_reject_unauthorized() as i32; + apply_default_ciphers(vm, &mut cfg); cfg } +/// `tls.DEFAULT_CIPHERS = ...` sets the cipher list of every context built +/// without a `ciphers` option, servers included, like Node's +/// configSecureContext. A list with no TLS 1.2 cipher left raises the +/// protocol floor to TLS 1.3 the way Node does, instead of leaving +/// BoringSSL's built-in TLS 1.2 list in effect. +fn apply_default_ciphers(vm: &VirtualMachine, cfg: &mut SSLConfig) { + let Some(ciphers) = vm.tls_default_ciphers() else { + return; + }; + cfg.ssl_ciphers = dupe_z(ciphers); + let tls1_3 = i32::from(bun_boringssl_sys::TLS1_3_VERSION); + if ciphers.is_empty() + && cfg.ssl_min_version < tls1_3 + && (cfg.ssl_max_version == 0 || cfg.ssl_max_version >= tls1_3) + { + cfg.ssl_min_version = tls1_3; + } +} + /// Whether a new TLS socket must enforce `rejectUnauthorized`: close the /// connection when the peer certificate fails verification. pub fn resolve_reject_unauthorized( diff --git a/test/js/node/tls/node-tls-server.test.ts b/test/js/node/tls/node-tls-server.test.ts index 707474890f20..95441ff63bb4 100644 --- a/test/js/node/tls/node-tls-server.test.ts +++ b/test/js/node/tls/node-tls-server.test.ts @@ -2685,3 +2685,72 @@ describe("pauseOnConnect", () => { } }); }); + +// tls.DEFAULT_CIPHERS is the process-wide cipher policy. Node applies it to +// every secure context built without an explicit `ciphers`, so a server +// created after the assignment refuses the suites the policy excludes. +it("tls.DEFAULT_CIPHERS applies to servers created without a ciphers option", async () => { + const script = ` + import tls from "node:tls"; + import https from "node:https"; + import { once } from "node:events"; + const cert = ${JSON.stringify({ key: cert1.key, cert: cert1.cert })}; + tls.DEFAULT_CIPHERS = "ECDHE-RSA-AES128-GCM-SHA256"; + + function probe(port, ciphers) { + return new Promise(resolve => { + const c = tls.connect({ port, host: "127.0.0.1", ciphers, maxVersion: "TLSv1.2", rejectUnauthorized: false }); + c.on("secureConnect", () => { + const name = c.getCipher().name; + c.end(); + resolve("ok:" + name); + }); + c.on("error", e => resolve("err:" + e.code)); + }); + } + async function run(server) { + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + const port = server.address().port; + const excluded = await probe(port, "ECDHE-RSA-AES256-GCM-SHA384"); + const allowed = await probe(port, "ECDHE-RSA-AES128-GCM-SHA256"); + server.close(); + return { excluded, allowed }; + } + const results = { + tls: await run(tls.createServer(cert, s => s.end("x"))), + https: await run(https.createServer(cert, (req, res) => res.end("x"))), + }; + { + const server = Bun.serve({ port: 0, tls: cert, fetch: () => new Response("x") }); + results.serve = { + excluded: await probe(server.port, "ECDHE-RSA-AES256-GCM-SHA384"), + allowed: await probe(server.port, "ECDHE-RSA-AES128-GCM-SHA256"), + }; + await server.stop(true); + } + // Only TLS 1.3 suite names leaves no TLS 1.2 cipher, so the protocol + // floor becomes TLS 1.3 (Node's configSecureContext does the same). + tls.DEFAULT_CIPHERS = "TLS_AES_128_GCM_SHA256"; + results.tls13Only = await run(tls.createServer(cert, s => s.end("x"))); + console.log(JSON.stringify(results)); + `; + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", script], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stderr).toBe(""); + expect(JSON.parse(stdout.trim())).toEqual({ + tls: { excluded: "err:ERR_SSL_SSLV3_ALERT_HANDSHAKE_FAILURE", allowed: "ok:ECDHE-RSA-AES128-GCM-SHA256" }, + https: { excluded: "err:ERR_SSL_SSLV3_ALERT_HANDSHAKE_FAILURE", allowed: "ok:ECDHE-RSA-AES128-GCM-SHA256" }, + serve: { excluded: "err:ERR_SSL_SSLV3_ALERT_HANDSHAKE_FAILURE", allowed: "ok:ECDHE-RSA-AES128-GCM-SHA256" }, + tls13Only: { + excluded: "err:ERR_SSL_TLSV1_ALERT_PROTOCOL_VERSION", + allowed: "err:ERR_SSL_TLSV1_ALERT_PROTOCOL_VERSION", + }, + }); + expect(exitCode).toBe(0); +}); From 4d09a54f454af6c62fbbc4950bc4839dc6ad2ae1 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 7 Sep 2026 06:19:43 +0000 Subject: [PATCH 2/5] tls: raise the floor to TLS 1.3 for a 1.3-only default list regardless of maxVersion With maxVersion pinned below 1.3 this leaves no version to negotiate, so every handshake fails instead of BoringSSL's built-in TLS 1.2 list staying in effect. Same outcome as Node's emptied TLS 1.2 list, and the same rule the explicit ciphers option already follows in tls.ts. --- src/runtime/socket/SSLConfig.rs | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/src/runtime/socket/SSLConfig.rs b/src/runtime/socket/SSLConfig.rs index 2e5814481911..448236be1e57 100644 --- a/src/runtime/socket/SSLConfig.rs +++ b/src/runtime/socket/SSLConfig.rs @@ -280,18 +280,17 @@ pub fn tls_true_defaults(vm: &VirtualMachine) -> SSLConfig { /// `tls.DEFAULT_CIPHERS = ...` sets the cipher list of every context built /// without a `ciphers` option, servers included, like Node's /// configSecureContext. A list with no TLS 1.2 cipher left raises the -/// protocol floor to TLS 1.3 the way Node does, instead of leaving -/// BoringSSL's built-in TLS 1.2 list in effect. +/// protocol floor to TLS 1.3, because BoringSSL keeps its built-in TLS 1.2 +/// list when `SSL_CTX_set_cipher_list("")` matches nothing. With an explicit +/// `maxVersion` below 1.3 that leaves no version to negotiate and every +/// handshake fails, the outcome Node's emptied TLS 1.2 list gives. fn apply_default_ciphers(vm: &VirtualMachine, cfg: &mut SSLConfig) { let Some(ciphers) = vm.tls_default_ciphers() else { return; }; cfg.ssl_ciphers = dupe_z(ciphers); let tls1_3 = i32::from(bun_boringssl_sys::TLS1_3_VERSION); - if ciphers.is_empty() - && cfg.ssl_min_version < tls1_3 - && (cfg.ssl_max_version == 0 || cfg.ssl_max_version >= tls1_3) - { + if ciphers.is_empty() && cfg.ssl_min_version < tls1_3 { cfg.ssl_min_version = tls1_3; } } From d1974ce38377d415a80dde405e5b5cf42a6b6eaa Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 7 Sep 2026 06:32:04 +0000 Subject: [PATCH 3/5] tls: shorten the default-ciphers doc comments --- src/jsc/VirtualMachine.rs | 3 +-- src/runtime/socket/SSLConfig.rs | 11 ++++------- 2 files changed, 5 insertions(+), 9 deletions(-) diff --git a/src/jsc/VirtualMachine.rs b/src/jsc/VirtualMachine.rs index bc6fb85c2a91..9089e3abbb0a 100644 --- a/src/jsc/VirtualMachine.rs +++ b/src/jsc/VirtualMachine.rs @@ -3623,8 +3623,7 @@ impl VirtualMachine { self.transpiler.env_mut().get_tls_reject_unauthorized() } - /// The TLS 1.2 cipher list assigned through `tls.DEFAULT_CIPHERS = ...` - /// (TLS 1.3 suite names already stripped), or `None` when never assigned. + /// The TLS 1.2 list assigned through `tls.DEFAULT_CIPHERS`, `None` when never assigned. pub fn tls_default_ciphers(&self) -> Option<&[u8]> { self.rare_data .as_deref() diff --git a/src/runtime/socket/SSLConfig.rs b/src/runtime/socket/SSLConfig.rs index 448236be1e57..0c4dd12318fb 100644 --- a/src/runtime/socket/SSLConfig.rs +++ b/src/runtime/socket/SSLConfig.rs @@ -277,18 +277,15 @@ pub fn tls_true_defaults(vm: &VirtualMachine) -> SSLConfig { cfg } -/// `tls.DEFAULT_CIPHERS = ...` sets the cipher list of every context built -/// without a `ciphers` option, servers included, like Node's -/// configSecureContext. A list with no TLS 1.2 cipher left raises the -/// protocol floor to TLS 1.3, because BoringSSL keeps its built-in TLS 1.2 -/// list when `SSL_CTX_set_cipher_list("")` matches nothing. With an explicit -/// `maxVersion` below 1.3 that leaves no version to negotiate and every -/// handshake fails, the outcome Node's emptied TLS 1.2 list gives. +/// No `ciphers` option means `tls.DEFAULT_CIPHERS`, like Node's configSecureContext. fn apply_default_ciphers(vm: &VirtualMachine, cfg: &mut SSLConfig) { let Some(ciphers) = vm.tls_default_ciphers() else { return; }; cfg.ssl_ciphers = dupe_z(ciphers); + // Only TLS 1.3 names were assigned: `SSL_CTX_set_cipher_list("")` keeps + // BoringSSL's built-in TLS 1.2 list, so raise the floor instead (Node + // does the same in configSecureContext). let tls1_3 = i32::from(bun_boringssl_sys::TLS1_3_VERSION); if ciphers.is_empty() && cfg.ssl_min_version < tls1_3 { cfg.ssl_min_version = tls1_3; From 18966f0088efd2a0ee07d5fab0d3db8de7787842 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 8 Sep 2026 01:27:49 +0000 Subject: [PATCH 4/5] tls: new SecureContext() with no options also takes tls.DEFAULT_CIPHERS; run the test probes concurrently --- src/runtime/api/bun/SecureContext.rs | 8 ++-- src/runtime/socket/SSLConfig.rs | 5 +-- test/js/node/tls/node-tls-server.test.ts | 49 ++++++++++++++---------- 3 files changed, 35 insertions(+), 27 deletions(-) diff --git a/src/runtime/api/bun/SecureContext.rs b/src/runtime/api/bun/SecureContext.rs index e44ee0dbe2a4..c40516129c02 100644 --- a/src/runtime/api/bun/SecureContext.rs +++ b/src/runtime/api/bun/SecureContext.rs @@ -15,7 +15,7 @@ use crate::crypto::boringssl_jsc::err_to_js; use crate::socket::uws_jsc::create_bun_socket_error_to_js; -use crate::socket::{SSLConfig, SSLConfigFromJs}; +use crate::socket::{SSLConfig, SSLConfigFromJs, tls_true_defaults}; use bun_boringssl_sys as boringssl; use bun_core::EncodedSlice; use bun_jsc::EncodedSliceJsc as _; @@ -73,7 +73,7 @@ impl SecureContext { // SAFETY: `bun_vm()` returns the live per-global VM pointer; valid for the call. let vm = global.bun_vm().as_mut(); - let config = SSLConfig::from_js(vm, global, opts)?.unwrap_or_else(SSLConfig::zero); + let config = SSLConfig::from_js(vm, global, opts)?.unwrap_or_else(|| tls_true_defaults(vm)); // `defer config.deinit()` — handled by Drop. SecureContext::create(global, &config) @@ -231,7 +231,7 @@ impl SecureContext { // SAFETY: `bun_vm()` returns the live per-global VM pointer; valid for the call. let vm = global.bun_vm().as_mut(); - let config = SSLConfig::from_js(vm, global, opts)?.unwrap_or_else(SSLConfig::zero); + let config = SSLConfig::from_js(vm, global, opts)?.unwrap_or_else(|| tls_true_defaults(vm)); // `defer config.deinit()` — handled by Drop. let ctx_opts = config.as_usockets(); @@ -271,7 +271,7 @@ impl SecureContext { // SAFETY: `bun_vm()` returns the live per-global VM pointer; valid for the call. let vm = global.bun_vm().as_mut(); - let config = SSLConfig::from_js(vm, global, opts)?.unwrap_or_else(SSLConfig::zero); + let config = SSLConfig::from_js(vm, global, opts)?.unwrap_or_else(|| tls_true_defaults(vm)); // `defer config.deinit()` — handled by Drop. let ctx_opts = config.as_usockets(); diff --git a/src/runtime/socket/SSLConfig.rs b/src/runtime/socket/SSLConfig.rs index 0c4dd12318fb..326844608651 100644 --- a/src/runtime/socket/SSLConfig.rs +++ b/src/runtime/socket/SSLConfig.rs @@ -283,9 +283,8 @@ fn apply_default_ciphers(vm: &VirtualMachine, cfg: &mut SSLConfig) { return; }; cfg.ssl_ciphers = dupe_z(ciphers); - // Only TLS 1.3 names were assigned: `SSL_CTX_set_cipher_list("")` keeps - // BoringSSL's built-in TLS 1.2 list, so raise the floor instead (Node - // does the same in configSecureContext). + // `SSL_CTX_set_cipher_list("")` keeps BoringSSL's built-in TLS 1.2 list, + // so a TLS 1.3-only policy is enforced through the version floor instead. let tls1_3 = i32::from(bun_boringssl_sys::TLS1_3_VERSION); if ciphers.is_empty() && cfg.ssl_min_version < tls1_3 { cfg.ssl_min_version = tls1_3; diff --git a/test/js/node/tls/node-tls-server.test.ts b/test/js/node/tls/node-tls-server.test.ts index 95441ff63bb4..08961b743b98 100644 --- a/test/js/node/tls/node-tls-server.test.ts +++ b/test/js/node/tls/node-tls-server.test.ts @@ -2695,7 +2695,6 @@ it("tls.DEFAULT_CIPHERS applies to servers created without a ciphers option", as import https from "node:https"; import { once } from "node:events"; const cert = ${JSON.stringify({ key: cert1.key, cert: cert1.cert })}; - tls.DEFAULT_CIPHERS = "ECDHE-RSA-AES128-GCM-SHA256"; function probe(port, ciphers) { return new Promise(resolve => { @@ -2708,31 +2707,41 @@ it("tls.DEFAULT_CIPHERS applies to servers created without a ciphers option", as c.on("error", e => resolve("err:" + e.code)); }); } - async function run(server) { + async function listen(server) { server.listen(0, "127.0.0.1"); await once(server, "listening"); - const port = server.address().port; - const excluded = await probe(port, "ECDHE-RSA-AES256-GCM-SHA384"); - const allowed = await probe(port, "ECDHE-RSA-AES128-GCM-SHA256"); - server.close(); - return { excluded, allowed }; - } - const results = { - tls: await run(tls.createServer(cert, s => s.end("x"))), - https: await run(https.createServer(cert, (req, res) => res.end("x"))), - }; - { - const server = Bun.serve({ port: 0, tls: cert, fetch: () => new Response("x") }); - results.serve = { - excluded: await probe(server.port, "ECDHE-RSA-AES256-GCM-SHA384"), - allowed: await probe(server.port, "ECDHE-RSA-AES128-GCM-SHA256"), - }; - await server.stop(true); + return server; } + // The policy in effect when a server starts listening is the one it keeps. + tls.DEFAULT_CIPHERS = "ECDHE-RSA-AES128-GCM-SHA256"; + const tlsServer = await listen(tls.createServer(cert, s => s.end("x"))); + const httpsServer = await listen(https.createServer(cert, (req, res) => res.end("x"))); + const bunServer = Bun.serve({ port: 0, tls: cert, fetch: () => new Response("x") }); // Only TLS 1.3 suite names leaves no TLS 1.2 cipher, so the protocol // floor becomes TLS 1.3 (Node's configSecureContext does the same). tls.DEFAULT_CIPHERS = "TLS_AES_128_GCM_SHA256"; - results.tls13Only = await run(tls.createServer(cert, s => s.end("x"))); + const tls13OnlyServer = await listen(tls.createServer(cert, s => s.end("x"))); + + const ports = { + tls: tlsServer.address().port, + https: httpsServer.address().port, + serve: bunServer.port, + tls13Only: tls13OnlyServer.address().port, + }; + const results = {}; + await Promise.all( + Object.entries(ports).map(async ([name, port]) => { + const [excluded, allowed] = await Promise.all([ + probe(port, "ECDHE-RSA-AES256-GCM-SHA384"), + probe(port, "ECDHE-RSA-AES128-GCM-SHA256"), + ]); + results[name] = { excluded, allowed }; + }), + ); + tlsServer.close(); + httpsServer.close(); + tls13OnlyServer.close(); + await bunServer.stop(true); console.log(JSON.stringify(results)); `; await using proc = Bun.spawn({ From cff30182464ba56c4a7bb39d8b85d43988be3a5f Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 8 Sep 2026 01:46:14 +0000 Subject: [PATCH 5/5] tls: trim comments --- src/runtime/socket/SSLConfig.rs | 3 +-- test/js/node/tls/node-tls-server.test.ts | 2 -- 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/src/runtime/socket/SSLConfig.rs b/src/runtime/socket/SSLConfig.rs index 326844608651..69a95264e576 100644 --- a/src/runtime/socket/SSLConfig.rs +++ b/src/runtime/socket/SSLConfig.rs @@ -283,8 +283,7 @@ fn apply_default_ciphers(vm: &VirtualMachine, cfg: &mut SSLConfig) { return; }; cfg.ssl_ciphers = dupe_z(ciphers); - // `SSL_CTX_set_cipher_list("")` keeps BoringSSL's built-in TLS 1.2 list, - // so a TLS 1.3-only policy is enforced through the version floor instead. + // An empty TLS 1.2 list would leave BoringSSL's built-in one in effect. let tls1_3 = i32::from(bun_boringssl_sys::TLS1_3_VERSION); if ciphers.is_empty() && cfg.ssl_min_version < tls1_3 { cfg.ssl_min_version = tls1_3; diff --git a/test/js/node/tls/node-tls-server.test.ts b/test/js/node/tls/node-tls-server.test.ts index 08961b743b98..ab8eb1574ea3 100644 --- a/test/js/node/tls/node-tls-server.test.ts +++ b/test/js/node/tls/node-tls-server.test.ts @@ -2717,8 +2717,6 @@ it("tls.DEFAULT_CIPHERS applies to servers created without a ciphers option", as const tlsServer = await listen(tls.createServer(cert, s => s.end("x"))); const httpsServer = await listen(https.createServer(cert, (req, res) => res.end("x"))); const bunServer = Bun.serve({ port: 0, tls: cert, fetch: () => new Response("x") }); - // Only TLS 1.3 suite names leaves no TLS 1.2 cipher, so the protocol - // floor becomes TLS 1.3 (Node's configSecureContext does the same). tls.DEFAULT_CIPHERS = "TLS_AES_128_GCM_SHA256"; const tls13OnlyServer = await listen(tls.createServer(cert, s => s.end("x")));