// This test ensures that when a TLS connection is established, the server // selects the most recently added SecureContext that matches the servername. import { describe, expect, it } from "bun:test"; import { readFileSync } from "node:fs"; import { AddressInfo } from "node:net"; import { join } from "node:path"; import tls from "node:tls"; function loadPEM(filename: string) { return readFileSync(join(import.meta.dir, "fixtures", filename)).toString(); } const agent1Cert = loadPEM("agent1-cert.pem"); const agent1Key = loadPEM("agent1-key.pem"); const agent2Cert = loadPEM("agent2-cert.pem"); const agent2Key = loadPEM("agent2-key.pem"); const agent3Cert = loadPEM("agent3-cert.pem"); const agent3Key = loadPEM("agent3-key.pem"); const agent6Cert = loadPEM("agent6-cert.pem"); const agent6Key = loadPEM("agent6-key.pem"); const ca1 = loadPEM("ca1-cert.pem"); const ca2 = loadPEM("ca2-cert.pem"); const SNIContexts = { "a.example.com": { key: agent1Key, cert: agent1Cert, }, "asterisk.test.com": { key: agent3Key, cert: agent3Cert, }, "chain.example.com": { key: agent6Key, // NOTE: Contains ca3 chain cert cert: agent6Cert, }, }; const serverOptions = { key: agent2Key, cert: agent2Cert, requestCert: true, rejectUnauthorized: false, }; const badSecureContext = { key: agent1Key, cert: agent1Cert, ca: [ca2], }; const goodSecureContext = { key: agent1Key, cert: agent1Cert, ca: [ca1], }; describe("tls.Server", () => { it("addContext", async () => { const serverOptions = { key: agent2Key, cert: agent2Cert, ca: [ca2], requestCert: true, rejectUnauthorized: false, }; let connections = 0; const { promise, resolve, reject } = Promise.withResolvers(); let listening_server: tls.Server | null = null; try { listening_server = tls.createServer(serverOptions, async c => { try { if (++connections === 3) { resolve(); } //@ts-ignore if (c.servername === "unknowncontext") { expect(c.authorized).toBe(false); return; } expect(c.authorized).toBe(true); } catch (e) { reject(e); } }); const server = listening_server as tls.Server; const secureContext = { key: agent1Key, cert: agent1Cert, ca: [ca1], }; server.addContext("context1", secureContext); //@ts-ignore server.addContext("context2", tls.createSecureContext(secureContext)); const clientOptionsBase = { key: agent1Key, cert: agent1Cert, ca: [ca1], rejectUnauthorized: false, }; function connect(servername: string) { return new Promise((resolve, reject) => { const client = tls.connect( { ...clientOptionsBase, port: (server.address() as AddressInfo).port, host: "127.0.0.1", servername, }, () => { client.end(); resolve(); }, ); client.on("error", reject); }); } server.listen(0, async () => { await connect("context1"); await connect("context2"); await connect("unknowncontext"); }); await promise; } finally { listening_server?.close(); } }); it("should select the most recently added SecureContext", async () => { let listening_server: tls.Server | null = null; const { promise, resolve, reject } = Promise.withResolvers(); try { const timeout = setTimeout(() => { reject("timeout"); }, 3000); listening_server = tls.createServer(serverOptions, c => { try { // The 'a' and 'b' subdomains are used to distinguish between client // connections. // Connection to subdomain 'a' is made when the 'bad' secure context is // the only one in use. //@ts-ignore if ("a.example.com" === c.servername) { expect(c.authorized).toBe(false); } // Connection to subdomain 'b' is made after the 'good' context has been // added. //@ts-ignore if ("b.example.com" === c.servername) { expect(c.authorized).toBe(true); clearTimeout(timeout); resolve(); } } catch (e) { clearTimeout(timeout); reject(e); } }); const server = listening_server as tls.Server; // 1. Add the 'bad' secure context. A connection using this context will not be // authorized. server.addContext("*.example.com", badSecureContext); server.listen(0, () => { const options = { port: (server?.address() as AddressInfo).port, host: "127.0.0.1", key: agent1Key, cert: agent1Cert, ca: [ca1], servername: "a.example.com", rejectUnauthorized: false, }; // 2. Make a connection using servername 'a.example.com'. Since a 'bad' // secure context is used, this connection should not be authorized. const client = tls.connect(options, () => { client.end(); }); client.on("close", () => { // 3. Add a 'good' secure context. server.addContext("*.example.com", goodSecureContext); options.servername = "b.example.com"; // 4. Make a connection using servername 'b.example.com'. This connection // should be authorized because the 'good' secure context is the most // recently added matching context. const other = tls.connect(options, () => { other.end(); }); other.on("close", () => { // 5. Make another connection using servername 'b.example.com' to ensure // that the array of secure contexts is not reversed in place with each // SNICallback call, as someone might be tempted to refactor this piece of // code by using Array.prototype.reverse() method. const onemore = tls.connect(options, () => { onemore.end(); }); onemore.on("close", () => { server.close(); }); onemore.on("error", reject); }); other.on("error", reject); }); client.on("error", reject); }); server.on("error", reject); server.on("clientError", reject); await promise; } finally { listening_server?.close(); } }); function testCA(ca: Array) { const { promise, resolve, reject } = Promise.withResolvers(); const server = tls.createServer({ ca, cert: agent3Cert, key: agent3Key }); server.addContext("agent3", { ca, cert: agent3Cert, key: agent3Key }); server.listen(0, "127.0.0.1", () => { const options = { servername: "agent3", host: "127.0.0.1", port: (server.address() as AddressInfo).port, ca, }; var authorized = false; const socket = tls.connect(options, () => { authorized = socket.authorized; socket.end(); }); socket.on("error", reject); socket.on("close", () => { server.close(() => { resolve(authorized); }); }); }); return promise; } it("should allow multiple CA", async () => { // Verify that multiple CA certificates can be provided, and that for // convenience that can also be in newline-separated strings. expect(await testCA([ca1, ca2])).toBeTrue(); }); it("should allow multiple CA in newline-separated strings", async () => { expect(await testCA([ca2 + "\n" + ca1])).toBeTrue(); }); function testClient(options: any, clientResult: boolean, serverResult: string) { const { promise, resolve, reject } = Promise.withResolvers(); const server = tls.createServer(serverOptions, c => { try { //@ts-ignore expect(c.servername).toBe(serverResult); expect(c.authorized).toBe(false); } catch (e) { reject(e); } }); server.addContext("a.example.com", SNIContexts["a.example.com"]); server.addContext("*.test.com", SNIContexts["asterisk.test.com"]); server.addContext("chain.example.com", SNIContexts["chain.example.com"]); server.on("tlsClientError", reject); server.listen(0, () => { const client = tls.connect( { ...options, port: (server.address() as AddressInfo).port, host: "127.0.0.1", rejectUnauthorized: false, }, () => { const result = //@ts-ignore client.authorizationError && client.authorizationError.indexOf("ERR_TLS_CERT_ALTNAME_INVALID") !== -1; if (result !== clientResult) { reject(new Error(`Expected ${clientResult}, got ${result} in ${options.servername}`)); } else { resolve(); } client.end(); }, ); client.on("error", reject); client.on("close", () => { server.close(); }); }); return promise; } it("SNI tls.Server + tls.connect", async () => { await testClient( { ca: [ca1], servername: "a.example.com", }, true, "a.example.com", ); await testClient( { ca: [ca2], servername: "b.test.com", }, true, "b.test.com", ); await testClient( { ca: [ca2], servername: "a.b.test.com", }, false, "a.b.test.com", ); await testClient( { ca: [ca1], servername: "c.wrong.com", }, false, "c.wrong.com", ); await testClient( { ca: [ca1], servername: "chain.example.com", }, true, "chain.example.com", ); }); }); describe("Bun.serve SNI", () => { function doClientRequest(options: any) { return new Promise((resolve, reject) => { const client = tls.connect( { ...options, rejectUnauthorized: false, }, () => { resolve( //@ts-ignore client.authorizationError && client.authorizationError.indexOf("ERR_TLS_CERT_ALTNAME_INVALID") !== -1, ); }, ); client.on("close", resolve); client.on("error", reject); }); } it("single SNI", async () => { { using server = Bun.serve({ port: 0, tls: { ...SNIContexts["asterisk.test.com"], serverName: "*.test.com", }, fetch(req, res) { return new Response(new URL(req.url).hostname); }, }); for (const servername of ["a.test.com", "b.test.com", "c.test.com"]) { const client = await doClientRequest({ ...SNIContexts["asterisk.test.com"], port: server.port, ca: [ca2], servername, }); expect(client).toBe(true); } { const client = await doClientRequest({ ...goodSecureContext, port: server.port, servername: "a.example.com", }); expect(client).toBe(false); } } { using server = Bun.serve({ port: 0, tls: { ...goodSecureContext, serverName: "*.example.com", }, fetch(req, res) { return new Response(new URL(req.url).hostname); }, }); { const client = await doClientRequest({ ...goodSecureContext, port: server.port, servername: "a.example.com", }); expect(client).toBe(true); } { const client = await doClientRequest({ ...goodSecureContext, port: server.port, servername: "b.example.com", }); expect(client).toBe(true); } } }); it("multiple SNI", async () => { { using server = Bun.serve({ port: 0, tls: [ serverOptions, { serverName: "a.example.com", ...SNIContexts["a.example.com"], }, { serverName: "*.test.com", ...SNIContexts["asterisk.test.com"], }, { serverName: "chain.example.com", ...SNIContexts["chain.example.com"], }, ], fetch(req, res) { return new Response("OK"); }, }); expect( await doClientRequest({ ca: [ca1], servername: "a.example.com", port: server.port, }), ).toBe(true); expect( await doClientRequest({ ca: [ca2], servername: "b.test.com", port: server.port, }), ).toBe(true); expect( await doClientRequest({ ca: [ca2], servername: "a.b.test.com", port: server.port, }), ).toBe(false); expect( await doClientRequest({ ca: [ca1], servername: "c.wrong.com", port: server.port, }), ).toBe(false); expect( await doClientRequest({ ca: [ca1], servername: "chain.example.com", port: server.port, }), ).toBe(true); } }); });