Files
bun-src/test/js/node/tls/ssl-ctx-cache.test.ts
2026-08-27 21:09:14 +00:00

428 lines
18 KiB
TypeScript

// Native weak `SSLContextCache`: every JS-thread consumer that turns an SSL
// config into an `SSL_CTX*` should hit the same per-VM cache, so identical
// configs (including `{servername}`-only and inline-CA configs) allocate one
// CTX, not one per connection. The cache holds zero refs — when the last
// real owner drops, BoringSSL's ex_data free callback tombstones the entry.
import { expect, test } from "bun:test";
import { once } from "node:events";
import tls from "node:tls";
// @ts-expect-error - debug-only export
import { sslCtxLiveCount } from "bun:internal-for-testing";
import { tempDir, tls as tlsCerts } from "harness";
import { readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
async function withServer(fn: (port: number) => Promise<void>) {
const server = tls.createServer({ ...tlsCerts, rejectUnauthorized: false }, s => s.end());
server.listen(0, "127.0.0.1");
await once(server, "listening");
const { port } = server.address() as import("net").AddressInfo;
try {
await fn(port);
} finally {
server.close();
await once(server, "close");
}
}
// Before the native cache, `Bun.connect({tls:{servername}})` set
// `requires_custom_request_ctx` and built a fresh SSL_CTX per call even though
// SNI is per-SSL not per-CTX. The digest now excludes servername, so 50 of
// these share the default client CTX.
test("Bun.connect with servername-only tls reuses one SSL_CTX", async () => {
await withServer(async port => {
// Warm: server CTX + the digest-{} client CTX.
await connectOnce(port);
Bun.gc(true);
const before = sslCtxLiveCount();
for (let i = 0; i < 50; i++) await connectOnce(port);
await new Promise<void>(r => setImmediate(() => queueMicrotask(r)));
Bun.gc(true);
// Old behaviour: Δ ≈ 50. Now: Δ ≤ 2.
expect(sslCtxLiveCount() - before).toBeLessThanOrEqual(2);
});
async function connectOnce(port: number) {
const { promise, resolve, reject } = Promise.withResolvers<void>();
const sock = await Bun.connect({
hostname: "127.0.0.1",
port,
tls: { servername: "localhost", rejectUnauthorized: false },
socket: {
// With a `handshake` handler present, `open` fires on TCP-connect
// (pre-handshake). Calling `s.end()` there leaves the libuv-backed
// Windows socket in a state where `close` never fires. End after
// the handshake completes instead.
open() {},
handshake(s) {
s.end();
},
close() {
resolve();
},
data() {},
error(_s, e) {
reject(e);
},
connectError(_s, e) {
reject(e);
},
},
});
await promise;
}
});
// The user-facing `tls.createSecureContext()` is uncached: every call owns its
// SSL_CTX exclusively (so addCACert on one context can never leak into
// another); only internal consumers (tls.connect / Bun.connect / fetch) share
// contexts through the per-digest native cache.
test("createSecureContext owns its native handle exclusively (identical configs get distinct SSL_CTXs)", () => {
const opts = { ca: tlsCerts.cert, rejectUnauthorized: false };
const a = tls.createSecureContext(opts);
const b = tls.createSecureContext({ ...opts });
// The JS wrapper carries per-call `servername`, so wrappers differ; the
// SSL_CTX-owning `.context` is the deduped native cell.
// The user-facing createSecureContext() owns its SSL_CTX exclusively so
// addCACert on one context can never affect another.
expect(a.context).not.toBe(b.context);
// Different config → different handle.
const c = tls.createSecureContext({ rejectUnauthorized: false });
expect(c.context).not.toBe(a.context);
});
// Weak-cache reclaim: once every owner drops its ref and GC sweeps the
// SecureContext, BoringSSL's free callback tombstones the entry and the live
// count returns to baseline.
test("SSL_CTX is freed once no owners remain (weak cache, not strong)", async () => {
// Drain anything previous tests left for the sweeper so `before` is stable.
Bun.gc(true);
await new Promise<void>(r => setImmediate(r));
Bun.gc(true);
const before = sslCtxLiveCount();
// Build a CTX with a unique digest (custom cipher) so nothing else holds it.
let sc: any = tls.createSecureContext({ ciphers: "ECDHE-RSA-AES128-GCM-SHA256" });
expect(sc.context).toBeTruthy();
// While `sc` is live the CTX must be live — proves the cache doesn't
// *prevent* allocation either.
expect(sslCtxLiveCount()).toBe(before + 1);
sc = undefined;
// Weak<> handles are reclaimed on full GC; SecureContext.finalize then
// SSL_CTX_free()s, which fires the ex_data tombstone. A strong cache would
// pin the count at before+1. JSC's conservative stack scan and finalizer
// scheduling don't guarantee N passes is enough — await the condition.
for (let i = 0; i < 50; i++) {
Bun.gc(true);
await new Promise<void>(r => setImmediate(r));
if (sslCtxLiveCount() <= before) break;
}
expect(sslCtxLiveCount()).toBeLessThanOrEqual(before);
});
// Same-CA inline configs across repeated `Bun.connect` calls resolve to one
// CTX — the cache is keyed by digest. (Not shared with `new WebSocket`, which
// projects via `asUSocketsForClientVerification()` → different `request_cert`
// → different digest by design.)
test("Bun.connect with inline ca shares SSL_CTX across calls", async () => {
await withServer(async port => {
const tlsOpts = { ca: tlsCerts.cert, rejectUnauthorized: false };
// Warm.
await connectOnce(port, tlsOpts);
Bun.gc(true);
const before = sslCtxLiveCount();
for (let i = 0; i < 30; i++) await connectOnce(port, tlsOpts);
await new Promise<void>(r => setImmediate(() => queueMicrotask(r)));
Bun.gc(true);
expect(sslCtxLiveCount() - before).toBeLessThanOrEqual(2);
});
async function connectOnce(port: number, tlsOpts: object) {
const { promise, resolve, reject } = Promise.withResolvers<void>();
await Bun.connect({
hostname: "127.0.0.1",
port,
tls: tlsOpts,
socket: {
// With a `handshake` handler present, `open` fires on TCP-connect
// (pre-handshake). Calling `s.end()` there leaves the libuv-backed
// Windows socket in a state where `close` never fires. End after
// the handshake completes instead.
open() {},
handshake(s) {
s.end();
},
close() {
resolve();
},
data() {},
error(_s, e) {
reject(e);
},
connectError(_s, e) {
reject(e);
},
},
});
await promise;
}
});
test("file-backed config: in-place rotation invalidates cache (mtime+size in digest)", async () => {
using dir = tempDir("ssl-ctx-rotate", {
"ca.pem": tlsCerts.cert,
});
const caFile = join(String(dir), "ca.pem");
await withServer(async port => {
// Exercise the cached connect path (which memoises by config digest);
// the user-facing createSecureContext() now owns its SSL_CTX exclusively,
// so it would create a fresh CTX per call and defeat the cache this test
// is about. Pin each socket so GC between connects can't drop the count.
const pin: unknown[] = [];
const connectOnce = async () => {
const s = tls.connect({ port, caFile, rejectUnauthorized: false } as any);
pin.push(s);
await once(s, "secureConnect");
s.destroy();
await once(s, "close");
};
// Warm: first connect may also lazy-init unrelated CTXs (default client,
// root store) — measure deltas after the first one.
await connectOnce();
const after1 = sslCtxLiveCount();
await connectOnce();
// Second connect with identical (path, mtime, size) hits cache.
expect(sslCtxLiveCount()).toBe(after1);
// Rotate in place — same path, different content. Rewriting bumps mtime
// and (here) size; either alone changes the digest.
writeFileSync(caFile, tlsCerts.cert + "\n");
await connectOnce();
// New (mtime, size) → fresh digest → fresh CTX.
expect(sslCtxLiveCount()).toBe(after1 + 1);
pin.length = 0;
});
});
test("addCACert on one user-facing context does not affect another with identical options", () => {
const a = tls.createSecureContext({});
const b = tls.createSecureContext({});
expect(a.context).not.toBe(b.context);
a.context.addCACert(tlsCerts.cert);
// b's native context is a different object and stays untouched.
expect(a.context).not.toBe(b.context);
});
// The digest-interned cache is deliberately reachable only through internal
// paths, but if one leaks (Symbol.for constructor, TLSSocket internals) the
// mutator itself must refuse rather than silently poison every consumer.
test("addCACert on a digest-interned context throws instead of poisoning the cache", () => {
const NativeSecureContext = tls.Server.prototype[Symbol.for("::buntlsnativesecurecontextctor::")];
const a = NativeSecureContext.intern({ ca: tlsCerts.cert });
const b = NativeSecureContext.intern({ ca: tlsCerts.cert });
expect(a).toBe(b);
expect(() => a.addCACert(tlsCerts.ca)).toThrow("cannot mutate a shared SecureContext");
});
// The exported constructor is user-facing too: it must never hand out the
// digest-interned SSL_CTX, or addCACert on one instance would silently extend
// the trust store of every context sharing that digest.
test("new tls.SecureContext() owns its native handle exclusively, like createSecureContext()", () => {
const a = new (tls as any).SecureContext({ ca: tlsCerts.cert });
const b = new (tls as any).SecureContext({ ca: tlsCerts.cert });
// The interned cache would hand both the same native cell.
expect(a.context).not.toBe(b.context);
});
test("addCACert on one exported SecureContext instance does not change what another verifies", async () => {
// agent6's chain roots at ca1, which is not in the default roots: a client
// using `b` must keep rejecting it after `a` starts trusting ca1 (Node
// isolates the contexts and fails this connection closed).
const fx = (n: string) => readFileSync(join(import.meta.dir, "fixtures", n), "utf8");
const server = tls.createServer({ key: fx("agent6-key.pem"), cert: fx("agent6-cert.pem") }, s => s.end());
server.listen(0, "127.0.0.1");
await once(server, "listening");
const { port } = server.address() as import("net").AddressInfo;
const a = new (tls as any).SecureContext({ ca: fx("ca2-cert.pem") });
const b = new (tls as any).SecureContext({ ca: fx("ca2-cert.pem") });
a.context.addCACert(fx("ca1-cert.pem"));
const outcome = Promise.withResolvers<string>();
const socket = tls.connect({
port,
host: "127.0.0.1",
secureContext: b,
rejectUnauthorized: true,
checkServerIdentity: () => undefined,
});
socket.on("secureConnect", () => outcome.resolve(`secureConnect authorized=${socket.authorized}`));
socket.on("error", error => outcome.resolve(`error ${(error as NodeJS.ErrnoException).code}`));
try {
expect(await outcome.promise).toBe("error UNABLE_TO_GET_ISSUER_CERT_LOCALLY");
} finally {
socket.destroy();
server.close();
}
});
test("setDefaultCACertificates() override applies to plain tls.connect (no explicit ca)", async () => {
const keys = (f: string) => readFileSync(join(import.meta.dir, "../test/fixtures/keys", f));
const prev = tls.getCACertificates("default");
try {
tls.setDefaultCACertificates([keys("ca1-cert.pem").toString()]);
const server = tls.createServer({ key: keys("agent1-key.pem"), cert: keys("agent1-cert.pem") }, s => s.end("ok"));
await new Promise<void>(resolve => server.listen(0, "127.0.0.1", resolve));
const port = (server.address() as any).port;
const socket = tls.connect({ port, host: "127.0.0.1", rejectUnauthorized: true, servername: "agent1" });
await once(socket, "secureConnect");
expect(socket.authorized).toBe(true);
socket.destroy();
server.close();
} finally {
tls.setDefaultCACertificates(prev);
}
});
test("ca: [] skips the setDefaultCACertificates override (distinct from ca: undefined)", async () => {
// Providing any `ca` value - including an empty array - bypasses the
// process-default override that setDefaultCACertificates() installs (the
// override only applies when `ca` is absent), so the connection verifies
// against the bundled roots instead. NOTE: this is not Node's full
// "ca: [] = empty trust store" semantics (an explicitly-empty list should
// trust NOTHING, not fall back to bundled roots) - that needs an explicit
// empty-CA flag through the native config and remains a follow-up. Make a
// fixture CA a process default first so the two cases are observably
// different.
const keys = (f: string) => readFileSync(join(import.meta.dir, "../test/fixtures/keys", f), "utf8");
const prevCerts = tls.getCACertificates("default");
tls.setDefaultCACertificates([keys("ca1-cert.pem")]);
try {
const server = tls.createServer({ key: keys("agent1-key.pem"), cert: keys("agent1-cert.pem") }, s => s.end());
server.listen(0, "127.0.0.1");
await once(server, "listening");
const port = (server.address() as import("net").AddressInfo).port;
// ca undefined -> the process defaults (which now include ca1) -> authorized.
const c1 = tls.connect({ port, host: "127.0.0.1", rejectUnauthorized: false, servername: "agent1" });
await once(c1, "secureConnect");
expect(c1.authorized).toBe(true);
c1.end();
await once(c1, "close");
// ca: [] -> the override is skipped, the bundled roots apply (which do not
// include ca1) -> NOT authorized.
const c2 = tls.connect({ port, host: "127.0.0.1", rejectUnauthorized: false, servername: "agent1", ca: [] });
await once(c2, "secureConnect");
expect(c2.authorized).toBe(false);
expect(c2.authorizationError).toBeTruthy();
c2.end();
await once(c2, "close");
server.close();
await once(server, "close");
} finally {
tls.setDefaultCACertificates(prevCerts);
}
});
test("setDefaultCACertificates() applies to a server's client-cert verification (no explicit ca)", async () => {
// The server path (setSecureContext -> Bun.listen) does not go through
// InternalSecureContext; the process-default override must still apply so
// an mTLS server with no explicit `ca` verifies client certificates against
// the overridden defaults rather than the bundled roots.
const keys = (f: string) => readFileSync(join(import.meta.dir, "../test/fixtures/keys", f), "utf8");
const prevCerts = tls.getCACertificates("default");
tls.setDefaultCACertificates([keys("ca1-cert.pem")]);
try {
const server = tls.createServer({
key: keys("agent1-key.pem"),
cert: keys("agent1-cert.pem"),
requestCert: true,
rejectUnauthorized: false,
});
const authorized = Promise.withResolvers<boolean>();
server.on("secureConnection", socket => {
authorized.resolve(socket.authorized);
socket.end();
});
server.listen(0, "127.0.0.1");
await once(server, "listening");
const port = (server.address() as import("net").AddressInfo).port;
// The client presents agent1's cert (signed by ca1, which is now a process
// default). The server must verify it as authorized.
const client = tls.connect({
port,
host: "127.0.0.1",
rejectUnauthorized: false,
key: keys("agent1-key.pem"),
cert: keys("agent1-cert.pem"),
});
await once(client, "secureConnect");
expect(await authorized.promise).toBe(true);
client.end();
await once(client, "close");
server.close();
await once(server, "close");
} finally {
tls.setDefaultCACertificates(prevCerts);
}
});
// `tls.Server.close()` must release the listener's SSL_CTX ref immediately.
// It used to be dropped only when the GC finalized the Listener, so a `Server`
// the program still references — or any server at process exit — kept its CTX
// alive. That is the leak `leak:create_ssl_context_from_bun_options` used to
// suppress. The listen socket up_refs its own ref in
// `us_internal_init_listen_socket` and each accepted socket's `SSL_new()` takes
// another, so releasing at close() cannot dangle.
test("tls.Server.close() releases the listener's SSL_CTX without waiting for GC", async () => {
Bun.gc(true);
const before = sslCtxLiveCount();
// Hold strong references so the GC can never finalize these Listeners; a
// distinct `sessionTimeout` per server gives each its own cache entry.
const kept: tls.Server[] = [];
for (let i = 0; i < 5; i++) {
const server = tls.createServer({ ...tlsCerts, sessionTimeout: 100 + i });
server.listen(0, "127.0.0.1");
await once(server, "listening");
server.close();
await once(server, "close");
kept.push(server);
}
// No Bun.gc() here on purpose: the point is that close() alone frees them.
expect({ leaked: sslCtxLiveCount() - before, servers: kept.length }).toEqual({ leaked: 0, servers: 5 });
});
// Releasing at close() must not pull the CTX out from under a socket the
// server already accepted.
test("a connection accepted before close() keeps working after it", async () => {
const server = tls.createServer({ ...tlsCerts }, s => {
s.on("error", () => {});
s.on("data", d => s.write(d));
});
server.listen(0, "127.0.0.1");
await once(server, "listening");
const { port } = server.address() as import("net").AddressInfo;
const client = tls.connect({ port, host: "127.0.0.1", rejectUnauthorized: false });
client.on("error", () => {});
await once(client, "secureConnect");
server.close(); // drops the listener's ref while `client` is still live
Bun.gc(true);
client.write("ping");
const [echoed] = await once(client, "data");
expect(echoed.toString()).toBe("ping");
client.destroy();
});