diff --git a/packages/bun-usockets/src/crypto/openssl.c b/packages/bun-usockets/src/crypto/openssl.c index 32d451e322be..cfe8f26687a5 100644 --- a/packages/bun-usockets/src/crypto/openssl.c +++ b/packages/bun-usockets/src/crypto/openssl.c @@ -191,6 +191,12 @@ static int us_ssl_pending_keylog_idx = -1; * received NewSessionTicket, so SSL_get_session() alone gives an unresumable * snapshot; node:tls's getSession()/getTLSTicket() read from here instead. */ static int us_ssl_new_session_ref_idx = -1; +/* Optional per-SSL sink for resumable sessions: an owner pointer plus a + * callback that receives each SSL_SESSION_up_ref'd session. Checked before the + * us_ssl_is_socket_ex_idx opt-in so consumers that don't surface a JS + * 'session' event (fetch) can still cache without paying the serialized + * pending-session queue. */ +static int us_ssl_session_sink_idx = -1; #ifdef _WIN32 static INIT_ONCE us_ex_idx_once = INIT_ONCE_STATIC_INIT; #else @@ -282,6 +288,20 @@ static void us_ssl_new_session_ref_free(void *parent, void *ptr, CRYPTO_EX_DATA (void)parent; (void)ad; (void)index; (void)argl; (void)argp; if (ptr) SSL_SESSION_free((SSL_SESSION *)ptr); } + +struct us_ssl_session_sink_t { + void *owner; + void (*on_new_session)(void *owner, SSL_SESSION *session); + void (*on_free)(void *owner); +}; +static void us_ssl_session_sink_free(void *parent, void *ptr, CRYPTO_EX_DATA *ad, + int index, long argl, void *argp) { + (void)parent; (void)ad; (void)index; (void)argl; (void)argp; + if (!ptr) return; + struct us_ssl_session_sink_t *sink = ptr; + if (sink->on_free) sink->on_free(sink->owner); + us_free(sink); +} /* NSS key-log lines are produced from inside SSL_do_handshake/SSL_read, so * they are parked on the SSL the same way new sessions are and delivered once * the read unwinds. The stored bytes already carry the trailing newline Node @@ -334,12 +354,21 @@ static void ssl_flush_pending_keylog(struct us_socket_t *s) { } static int us_ssl_new_session_cb(SSL *ssl, SSL_SESSION *session) { + /* The session sink is the cheap path: hand the session to an owner-provided + * callback (one up_ref, no i2d serialize, no queue). Used by the HTTP + * client's per-origin session cache. */ + struct us_ssl_session_sink_t *sink = SSL_get_ex_data(ssl, us_ssl_session_sink_idx); + if (sink && sink->on_new_session) { + SSL_SESSION_up_ref(session); + sink->on_new_session(sink->owner, session); + return 0; + } /* Park only for consumers that will drain the queue: SSLs attached to a * real us_socket_t (flushed into us_dispatch_session once the read unwinds) * and SSLs whose owner opted in via us_ssl_enable_pending_events (the * Rust SSLWrapper behind TLS-over-duplex / named pipes, which polls - * us_ssl_pop_pending_session after its reads). Everything else (fetch, - * WebSocket tunnels) has no consumer - don't queue. */ + * us_ssl_pop_pending_session after its reads). Everything else (WebSocket + * tunnels) has no consumer - don't queue. */ if (!SSL_get_ex_data(ssl, us_ssl_is_socket_ex_idx)) { return 0; } @@ -421,6 +450,7 @@ static void us_ex_idx_init(void) { us_ssl_pending_session_idx = SSL_get_ex_new_index(0, NULL, NULL, NULL, us_ssl_pending_session_free); us_ssl_pending_keylog_idx = SSL_get_ex_new_index(0, NULL, NULL, NULL, us_ssl_pending_session_free); us_ssl_new_session_ref_idx = SSL_get_ex_new_index(0, NULL, NULL, NULL, us_ssl_new_session_ref_free); + us_ssl_session_sink_idx = SSL_get_ex_new_index(0, NULL, NULL, NULL, us_ssl_session_sink_free); } #ifdef _WIN32 @@ -444,6 +474,37 @@ static inline int us_ssl_ctx_ex_idx(void) { return us_ctx_ex_idx; } +/* Install a session sink on `ssl`: each resumable session reaching the + * new-session callback is SSL_SESSION_up_ref'd and passed to `on_new_session` + * (which takes ownership of that reference). `on_free(owner)` runs once on + * SSL_free. Replacing an existing sink first frees the old one. */ +void us_ssl_set_session_sink(SSL *ssl, void *owner, + void (*on_new_session)(void *, SSL_SESSION *), + void (*on_free)(void *)) { + us_ex_idx_ensure(); + struct us_ssl_session_sink_t *sink = us_malloc(sizeof(*sink)); + if (!sink) { + if (on_free) on_free(owner); + return; + } + sink->owner = owner; + sink->on_new_session = on_new_session; + sink->on_free = on_free; + struct us_ssl_session_sink_t *old = SSL_get_ex_data(ssl, us_ssl_session_sink_idx); + SSL_set_ex_data(ssl, us_ssl_session_sink_idx, sink); + if (old) { + if (old->on_free) old->on_free(old->owner); + us_free(old); + } +} + +/* The `owner` pointer installed via us_ssl_set_session_sink, or NULL. */ +void *us_ssl_get_session_sink_owner(SSL *ssl) { + if (us_ssl_session_sink_idx < 0) return NULL; + struct us_ssl_session_sink_t *sink = SSL_get_ex_data(ssl, us_ssl_session_sink_idx); + return sink ? sink->owner : NULL; +} + /* TLS-over-duplex / named-pipe owners (the Rust SSLWrapper): opt this SSL * into the parked session/keylog queues so us_ssl_new_session_cb / * us_ssl_keylog_cb collect them. There is no us_socket_t to flush into diff --git a/packages/bun-usockets/src/libusockets.h b/packages/bun-usockets/src/libusockets.h index 105fdd599670..5266b5812da8 100644 --- a/packages/bun-usockets/src/libusockets.h +++ b/packages/bun-usockets/src/libusockets.h @@ -554,6 +554,13 @@ int us_ssl_pop_pending_keylog(struct ssl_st *ssl, unsigned char *out, int out_ca /* The resumable session most recently delivered via the new-session callback, * or NULL if none. Borrowed; valid until the next NewSessionTicket or SSL_free. */ struct ssl_session_st *us_ssl_get_new_session(struct ssl_st *ssl); +/* Per-SSL session sink: each resumable session reaching the new-session + * callback is SSL_SESSION_up_ref'd and handed to on_new_session (which takes + * ownership of that reference). on_free(owner) runs once on SSL_free. */ +void us_ssl_set_session_sink(struct ssl_st *ssl, void *owner, + void (*on_new_session)(void *, struct ssl_session_st *), + void (*on_free)(void *)); +void *us_ssl_get_session_sink_owner(struct ssl_st *ssl); /* Public interfaces for loops */ diff --git a/src/boringssl/lib.rs b/src/boringssl/lib.rs index 684fd1d8109f..0bf0c715c3ac 100644 --- a/src/boringssl/lib.rs +++ b/src/boringssl/lib.rs @@ -94,30 +94,38 @@ pub unsafe fn ssl_ctx_setup(ctx: *mut boring::SSL_CTX) { // into the process, including pthreads locks. Failing to meet these constraints // may result in deadlocks, crashes, or memory corruption. +// Routed through `default_alloc` (mimalloc, or libc under `cfg(bun_asan)`) +// rather than `mimalloc` directly. The BoringSSL build already drops +// `BORINGSSL_REQUIRE_MEMORY_HOOKS` under ASAN so Mach-O/COFF fall back to +// libc, but on ELF the weak hook symbols still resolve to these definitions; +// hard-coding mimalloc here put every `OPENSSL_malloc` allocation outside +// LeakSanitizer's scanned set and any libc-backed allocation reachable only +// via one (an SSL's ex_data array, the per-SSL session-sink owner it holds) +// was reported as a leak at exit. #[unsafe(no_mangle)] pub(crate) extern "C" fn OPENSSL_memory_alloc(size: usize) -> *mut c_void { - bun_alloc::mimalloc::mi_malloc(size) + bun_alloc::default_alloc::malloc(size) } // BoringSSL always expects memory to be zero'd /// # Safety -/// `ptr` must be non-null and have been returned by `OPENSSL_memory_alloc` -/// (i.e. `mi_malloc`); BoringSSL guarantees both for this hook. +/// `ptr` must be non-null and returned by `OPENSSL_memory_alloc`; BoringSSL +/// guarantees both for this hook. #[unsafe(no_mangle)] pub(crate) unsafe extern "C" fn OPENSSL_memory_free(ptr: *mut c_void) { // SAFETY: BoringSSL guarantees ptr is non-null and was returned by - // OPENSSL_memory_alloc above (i.e. mi_malloc). + // OPENSSL_memory_alloc above. unsafe { - let len = bun_alloc::usable_size(ptr.cast()); + let len = bun_alloc::default_alloc::usable_size(ptr); ptr::write_bytes(ptr.cast::(), 0, len); - bun_alloc::mimalloc::mi_free(ptr); + bun_alloc::default_alloc::free(ptr); } } #[unsafe(no_mangle)] pub(crate) extern "C" fn OPENSSL_memory_get_size(ptr: *const c_void) -> usize { - // ptr was returned by mi_malloc (or is null, which usable_size handles). - bun_alloc::usable_size(ptr.cast()) + // SAFETY: ptr was returned by OPENSSL_memory_alloc (null-safe). + unsafe { bun_alloc::default_alloc::usable_size(ptr) } } pub use bun_sys::posix::INET6_ADDRSTRLEN; diff --git a/src/bun_alloc/lib.rs b/src/bun_alloc/lib.rs index e1ad9e7cf98b..2fd5d857d129 100644 --- a/src/bun_alloc/lib.rs +++ b/src/bun_alloc/lib.rs @@ -271,8 +271,7 @@ pub mod default_alloc { /// # Safety /// `ptr` must be null or a live allocation from the default allocator. #[inline] - #[cfg(any(debug_assertions, bun_asan))] - pub(crate) unsafe fn usable_size(ptr: *const c_void) -> usize { + pub unsafe fn usable_size(ptr: *const c_void) -> usize { if ptr.is_null() { return 0; } diff --git a/src/bun_core/env_var.rs b/src/bun_core/env_var.rs index 6d1d0f45ef20..3e3d734b210f 100644 --- a/src/bun_core/env_var.rs +++ b/src/bun_core/env_var.rs @@ -208,6 +208,7 @@ pub mod feature_flag { new_feature_flag!(pub BUN_FEATURE_FLAG_DISABLE_ASYNC_TRANSPILER, "BUN_FEATURE_FLAG_DISABLE_ASYNC_TRANSPILER", {}); new_feature_flag!(pub BUN_FEATURE_FLAG_DISABLE_ISOLATION_SOURCE_CACHE, "BUN_FEATURE_FLAG_DISABLE_ISOLATION_SOURCE_CACHE", {}); new_feature_flag!(pub BUN_FEATURE_FLAG_DISABLE_DNS_CACHE, "BUN_FEATURE_FLAG_DISABLE_DNS_CACHE", {}); + new_feature_flag!(pub BUN_FEATURE_FLAG_DISABLE_FETCH_TLS_SESSION_CACHE, "BUN_FEATURE_FLAG_DISABLE_FETCH_TLS_SESSION_CACHE", {}); new_feature_flag!(pub BUN_FEATURE_FLAG_DISABLE_DNS_CACHE_LIBINFO, "BUN_FEATURE_FLAG_DISABLE_DNS_CACHE_LIBINFO", {}); // Force the event loop to use epoll_pwait(2) instead of epoll_pwait2(2). // Escape hatch for seccomp policies that block syscall 441 without diff --git a/src/http/HTTPContext.rs b/src/http/HTTPContext.rs index 0296d53ab252..16734c09923f 100644 --- a/src/http/HTTPContext.rs +++ b/src/http/HTTPContext.rs @@ -18,7 +18,7 @@ use bun_uws as uws; bun_core::declare_scope!(HTTPContext, hidden); const POOL_SIZE: usize = 64; -const MAX_KEEPALIVE_HOSTNAME: usize = 128; +pub(crate) const MAX_KEEPALIVE_HOSTNAME: usize = 128; /// The const-generic `SSL` is load-bearing for monomorphization (gates hot /// inner-loop branches); do not demote to a runtime bool. @@ -55,6 +55,8 @@ pub struct HTTPContext { // into the box interior; unboxing would dangle it on `Vec` realloc. #[expect(clippy::vec_box)] pub(crate) pending_h2_connects: Vec>, + /// Client-side TLS session cache; populated only when `SSL`. + pub(crate) session_cache: crate::session_cache::SessionCache, } // Intrusive refcount: @@ -1177,6 +1179,12 @@ impl Handler { // `client` again. return; } + // Peer chain + hostname verified: let the session sink + // flush its pending TLS 1.2 ticket (parked before this + // dispatch) and cache later TLS 1.3 tickets directly. + // SAFETY: `ssl` is the live handle for this socket on the + // HTTP thread. + unsafe { crate::session_cache::arm(ssl) }; } return client.first_call::(socket); diff --git a/src/http/HTTPThread.rs b/src/http/HTTPThread.rs index 1118cc0717cb..c832568d9d69 100644 --- a/src/http/HTTPThread.rs +++ b/src/http/HTTPThread.rs @@ -162,6 +162,7 @@ impl HttpThread { secure: None, active_h2_sessions: Vec::new(), pending_h2_connects: Vec::new(), + session_cache: crate::session_cache::SessionCache::new(), }, https_context: NewHttpContext:: { ref_count: Cell::new(1), @@ -170,6 +171,7 @@ impl HttpThread { secure: None, active_h2_sessions: Vec::new(), pending_h2_connects: Vec::new(), + session_cache: crate::session_cache::SessionCache::new(), }, lazy_https_init: None, queued_tasks: Queue::new(), @@ -530,6 +532,7 @@ impl HttpThread { secure: None, active_h2_sessions: Vec::new(), pending_h2_connects: Vec::new(), + session_cache: crate::session_cache::SessionCache::new(), })); if let Err(err) = custom_context.init_with_client_config(client) { // `init_with_client_config` fails before `group.init()` runs. diff --git a/src/http/lib.rs b/src/http/lib.rs index a82d14ae9c22..1241d0286fe1 100644 --- a/src/http/lib.rs +++ b/src/http/lib.rs @@ -39,6 +39,8 @@ pub mod lshpack; pub mod proxy_tunnel; #[path = "SendFile.rs"] pub mod send_file; +#[path = "session_cache.rs"] +pub mod session_cache; #[path = "Signals.rs"] pub mod signals; #[path = "ThreadSafeStreamBuffer.rs"] @@ -1851,6 +1853,28 @@ impl<'a> HTTPClient<'a> { host_z, self.alpn_offer(), ); + + if crate::session_cache::eligible(self) { + let want_tunnel = self.http_proxy.is_some() && self.url.is_https(); + // SAFETY: `ssl_ptr` is live and pre-handshake (guarded by + // `SSL_is_init_finished == 0` above); `get_ssl_ctx` returns + // the static `https_context` or the heap context this + // client holds a strong ref on, both of which outlive + // every SSL attached to their socket group. + unsafe { + crate::session_cache::install( + ssl_ptr, + self.get_ssl_ctx::(), + self.connected_url.hostname, + self.connected_url.get_port_auto(), + if want_tunnel || self.http_proxy.is_none() { + self.proxy_auth_hash() + } else { + 0 + }, + ); + } + } } } else { self.first_call::(socket); diff --git a/src/http/session_cache.rs b/src/http/session_cache.rs new file mode 100644 index 000000000000..a1ba85bfc941 --- /dev/null +++ b/src/http/session_cache.rs @@ -0,0 +1,262 @@ +//! Client-side TLS session cache for `fetch()`. +//! +//! Keyed on the keep-alive pool tuple `(hostname, port, proxy_auth_hash)` and +//! scoped to one [`HTTPContext`] per interned `SSLConfig`. A sink is +//! installed before the handshake and armed only after `checkServerIdentity` +//! passes, so an unverified handshake never inserts: a resumed handshake +//! restores the stored `verify_result` without a Certificate message, and +//! caching an unverified session would launder it into a later strict caller. +//! HTTP-thread-only. + +use core::cell::RefCell; +use core::ffi::c_void; +use core::ptr::NonNull; + +use bun_boringssl_sys::{SSL, SSL_SESSION, SSL_SESSION_free, SSL_set_session}; +use bun_core::strings; + +use crate::http_context::MAX_KEEPALIVE_HOSTNAME; +use crate::signals; + +/// An `SSL_SESSION` retains the peer chain, so keep this well below rustls' 256. +const SESSION_CACHE_CAPACITY: usize = 32; + +struct CacheEntry { + hostname: Box<[u8]>, + port: u16, + proxy_auth_hash: u64, + /// `Option` so [`SessionCache::take`] can move ownership out. + session: Option>, +} + +impl Drop for CacheEntry { + fn drop(&mut self) { + if let Some(s) = self.session.take() { + // SAFETY: owns one reference from `SSL_SESSION_up_ref` in + // `us_ssl_new_session_cb`. + unsafe { SSL_SESSION_free(s.as_ptr()) }; + } + } +} + +#[derive(Default)] +pub(crate) struct SessionCache { + entries: RefCell>, +} + +impl SessionCache { + pub(crate) const fn new() -> Self { + Self { + entries: RefCell::new(Vec::new()), + } + } + + /// Remove and return the matching session (+1 ref). TLS 1.3 tickets are + /// single-use, so a hit consumes the entry. + pub(crate) fn take( + &self, + hostname: &[u8], + port: u16, + proxy_auth_hash: u64, + ) -> Option> { + if hostname.len() > MAX_KEEPALIVE_HOSTNAME { + return None; + } + let mut entries = self.entries.borrow_mut(); + let idx = entries.iter().position(|e| { + e.port == port + && e.proxy_auth_hash == proxy_auth_hash + && strings::eql_long(&e.hostname, hostname, true) + })?; + entries.remove(idx).session.take() + } + + /// Takes ownership of `session` (+1 ref). + fn insert( + &self, + hostname: &[u8], + port: u16, + proxy_auth_hash: u64, + session: NonNull, + ) { + if hostname.len() > MAX_KEEPALIVE_HOSTNAME { + // SAFETY: caller transferred one reference. + unsafe { SSL_SESSION_free(session.as_ptr()) }; + return; + } + let mut entries = self.entries.borrow_mut(); + if let Some(idx) = entries.iter().position(|e| { + e.port == port + && e.proxy_auth_hash == proxy_auth_hash + && strings::eql_long(&e.hostname, hostname, true) + }) { + let _ = entries.remove(idx); + } else if entries.len() >= SESSION_CACHE_CAPACITY { + let _ = entries.remove(0); + } + entries.push(CacheEntry { + hostname: Box::<[u8]>::from(hostname), + port, + proxy_auth_hash, + session: Some(session), + }); + } +} + +/// Per-`SSL` sink. Box-allocated; the ex_data slot on the `SSL` holds the raw +/// pointer and its free callback reclaims the Box on `SSL_free`. +pub(crate) struct SessionSink { + ctx: *const crate::HttpsContext, + hostname: Box<[u8]>, + port: u16, + proxy_auth_hash: u64, + /// Set once `checkServerIdentity` passes. TLS 1.2 delivers the session + /// inside `SSL_do_handshake`, before `on_handshake` can verify the peer. + armed: bool, + pending: Option>, +} + +impl Drop for SessionSink { + fn drop(&mut self) { + if let Some(p) = self.pending.take() { + // SAFETY: `pending` owns one reference. + unsafe { SSL_SESSION_free(p.as_ptr()) }; + } + } +} + +extern "C" fn sink_on_new_session(owner: *mut c_void, session: *mut SSL_SESSION) { + let Some(session) = NonNull::new(session) else { + return; + }; + let Some(owner) = NonNull::new(owner.cast::()) else { + // SAFETY: +1 reference received from C with no consumer. + unsafe { SSL_SESSION_free(session.as_ptr()) }; + return; + }; + // SAFETY: `owner` is the Box interior installed by [`install`]. Runs on + // the HTTP thread inside `SSL_read`/`SSL_do_handshake`; nothing else + // holds a borrow of the sink during that call. + let sink = unsafe { owner.as_ref() }; + if sink.armed { + // SAFETY: `ctx` is the `HTTPContext` owning this socket's + // group; it outlives every SSL attached to it. + unsafe { &*sink.ctx }.session_cache.insert( + &sink.hostname, + sink.port, + sink.proxy_auth_hash, + session, + ); + } else { + // SAFETY: unique access on the HTTP thread (see above). + let sink = unsafe { &mut *owner.as_ptr() }; + if let Some(prev) = sink.pending.replace(session) { + // SAFETY: `pending` owned one reference. + unsafe { SSL_SESSION_free(prev.as_ptr()) }; + } + } +} + +extern "C" fn sink_on_free(owner: *mut c_void) { + if owner.is_null() { + return; + } + // SAFETY: `owner` is the `heap::into_raw` from [`install`]; the ex_data + // free callback fires exactly once on `SSL_free`. + unsafe { bun_core::heap::destroy(owner.cast::()) }; +} + +unsafe extern "C" { + fn us_ssl_set_session_sink( + ssl: *mut SSL, + owner: *mut c_void, + on_new_session: Option, + on_free: Option, + ); + fn us_ssl_get_session_sink_owner(ssl: *mut SSL) -> *mut c_void; +} + +/// Whether this TLS client should read/write the cache. Lax verification and +/// the JS `checkServerIdentity` path are excluded because [`arm`] only runs +/// after the native identity check in `on_handshake`; neither reaches it. +pub(crate) fn eligible(client: &crate::HTTPClient<'_>) -> bool { + client.flags.reject_unauthorized + && !client.signals.get(signals::Field::CertErrors) + && client.unix_socket_path.slice().is_empty() + && !bun_core::env_var::feature_flag::BUN_FEATURE_FLAG_DISABLE_FETCH_TLS_SESSION_CACHE + .get() + .unwrap_or(false) +} + +/// Offer any cached session for this key and install an unarmed sink. +/// +/// # Safety +/// `ssl` must be a live pre-handshake `SSL*`; `ctx` must outlive `ssl`. +pub(crate) unsafe fn install( + ssl: *mut SSL, + ctx: *const crate::HttpsContext, + hostname: &[u8], + port: u16, + proxy_auth_hash: u64, +) { + debug_assert!(!ssl.is_null()); + debug_assert!(!ctx.is_null()); + // SAFETY: caller contract. + let cache = unsafe { &(*ctx).session_cache }; + if let Some(session) = cache.take(hostname, port, proxy_auth_hash) { + // SAFETY: `ssl` is live and pre-handshake; `SSL_set_session` takes + // its own reference, so release ours after. + unsafe { + SSL_set_session(ssl, session.as_ptr()); + SSL_SESSION_free(session.as_ptr()); + } + } + let sink = Box::new(SessionSink { + ctx, + hostname: Box::<[u8]>::from(hostname), + port, + proxy_auth_hash, + armed: false, + pending: None, + }); + // SAFETY: `ssl` is live; Box ownership moves to the ex_data slot. + unsafe { + us_ssl_set_session_sink( + ssl, + bun_core::heap::into_raw(sink).cast::(), + Some(sink_on_new_session), + Some(sink_on_free), + ); + } +} + +/// Flush the parked TLS 1.2 session and admit later TLS 1.3 tickets. +/// +/// # Safety +/// `ssl` must be a live `SSL*` on the HTTP thread. +pub(crate) unsafe fn arm(ssl: *mut SSL) { + if ssl.is_null() { + return; + } + // SAFETY: caller contract. + let owner = unsafe { us_ssl_get_session_sink_owner(ssl) }; + let Some(mut owner) = NonNull::new(owner.cast::()) else { + return; + }; + // SAFETY: `owner` is the Box interior installed by [`install`], live + // until `SSL_free`; HTTP-thread-only so this `&mut` is unique. + let sink = unsafe { owner.as_mut() }; + if sink.armed { + return; + } + sink.armed = true; + if let Some(session) = sink.pending.take() { + // SAFETY: `ctx` outlives `ssl` per [`install`]'s contract. + unsafe { &*sink.ctx }.session_cache.insert( + &sink.hostname, + sink.port, + sink.proxy_auth_hash, + session, + ); + } +} diff --git a/test/js/web/fetch/fetch.tls.session-resumption-fixture.ts b/test/js/web/fetch/fetch.tls.session-resumption-fixture.ts new file mode 100644 index 000000000000..b6fee557c2a1 --- /dev/null +++ b/test/js/web/fetch/fetch.tls.session-resumption-fixture.ts @@ -0,0 +1,134 @@ +// Observes server-side `isSessionReused()` across two sequential fetch() +// requests that each answer with `Connection: close`, so the second cannot +// reuse a keep-alive socket and must open a fresh TLS connection. A second +// value of `true` means fetch offered a cached session via SSL_set_session. +// +// argv[2]: "TLSv1.2" | "TLSv1.3" — pins both ends so the 1.2 and 1.3 ticket +// delivery paths (inside SSL_do_handshake vs post-handshake) are both covered. +// +// All scenarios run in one process; each uses its own server (fresh port), so +// the per-`(hostname, port, hash)` cache key keeps them isolated. +import tls from "node:tls"; +import type { AddressInfo } from "node:net"; +import { tls as cert } from "harness"; + +const version = (process.argv[2] ?? "TLSv1.3") as tls.SecureVersion; + +function makeServer() { + const reused: boolean[] = []; + const connections: tls.TLSSocket[] = []; + const server = tls.createServer({ + key: cert.key, + cert: cert.cert, + minVersion: version, + maxVersion: version, + }); + server.on("secureConnection", (socket: tls.TLSSocket) => { + connections.push(socket); + reused.push(socket.isSessionReused()); + if (socket.getProtocol() !== version) { + process.stderr.write(`negotiated ${socket.getProtocol()}, expected ${version}\n`); + process.exit(1); + } + socket.on("error", () => {}); + socket.once("data", () => { + socket.end("HTTP/1.1 200 OK\r\nConnection: close\r\nContent-Length: 2\r\n\r\nok"); + }); + }); + server.on("tlsClientError", () => {}); + const listening = new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", () => resolve((server.address() as AddressInfo).port)); + }); + const close = () => { + for (const c of connections) c.destroy(); + server.close(); + }; + return { reused, listening, close }; +} + +const ca = { ca: cert.cert } as const; +async function ok(res: Response) { + if ((await res.text()) !== "ok") throw new Error("bad body"); +} + +const out: Record = {}; + +// default: second connect resumes. +{ + const a = makeServer(); + const url = `https://localhost:${await a.listening}/`; + await ok(await fetch(url, { tls: ca })); + await ok(await fetch(url, { tls: ca })); + out.default = a.reused; + a.close(); +} + +// mismatch: trusted chain, wrong SAN. Both fetches fail +// ERR_TLS_CERT_ALTNAME_INVALID (proving each ran a real handshake) and no +// resumption is observed. A TLS 1.3 client may RST before the server +// completes its side, so `reused` can have fewer than two entries. +{ + const a = makeServer(); + const url = `https://localhost:${await a.listening}/`; + const bad = { ca: cert.cert, serverName: "wrong.example" } as const; + for (let i = 0; i < 2; i++) { + try { + await fetch(url, { tls: bad }); + throw new Error(`mismatch fetch ${i} resolved; expected ERR_TLS_CERT_ALTNAME_INVALID`); + } catch (e: any) { + if (e?.code !== "ERR_TLS_CERT_ALTNAME_INVALID") throw e; + } + } + out.mismatch = a.reused; + a.close(); +} + +// check-server-identity: a JS checkServerIdentity callback bypasses the cache +// (verification completes off-thread after on_handshake). The first fetch +// succeeds but never installs a sink, so the second sees an empty cache. +{ + const a = makeServer(); + const url = `https://localhost:${await a.listening}/`; + let callbackRan = false; + await ok( + await fetch(url, { + tls: { + ca: cert.cert, + checkServerIdentity: (host: string, peer: tls.PeerCertificate) => { + callbackRan = true; + return tls.checkServerIdentity(host, peer); + }, + }, + }), + ); + if (!callbackRan) throw new Error("checkServerIdentity callback did not run"); + await ok(await fetch(url, { tls: ca })); + out.checkServerIdentity = a.reused; + a.close(); +} + +// port-isolation: same hostname + SSLConfig, different port — the cache key +// includes the port, so A's ticket must never be offered to B. +{ + const a = makeServer(); + const b = makeServer(); + await ok(await fetch(`https://localhost:${await a.listening}/`, { tls: ca })); + await ok(await fetch(`https://localhost:${await b.listening}/`, { tls: ca })); + out.portIsolation = { a: a.reused, b: b.reused }; + a.close(); + b.close(); +} + +// host-isolation: same port + SSLConfig, different connect hostname +// (127.0.0.1 vs localhost) — the cache key includes the hostname. +{ + const a = makeServer(); + const port = await a.listening; + await ok(await fetch(`https://localhost:${port}/`, { tls: ca })); + await ok(await fetch(`https://127.0.0.1:${port}/`, { tls: ca })); + out.hostIsolation = a.reused; + a.close(); +} + +console.log(JSON.stringify(out)); diff --git a/test/js/web/fetch/fetch.tls.test.ts b/test/js/web/fetch/fetch.tls.test.ts index fb5e2ab551db..6617f3bd69c4 100644 --- a/test/js/web/fetch/fetch.tls.test.ts +++ b/test/js/web/fetch/fetch.tls.test.ts @@ -195,6 +195,83 @@ describe.concurrent("fetch-tls", () => { }); }); + // A second fetch to the same origin after `Connection: close` has to open a + // fresh TLS connection (no keep-alive socket to reuse). With a client-side + // session cache, that connect offers the ticket from the first handshake and + // the server observes a resumed session; without one, it's a full handshake. + // TLS 1.2 delivers the session inside SSL_do_handshake (before + // checkServerIdentity runs), TLS 1.3 as a post-handshake NewSessionTicket; + // both paths must cache. Each fixture run exercises every scenario against + // its own server (fresh port) so the cache key keeps them isolated. + describe("client-side TLS session resumption", () => { + const fixture = join(import.meta.dir, "fetch.tls.session-resumption-fixture.ts"); + async function run(version: string, env: Record = {}) { + await using proc = Bun.spawn({ + cmd: [bunExe(), fixture, version], + env: { ...bunEnv, ...env }, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stderr).not.toMatch(/AddressSanitizer|ERROR: (Leak|Thread)Sanitizer/); + expect(stdout.trim()).toStartWith("{"); + expect(exitCode).toBe(0); + return JSON.parse(stdout.trim()) as { + default: boolean[]; + mismatch: boolean[]; + checkServerIdentity: boolean[]; + portIsolation: { a: boolean[]; b: boolean[] }; + hostIsolation: boolean[]; + }; + } + + // Each run starts six TLS servers and performs ~12 handshakes in a + // debug+ASAN subprocess, which can exceed the default timeout when all + // four run under `describe.concurrent`. + const timeout = isASAN ? 20_000 : 10_000; + for (const version of ["TLSv1.2", "TLSv1.3"]) { + it( + `caches only verified sessions keyed on (host, port) (${version})`, + async () => { + const r = await run(version); + expect({ + default: r.default, + checkServerIdentity: r.checkServerIdentity, + portIsolation: r.portIsolation, + hostIsolation: r.hostIsolation, + }).toEqual({ + // Second fresh connect to the same origin resumes. + default: [false, true], + // A JS checkServerIdentity callback is excluded (verdict arrives + // off-thread after on_handshake), so the second fetch sees no + // cached ticket. + checkServerIdentity: [false, false], + // Same hostname + SSLConfig, different port: no resumption. + portIsolation: { a: [false], b: [false] }, + // Same port + SSLConfig, different connect hostname: no resumption. + hostIsolation: [false, false], + }); + // A handshake rejected by checkServerIdentity (trusted chain, wrong + // SAN) must not seed the cache. The fixture asserts each fetch + // rejects with ERR_TLS_CERT_ALTNAME_INVALID; the client may RST + // before the server completes its side of a TLS 1.3 handshake, so + // fewer than two entries is acceptable. + expect(r.mismatch).not.toContain(true); + }, + timeout, + ); + + it( + `is disabled by BUN_FEATURE_FLAG_DISABLE_FETCH_TLS_SESSION_CACHE (${version})`, + async () => { + const r = await run(version, { BUN_FEATURE_FLAG_DISABLE_FETCH_TLS_SESSION_CACHE: "1" }); + expect(r.default).toEqual([false, false]); + }, + timeout, + ); + } + }); + // Covers a family of HTTP-thread crashes (sentry BUN-2WC6 and siblings) where // a certificate identity failure during a handshake completed from the // SSL_read path, racing aborts, idle timeouts, and keepalive churn, caused a