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
1 change: 1 addition & 0 deletions src/js/builtins.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -527,6 +527,7 @@ declare function $ERR_TLS_RENEGOTIATION_UNSUPPORTED(): Error;
declare function $ERR_TLS_INVALID_STATE(): Error;
declare function $ERR_UNAVAILABLE_DURING_EXIT(): Error;
declare function $ERR_TLS_CERT_ALTNAME_FORMAT(): SyntaxError;
declare function $ERR_TLS_REQUIRED_SERVER_NAME(): Error;
declare function $ERR_TLS_SNI_FROM_SERVER(): Error;
declare function $ERR_SSL_NO_CIPHER_MATCH(): Error;
declare function $ERR_INVALID_URI(): URIError;
Expand Down
13 changes: 9 additions & 4 deletions src/js/node/tls.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1238,9 +1238,14 @@ function Server(options, secureConnectionListener): void {
this.ALPNProtocols = undefined;
this._sharedCreds = undefined;

let contexts: Map<string, typeof InternalSecureContext> | null = null;
// Every addContext() entry in call order (node's `_contexts`). listen() loads them into each new native listener.
const contexts = new Map<string, InstanceType<typeof InternalSecureContext>>();

this.addContext = function (hostname, context) {
// https://github.com/nodejs/node/blob/v26.3.0/lib/internal/tls/wrap.js#L1571-L1574
if (!hostname) {
throw $ERR_TLS_REQUIRED_SERVER_NAME();
}
if (typeof hostname !== "string") {
throw new TypeError("hostname must be a string");
}
Expand All @@ -1252,10 +1257,10 @@ function Server(options, secureConnectionListener): void {
// Pass the native SSL_CTX wrapper, not the JS InternalSecureContext —
// the native side detects it via SecureContext.fromJS and up_refs.
addServerName(handle, hostname, context.context);
} else {
if (!contexts) contexts = new Map();
contexts.set(hostname, context);
}
// Only after the live listener accepted the entry: listen() replays every recorded entry.
contexts.$delete(hostname); // a re-added name moves to the end, which keeps call order
contexts.$set(hostname, context);
};

this.setSecureContext = function (options) {
Expand Down
2 changes: 2 additions & 0 deletions src/jsc/bindings/ErrorCode.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -2380,6 +2380,8 @@ JSC_DEFINE_HOST_FUNCTION_WITH_ATTRIBUTES(Bun::jsFunctionMakeErrorWithCode, __att
return JSC::JSValue::encode(createError(globalObject, ErrorCode::ERR_UNAVAILABLE_DURING_EXIT, "Cannot call function in process exit handler"_s));
case ErrorCode::ERR_TLS_CERT_ALTNAME_FORMAT:
return JSC::JSValue::encode(createError(globalObject, ErrorCode::ERR_TLS_CERT_ALTNAME_FORMAT, "Invalid subject alternative name string"_s));
case ErrorCode::ERR_TLS_REQUIRED_SERVER_NAME:
return JSC::JSValue::encode(createError(globalObject, ErrorCode::ERR_TLS_REQUIRED_SERVER_NAME, "\"servername\" is required parameter for Server.addContext"_s));
case ErrorCode::ERR_TLS_SNI_FROM_SERVER:
return JSC::JSValue::encode(createError(globalObject, ErrorCode::ERR_TLS_SNI_FROM_SERVER, "Cannot issue SNI from a TLS server-side socket"_s));
case ErrorCode::ERR_TLS_INVALID_STATE:
Expand Down
1 change: 1 addition & 0 deletions src/jsc/bindings/ErrorCode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,7 @@ const errors: ErrorCodeMapping = [
["ERR_TLS_PROTOCOL_VERSION_CONFLICT", TypeError],
["ERR_TLS_RENEGOTIATION_DISABLED", Error],
["ERR_TLS_RENEGOTIATION_UNSUPPORTED", Error],
["ERR_TLS_REQUIRED_SERVER_NAME", Error],
["ERR_TLS_SNI_FROM_SERVER", Error],
["ERR_TLS_INVALID_STATE", Error],
["ERR_TLS_ALPN_CALLBACK_WITH_PROTOCOLS", TypeError],
Expand Down
152 changes: 151 additions & 1 deletion test/js/node/tls/node-tls-server.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import crypto from "crypto";
import { readFileSync, realpathSync } from "fs";
import { bunEnv, bunExe, tls as cert1, isDebug, isWindows } from "harness";
import { bunEnv, bunExe, tls as cert1, isDebug, isWindows, tempDir } from "harness";
import https from "https";
import net, { AddressInfo } from "net";
import { createTest } from "node-harness";
Expand Down Expand Up @@ -1437,6 +1437,156 @@ it("an asynchronous SNICallback resolving cb(null, null) still honors addContext
await once(server, "close");
});

describe("addContext() entries apply to every listen()", () => {
// A native listener only knows the SNI entries it was given. tls.Server keeps
// every addContext() entry, like node's server._contexts, and loads them into
// each listener it creates. node v26.3.0 serves the same certificates.
const fixture = (name: string) => readFileSync(join(import.meta.dir, "fixtures", name), "utf8");
const agent1 = { key: fixture("agent1-key.pem"), cert: fixture("agent1-cert.pem") };
const agent2 = { key: fixture("agent2-key.pem"), cert: fixture("agent2-cert.pem") };
const agent3 = { key: fixture("agent3-key.pem"), cert: fixture("agent3-cert.pem") };

async function listen(server: Server) {
server.listen(0, "127.0.0.1");
await once(server, "listening");
return server.address() as AddressInfo;
}

async function relisten(server: Server) {
server.close();
await once(server, "close");
return listen(server);
}

// The CN of the certificate the server presents for `servername`.
async function servedCN({ address, port }: AddressInfo, servername: string) {
const client = connect({ host: address, port, servername, rejectUnauthorized: false });
try {
await once(client, "secureConnect");
return client.getPeerCertificate().subject.CN;
} finally {
client.destroy();
}
}

it("an entry added while listening is still there after close() and listen()", async () => {
const server: Server = createServer(agent1, socket => socket.end());
try {
const first = await listen(server);
server.addContext("added.example", agent2);
const whileListening = await servedCN(first, "added.example");
const second = await relisten(server);
expect({
whileListening,
afterRelisten: await servedCN(second, "added.example"),
otherName: await servedCN(second, "other.example"),
}).toEqual({
whileListening: "agent2",
afterRelisten: "agent2",
otherName: "agent1",
});
} finally {
server.close();
}
});

it("an entry replaced while listening stays replaced after close() and listen()", async () => {
const server: Server = createServer(agent1, socket => socket.end());
server.addContext("rotated.example", agent2);
try {
const first = await listen(server);
const beforeReplace = await servedCN(first, "rotated.example");
server.addContext("rotated.example", agent3);
const afterReplace = await servedCN(first, "rotated.example");
const second = await relisten(server);
expect({
beforeReplace,
afterReplace,
afterRelisten: await servedCN(second, "rotated.example"),
}).toEqual({
beforeReplace: "agent2",
afterReplace: "agent3",
afterRelisten: "agent3",
});
} finally {
server.close();
}
});

it("addContext() without a servername throws ERR_TLS_REQUIRED_SERVER_NAME and keeps no entry", async () => {
// https://github.com/nodejs/node/blob/v26.3.0/lib/internal/tls/wrap.js#L1571-L1574
const required = expect.objectContaining({
name: "Error",
code: "ERR_TLS_REQUIRED_SERVER_NAME",
message: '"servername" is required parameter for Server.addContext',
});
const server: Server = createServer(agent1, socket => socket.end());
try {
// A kept empty name would make every listen() fail.
for (const servername of ["", undefined, null]) {
expect(() => server.addContext(servername as any, agent2)).toThrow(required);
}
await listen(server);
expect(() => server.addContext("", agent2)).toThrow(required);
expect(await servedCN(await relisten(server), "added.example")).toBe("agent1");
} finally {
server.close();
}
});

it("listen() loads the entries in addContext() call order", async () => {
// "ordered.example." and "ordered.example" land on the same native SNI
// entry, so the order a listener receives them in decides what it serves.
const server: Server = createServer(agent1, socket => socket.end());
server.addContext("ordered.example", agent2);
server.addContext("ordered.example.", agent2);
server.addContext("ordered.example", agent3);
try {
expect(await servedCN(await listen(server), "ordered.example")).toBe("agent3");
} finally {
server.close();
}
});

it("an entry added right after listen() in a cluster worker applies to that listen()", async () => {
// A worker's listen() asks the primary for the socket first, so the
// listener does not exist yet when addContext() runs.
using dir = tempDir("tls-addcontext-cluster", {
"main.cjs": `
const cluster = require("node:cluster");
const tls = require("node:tls");
if (cluster.isPrimary) {
const worker = cluster.fork();
worker.on("listening", ({ port }) => {
const client = tls.connect(
{ host: "127.0.0.1", port, servername: "added.example", rejectUnauthorized: false },
() => {
console.log(client.getPeerCertificate().subject.CN);
client.destroy();
worker.kill();
},
);
});
} else {
const server = tls.createServer(${JSON.stringify(agent1)}, socket => socket.end());
server.listen(0, "127.0.0.1");
server.addContext("added.example", ${JSON.stringify(agent2)});
}
`,
});
await using proc = Bun.spawn({
cmd: [bunExe(), "main.cjs"],
cwd: String(dir),
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect({ stdout, stderr }).toEqual({ stdout: "agent2\n", stderr: "" });
expect(exitCode).toBe(0);
});
});

describe("tls.Server socket destroySoon", () => {
// destroySoon() after end(big) must deliver every byte even when the TLS write
// batcher's final flush spills (#31584). The spill/kernel-buffer race hits ~4% of
Expand Down
Loading