diff --git a/src/jsc/VirtualMachine.rs b/src/jsc/VirtualMachine.rs index 962044c4b021..9089e3abbb0a 100644 --- a/src/jsc/VirtualMachine.rs +++ b/src/jsc/VirtualMachine.rs @@ -3623,6 +3623,13 @@ impl VirtualMachine { self.transpiler.env_mut().get_tls_reject_unauthorized() } + /// 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() + .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/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 3823142abe5f..69a95264e576 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,23 @@ 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 } +/// 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); + // 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; + } +} + /// 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..ab8eb1574ea3 100644 --- a/test/js/node/tls/node-tls-server.test.ts +++ b/test/js/node/tls/node-tls-server.test.ts @@ -2685,3 +2685,79 @@ 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 })}; + + 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 listen(server) { + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + 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") }); + tls.DEFAULT_CIPHERS = "TLS_AES_128_GCM_SHA256"; + 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({ + 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); +});