Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions packages/bun-usockets/src/crypto/openssl.c
Original file line number Diff line number Diff line change
Expand Up @@ -338,9 +338,10 @@ static void ssl_flush_pending_session(struct us_socket_t *s) {
}
}

/* Defined in Zig (`SSLContextCache.zig`): tombstones the cache entry on
/* Defined in Rust (`SSLContextCache.rs`): tombstones the cache entry on
* SSL_CTX refcount→0 so the per-VM weak SSL_CTX cache learns the pointer is
* dead without holding a ref of its own. */
* dead without holding a ref of its own. Runs on whichever thread dropped the
* last ref; the Rust side asserts that is the cache's owning JS thread. */
extern void bun_ssl_ctx_cache_on_free(void *parent, void *ptr, CRYPTO_EX_DATA *ad,
int index, long argl, void *argp);

Expand Down
52 changes: 48 additions & 4 deletions src/runtime/api/bun/SSLContextCache.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,18 @@
//! thread: every consumer's `SSL_CTX_free` (socket close, `owned_ssl_ctx`
//! deinit, `SecureContext.finalize`) runs there — JSC sweeps destructible
//! objects on the mutator, not heap-helper, thread. The mutex makes the
//! tombstone-write / `get_or_create`-load+`up_ref` ordering explicit and
//! protects against any future caller that does free off-thread; the lock is
//! uncontended in practice.
//! tombstone-write / `get_or_create`-load+`up_ref` ordering explicit; the
//! lock is uncontended in practice.
//!
//! The mutex can NOT make an off-thread `SSL_CTX_free` safe: BoringSSL drops
//! `references` to 0 *before* `CRYPTO_free_ex_data` reaches
//! `bun_ssl_ctx_cache_on_free`, so between the decrement and the tombstone
//! write a concurrent `get_or_create` would still see `entry.ctx != null` and
//! `SSL_CTX_up_ref` 0→1, resurrecting a ctx whose destruction already
//! committed (use-after-free). Keeping every last-ref free on the owning JS
//! thread is therefore a hard invariant, enforced by `debug_assert` in
//! `get_or_create_digest` / `bun_ssl_ctx_cache_on_free`; a consumer that
//! ships a cache-descended ref to another thread must marshal the free back.
//!
//! This subsumes the per-consumer `createSSLContext` calls (Postgres, MySQL,
//! Valkey, `Bun.connect`, `upgradeTLS`, WebSocket client) and the JS-side
Expand All @@ -31,11 +40,28 @@ use bun_uws::create_bun_socket_error_t;
// `jsc.API.ServerConfig.SSLConfig` — re-exported from src/runtime/socket/SSLConfig.rs
use crate::api::server::server_config::SSLConfig;

#[derive(Default)]
pub struct SSLContextCache {
map: ArrayHashMap<Digest, *mut Entry, DigestContext>,
mutex: Mutex,
ops_since_compact: u32,
/// The owning VM's JS thread, captured at construction
/// (`init_runtime_state` runs on it). See the module doc: last-ref
/// `SSL_CTX_free` of a cache-managed ctx off this thread is a
/// use-after-free window the mutex cannot close.
#[cfg(debug_assertions)]
owner_thread: std::thread::ThreadId,
}

impl Default for SSLContextCache {
fn default() -> Self {
Self {
map: ArrayHashMap::default(),
mutex: Mutex::default(),
ops_since_compact: 0,
#[cfg(debug_assertions)]
owner_thread: std::thread::current().id(),
}
}
}

pub type Digest = [u8; 32];
Expand Down Expand Up @@ -99,6 +125,7 @@ impl SSLContextCache {
d: Digest,
err: &mut create_bun_socket_error_t,
) -> Option<*mut boringssl::SSL_CTX> {
self.debug_assert_owner_thread();
{
let _guard = self.mutex.lock_guard();
if let Some(entry) = self.map.get(&d) {
Expand Down Expand Up @@ -189,6 +216,21 @@ impl SSLContextCache {
Some(ctx)
}

/// Enforces the module-doc invariant. Meaningful for the *free* side:
/// `bun_ssl_ctx_cache_on_free` runs on whichever thread dropped the last
/// ref, and an off-thread drop races `get_or_create`'s `up_ref` into a
/// resurrection use-after-free regardless of the mutex.
#[inline]
fn debug_assert_owner_thread(&self) {
#[cfg(debug_assertions)]
assert!(
std::thread::current().id() == self.owner_thread,
"SSLContextCache touched off its owning JS thread; an off-thread \
SSL_CTX_free of a cache-managed ctx can resurrect a dying SSL_CTX \
(see module doc). Marshal the free back to the owning thread."
);
}

/// Reclaim tombstoned entries. Locked variant — callers hold `self.mutex`.
fn compact_locked(&mut self) {
let mut i: usize = 0;
Expand Down Expand Up @@ -237,6 +279,7 @@ pub extern "C" fn bun_ssl_ctx_cache_on_free(
// SAFETY: non-null ptr is the *Entry we stored via SSL_CTX_set_ex_data; the
// owning cache outlives every SSL_CTX it hands out (Drop clears ex_data first).
let entry: &mut Entry = unsafe { bun_ptr::callback_ctx::<Entry>(ptr) };
entry.owner.debug_assert_owner_thread();
let _guard = entry.owner.mutex.lock_guard();
entry.ctx = ptr::null_mut();
}
Expand All @@ -247,6 +290,7 @@ impl Drop for SSLContextCache {
/// dereference the freed `Entry`/map. Map itself holds no refs, so no
/// `SSL_CTX_free` here.
fn drop(&mut self) {
self.debug_assert_owner_thread();
let _guard = self.mutex.lock_guard();
for &entry in self.map.values() {
// SAFETY: map values are live heap Entries; we hold the mutex.
Expand Down
70 changes: 70 additions & 0 deletions test/js/node/tls/ssl-ctx-cache.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
// real owner drops, BoringSSL's ex_data free callback tombstones the entry.
import { expect, test } from "bun:test";
import { once } from "node:events";
import http2 from "node:http2";
import tls from "node:tls";
// @ts-expect-error - debug-only export
import { sslCtxLiveCount } from "bun:internal-for-testing";
Expand Down Expand Up @@ -173,6 +174,75 @@ test("Bun.connect with inline ca shares SSL_CTX across calls", async () => {
}
});

// https://github.com/oven-sh/bun/issues/31881: node:http2 churn (a new
// session per request, firebase-admin's pattern) over the weak cache, with GC
// pressure interleaved so reclaim/tombstone/rebuild overlap the session
// lifecycle on the event loop. Debug builds additionally assert (in
// bun_ssl_ctx_cache_on_free) that every last-ref SSL_CTX_free stays on the
// owning JS thread, the invariant that keeps getOrCreate's up_ref from
// resurrecting a dying ctx.
test("node:http2 session churn with GC shares one SSL_CTX and completes every request", async () => {
const server = http2.createSecureServer({ ...tlsCerts });
server.on("stream", stream => {
stream.respond({ ":status": 200 });
stream.end("ok");
});
server.listen(0);
await once(server, "listening");
const { port } = server.address() as import("net").AddressInfo;

const clientOpts = { ca: tlsCerts.cert, rejectUnauthorized: false };

async function oneSession() {
const session = http2.connect(`https://localhost:${port}`, clientOpts);
// Armed before anything can fail so the finally below never misses the event.
const closed = once(session, "close");
const { promise, resolve, reject } = Promise.withResolvers<number>();
session.once("error", reject);
const req = session.request({ ":path": "/" });
req.once("error", reject);
let statusCode: number | undefined;
req.once("response", headers => {
statusCode = headers[":status"] as unknown as number;
});
// Resolve on full request completion, not just headers.
req.once("end", () => {
if (statusCode == null) reject(new Error("request ended without a :status header"));
else resolve(statusCode);
});
req.resume();
req.end();
try {
return await promise;
} finally {
session.close();
await closed;
}
}

try {
// Warm: server CTX + first client CTX + any lazy defaults.
expect(await oneSession()).toBe(200);
Bun.gc(true);
await new Promise<void>(r => setImmediate(r));
const before = sslCtxLiveCount();

for (let round = 0; round < 6; round++) {
const statuses = await Promise.all([oneSession(), oneSession(), oneSession(), oneSession(), oneSession()]);
expect(statuses).toEqual([200, 200, 200, 200, 200]);
Bun.gc(true);
await new Promise<void>(r => setImmediate(r));
}

// 30 sessions later: same client config = same digest = no per-session
// CTX growth (headroom for defaults lazily built mid-run).
expect(sslCtxLiveCount() - before).toBeLessThanOrEqual(2);
} finally {
server.close();
await once(server, "close");
}
});

test("file-backed config: in-place rotation invalidates cache (mtime+size in digest)", async () => {
using dir = tempDir("ssl-ctx-rotate", {
"ca.pem": tlsCerts.cert,
Expand Down
Loading