Skip to content
Merged
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
150 changes: 39 additions & 111 deletions test/js/node/tls/node-tls-cert.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { describe, expect, it } from "bun:test";
import { once } from "events";
import { readFileSync } from "fs";
import { bunEnv, bunExe, invalidTls, isASAN, isDebug, tmpdirSync } from "harness";
import { bunEnv, bunExe, invalidTls, tmpdirSync } from "harness";
import type { AddressInfo } from "node:net";
import type { Server, TLSSocket } from "node:tls";
import { join } from "path";
Expand Down Expand Up @@ -628,22 +628,27 @@ it("tls.connect should ignore invalid NODE_EXTRA_CA_CERTS", async () => {
passphrase: "123123123",
});

for (const invalid of ["not-exist.pem", "", " "]) {
const proc = Bun.spawn({
env: {
...bunEnv,
SERVER_PORT: server.address.port.toString(),
NODE_EXTRA_CA_CERTS: invalid,
},
stderr: "pipe",
stdout: "inherit",
stdin: "inherit",
cmd: [bunExe(), join(import.meta.dir, "node-tls-cert-extra-ca.fixture.js")],
});
const results = await Promise.all(
["not-exist.pem", "", " "].map(async invalid => {
const proc = Bun.spawn({
env: {
...bunEnv,
SERVER_PORT: server.address.port.toString(),
NODE_EXTRA_CA_CERTS: invalid,
},
stderr: "pipe",
stdout: "inherit",
stdin: "inherit",
cmd: [bunExe(), join(import.meta.dir, "node-tls-cert-extra-ca.fixture.js")],
});
const [stderr, exitCode] = await Promise.all([proc.stderr.text(), proc.exited]);
return { invalid, stderr, exitCode };
}),
);

expect(await proc.exited).toBe(1);
const stderr = await proc.stderr.text();
for (const { stderr, exitCode } of results) {
expect(stderr).toContain("UNABLE_TO_GET_ISSUER_CERT_LOCALLY");
expect(exitCode).toBe(1);
}
});

Expand All @@ -660,22 +665,27 @@ it("tls.connect should ignore NODE_EXTRA_CA_CERTS if it contains invalid cert",
passphrase: "123123123",
});

for (const invalid of [mixedValidAndInvalidCertsBundlePath, mixedInvalidAndValidCertsBundlePath]) {
const proc = Bun.spawn({
env: {
...bunEnv,
SERVER_PORT: server.address.port.toString(),
NODE_EXTRA_CA_CERTS: invalid,
},
stderr: "pipe",
stdout: "inherit",
stdin: "inherit",
cmd: [bunExe(), join(import.meta.dir, "node-tls-cert-extra-ca.fixture.js")],
});
const results = await Promise.all(
[mixedValidAndInvalidCertsBundlePath, mixedInvalidAndValidCertsBundlePath].map(async invalid => {
const proc = Bun.spawn({
env: {
...bunEnv,
SERVER_PORT: server.address.port.toString(),
NODE_EXTRA_CA_CERTS: invalid,
},
stderr: "pipe",
stdout: "inherit",
stdin: "inherit",
cmd: [bunExe(), join(import.meta.dir, "node-tls-cert-extra-ca.fixture.js")],
});
const [stderr, exitCode] = await Promise.all([proc.stderr.text(), proc.exited]);
return { invalid, stderr, exitCode };
}),
);

expect(await proc.exited).toBe(1);
const stderr = await proc.stderr.text();
for (const { stderr, exitCode } of results) {
expect(stderr).toContain("ignoring extra certs");
expect(exitCode).toBe(1);
}
});
describe("tls ciphers should work", () => {
Expand Down Expand Up @@ -719,85 +729,3 @@ describe("tls ciphers should work", () => {
);
});
});

// A local `bun bd` debug build is ASAN-instrumented but not named `bun-asan`;
// ASAN's default 256MB quarantine retains every freed allocation, so RSS grows
// by the total allocation churn regardless of leaks and the threshold below
// cannot distinguish a leak from the quarantine. Skip every debug build -
// since isASAN learned that local debug builds are ASAN-instrumented too,
// debug+ASAN is exactly the un-measurable combination (quarantine + redzones
// inflate RSS unboundedly for this access pattern); CI's release ASAN lane
// (isDebug false) keeps running with its own threshold.
it.skipIf(isDebug)(
"server-side getPeerCertificate() should not leak",
async () => {
// Guards against the SSL_get_peer_certificate X509 ref leak and the
// computeRaw BIO leak on the server getPeerCertificate() path.
const { promise: serverSocketPromise, resolve: onServerSocket } = Promise.withResolvers<TLSSocket>();
const server = tls.createServer(
{
key: serverTls.key,
cert: serverTls.cert,
ca: [clientTls.ca],
requestCert: true,
rejectUnauthorized: false,
},
socket => onServerSocket(socket),
);
await once(server.listen(0, "127.0.0.1"), "listening");

const client = tls.connect({
host: "127.0.0.1",
port: (server.address() as AddressInfo).port,
key: clientTls.key,
cert: clientTls.cert,
ca: [serverTls.ca],
checkServerIdentity,
});
await once(client, "secureConnect");

const serverSocket = await serverSocketPromise;
try {
// Make sure the client actually sent a cert so we exercise the
// SSL_get_peer_certificate path rather than falling through to the
// cert-chain branch.
const first = serverSocket.getPeerCertificate();
expect(first).toBeDefined();
expect(first?.subject).toBeDefined();

function spin(n: number) {
for (let i = 0; i < n; i++) {
serverSocket.getPeerCertificate();
serverSocket.getPeerCertificate(false);
}
Bun.gc(true);
Bun.gc(true);
}

// Run in fixed-size rounds with a GC after each so the steady-state
// heap footprint stays bounded. The first few rounds grow the heap
// regardless of leaks, so take the baseline after warmup.
const perRound = isDebug ? 2_500 : 5_000;
for (let round = 0; round < 4; round++) spin(perRound);
const baseline = process.memoryUsage.rss();

for (let round = 0; round < 5; round++) spin(perRound);
const after = process.memoryUsage.rss();
const growth = after - baseline;

// Unpatched, the BIO leak alone is ~800 bytes/call → ~20MB over the
// 25k abbreviated calls here (~10MB for 12.5k in debug). Leave slack for
// allocator/ASAN noise but stay well below that. Both calls in the loop
// build the full leaf-certificate object (getPeerCertificate(false) used
// to return {}), so the debug budget covers 2x the constructions. Local
// debug (non-`bun-asan`) builds skip this test entirely - see skipIf above.
const threshold = 1024 * 1024 * (isDebug ? 20 : isASAN ? 16 : 12);
expect(growth).toBeLessThan(threshold);
} finally {
client.end();
serverSocket.end();
server.close();
}
},
180_000,
);
127 changes: 68 additions & 59 deletions test/js/node/tls/node-tls-getpeercert-leak.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,69 +18,78 @@ const serverTls = {
ca: readFileSync(join(import.meta.dir, "fixtures", "ca2-cert.pem"), "utf8"),
};

it("server-side getPeerCertificate() should not leak", async () => {
// Guards against the SSL_get_peer_certificate X509 ref leak and the
// computeRaw BIO leak on the server getPeerCertificate() path.
const { promise: serverSocketPromise, resolve: onServerSocket } = Promise.withResolvers<TLSSocket>();
const server = tls.createServer(
{
key: serverTls.key,
cert: serverTls.cert,
ca: [clientTls.ca],
requestCert: true,
rejectUnauthorized: false,
},
socket => onServerSocket(socket),
);
await once(server.listen(0, "127.0.0.1"), "listening");
// Debug builds are ASAN-instrumented; ASAN's default 256MB quarantine retains
// every freed allocation, so RSS grows with total allocation churn regardless
// of leaks and the threshold below cannot distinguish a leak from the
// quarantine. CI's release ASAN lane (isDebug false) still runs.
it.skipIf(isDebug)(
"server-side getPeerCertificate() should not leak",
async () => {
Comment thread
robobun marked this conversation as resolved.
// Guards against the SSL_get_peer_certificate X509 ref leak and the
// computeRaw BIO leak on the server getPeerCertificate() path.
const { promise: serverSocketPromise, resolve: onServerSocket } = Promise.withResolvers<TLSSocket>();
const server = tls.createServer(
{
key: serverTls.key,
cert: serverTls.cert,
ca: [clientTls.ca],
requestCert: true,
rejectUnauthorized: false,
},
socket => onServerSocket(socket),
);
await once(server.listen(0, "127.0.0.1"), "listening");

const client = tls.connect({
host: "127.0.0.1",
port: (server.address() as AddressInfo).port,
key: clientTls.key,
cert: clientTls.cert,
ca: [serverTls.ca],
checkServerIdentity: () => undefined,
});
await once(client, "secureConnect");
const client = tls.connect({
host: "127.0.0.1",
port: (server.address() as AddressInfo).port,
key: clientTls.key,
cert: clientTls.cert,
ca: [serverTls.ca],
checkServerIdentity: () => undefined,
});
await once(client, "secureConnect");

const serverSocket = await serverSocketPromise;
try {
// Make sure the client actually sent a cert so we exercise the
// SSL_get_peer_certificate path rather than falling through to the
// cert-chain branch.
const first = serverSocket.getPeerCertificate();
expect(first).toBeDefined();
expect(first?.subject).toBeDefined();
const serverSocket = await serverSocketPromise;
try {
// Make sure the client actually sent a cert so we exercise the
// SSL_get_peer_certificate path rather than falling through to the
// cert-chain branch.
const first = serverSocket.getPeerCertificate();
expect(first).toBeDefined();
expect(first?.subject).toBeDefined();

function spin(n: number) {
for (let i = 0; i < n; i++) {
serverSocket.getPeerCertificate();
serverSocket.getPeerCertificate(false);
function spin(n: number) {
for (let i = 0; i < n; i++) {
serverSocket.getPeerCertificate();
serverSocket.getPeerCertificate(false);
}
Bun.gc(true);
Bun.gc(true);
}
Bun.gc(true);
Bun.gc(true);
}

// Run in fixed-size rounds with a GC after each so the steady-state
// heap footprint stays bounded. The first few rounds grow the heap
// regardless of leaks, so take the baseline after warmup.
const perRound = isDebug ? 2_500 : 5_000;
for (let round = 0; round < 4; round++) spin(perRound);
const baseline = process.memoryUsage.rss();
// Run in fixed-size rounds with a GC after each so the steady-state
// heap footprint stays bounded. The first few rounds grow the heap
// regardless of leaks, so take the baseline after warmup.
const perRound = 5_000;
for (let round = 0; round < 8; round++) spin(perRound);
const baseline = process.memoryUsage.rss();

for (let round = 0; round < 10; round++) spin(perRound);
const after = process.memoryUsage.rss();
const growth = after - baseline;
for (let round = 0; round < 15; round++) spin(perRound);
const after = process.memoryUsage.rss();
const growth = after - baseline;

// Unpatched, the BIO leak alone is ~800 bytes/call → ~40MB over the
// 50k abbreviated calls here (~20MB for 25k in debug). Leave slack for
// allocator/ASAN noise but stay well below that.
const threshold = 1024 * 1024 * (isDebug ? 10 : isASAN ? 16 : 12);
expect(growth).toBeLessThan(threshold);
} finally {
client.end();
serverSocket.end();
server.close();
}
}, 180_000);
// Unpatched, the BIO leak alone is ~800 bytes/call → ~60MB over the
// 75k abbreviated calls measured here. macOS CI VMs have shown ~21MB of
// benign RSS growth over the same window with no leak present, so the
// threshold is set well above that noise floor but well below the leak.
const threshold = 1024 * 1024 * (isASAN ? 40 : 32);
expect(growth).toBeLessThan(threshold);
} finally {
client.end();
serverSocket.end();
server.close();
}
},
180_000,
);
Loading