From a0356127a880107f83c09478b13a4bc69b904736 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 6 Sep 2026 13:23:31 +0000 Subject: [PATCH 1/4] node:https: forward crl, sessionTimeout, ecdhCurve, sigalgs and honorCipherOrder to the TLS listener https.Server built its Bun.serve tls object from a fixed list of keys. Every other tls.createServer option was dropped with no error, so a client certificate revoked by the configured crl completed mTLS and was served with req.socket.authorized === true. Forward crl, sessionTimeout, ecdhCurve, sigalgs, honorCipherOrder and allowPartialTrustChain, and run the same secure-context validation that tls.createServer runs. The validator moves to internal/tls so both modules share it. --- src/js/internal/tls.ts | 106 +++++++++++++++ src/js/node/_http_server.ts | 24 +++- src/js/node/tls.ts | 102 +------------- test/js/node/tls/node-tls-server.test.ts | 166 +++++++++++++++++++++++ 4 files changed, 296 insertions(+), 102 deletions(-) diff --git a/src/js/internal/tls.ts b/src/js/internal/tls.ts index c826d944b4c1..efe2926421c8 100644 --- a/src/js/internal/tls.ts +++ b/src/js/internal/tls.ts @@ -135,6 +135,110 @@ function secureProtocolToVersionRange(secureProtocol) { return null; } +const VALID_TLS_VERSIONS = new Set(["TLSv1", "TLSv1.1", "TLSv1.2", "TLSv1.3"]); + +const SUPPORTED_ECDH_GROUPS = new Set([ + "P-256", + "prime256v1", + "P-384", + "secp384r1", + "P-521", + "secp521r1", + "X25519", + "x25519", + "X25519MLKEM768", + "MLKEM1024", +]); + +const StringPrototypeSplit = String.prototype.split; + +// Subset of Node's configSecureContext() validations: +// https://github.com/nodejs/node/blob/843dc5f0d5ad/lib/internal/tls/secure-context.js#L318 +function validateSecureContextOptions(options) { + const { validateString, validateBuffer } = require("internal/validators"); + const { + ciphers, + passphrase, + ecdhCurve, + minVersion, + maxVersion, + sessionTimeout, + sigalgs, + ticketKeys, + clientCertEngine, + dhparam, + secureProtocol, + } = options; + validateSecureProtocol(secureProtocol); + if (ciphers !== undefined && ciphers !== null) validateString(ciphers, "options.ciphers"); + if (passphrase !== undefined && passphrase !== null) validateString(passphrase, "options.passphrase"); + if (sigalgs !== undefined && sigalgs !== null) { + validateString(sigalgs, "options.sigalgs"); + if (sigalgs === "") throw $ERR_INVALID_ARG_VALUE("options.sigalgs", sigalgs); + } + if (ecdhCurve !== undefined) { + validateString(ecdhCurve, "options.ecdhCurve"); + if (ecdhCurve !== "auto") { + for (const curve of StringPrototypeSplit.$call(ecdhCurve, ":")) { + if (!SUPPORTED_ECDH_GROUPS.has(curve)) { + // Not $ERR_*: Node's THROW_ERR_CRYPTO_OPERATION_FAILED has no bracketed + // toString; test-tls-ecdh-multiple.js pins /Error: Failed to set ECDH curve/. + const err = new Error("Failed to set ECDH curve") as Error & { code: string }; + err.code = "ERR_CRYPTO_OPERATION_FAILED"; + throw err; + } + } + } + } + // clientCertEngine must be a string (engine name); a provided engine then + // fails because BoringSSL (which Bun always uses) has no OpenSSL ENGINE + // support, matching Node's setClientCertEngine. Node: + // https://github.com/nodejs/node/blob/614050b657e9757c1097aa85f92f2cb51149dc0d/lib/internal/tls/secure-context.js#L296 + if (clientCertEngine !== undefined && clientCertEngine !== null) { + if (typeof clientCertEngine !== "string") { + throw $ERR_INVALID_ARG_TYPE("options.clientCertEngine", ["string", "null", "undefined"], clientCertEngine); + } + throw $ERR_CRYPTO_CUSTOM_ENGINE_NOT_SUPPORTED("Custom engines not supported by this OpenSSL"); + } + // BoringSSL (always used by Bun) has no automatic DH parameter selection. + // Matches Node's setDHParam('auto') throwing ERR_CRYPTO_UNSUPPORTED_OPERATION. + // https://github.com/nodejs/node/blob/614050b657e9757c1097aa85f92f2cb51149dc0d/lib/internal/tls/secure-context.js#L254 + if (dhparam === "auto") { + throw $ERR_CRYPTO_UNSUPPORTED_OPERATION("Automatic DH parameter selection is not supported"); + } + if (minVersion != null && !VALID_TLS_VERSIONS.has(minVersion)) + throw $ERR_TLS_INVALID_PROTOCOL_VERSION(String(minVersion), "minimum"); + if (maxVersion != null && !VALID_TLS_VERSIONS.has(maxVersion)) + throw $ERR_TLS_INVALID_PROTOCOL_VERSION(String(maxVersion), "maximum"); + if (ticketKeys !== undefined && ticketKeys !== null) { + validateBuffer(ticketKeys, "options.ticketKeys"); + const ticketKeysByteLength = ticketKeys.byteLength; + if (ticketKeysByteLength !== 48) { + throw $ERR_INVALID_ARG_VALUE("options.ticketKeys", ticketKeysByteLength, "must be exactly 48 bytes"); + } + } + // Negative session timeouts are rejected (min 0), matching Node — newer + // OpenSSL/BoringSSL do not handle negative values as users expect. + // https://github.com/nodejs/node/blob/614050b657e9757c1097aa85f92f2cb51149dc0d/lib/internal/tls/secure-context.js#L319 + if (sessionTimeout !== undefined && sessionTimeout !== null) { + // Node validates this with validateInt32(..., 0), whose range message + // reads ">= 0 && <= 2147483647"; the shared validator here words it + // differently, so spell the check out to match. + if (typeof sessionTimeout !== "number") { + throw $ERR_INVALID_ARG_TYPE("options.sessionTimeout", "number", sessionTimeout); + } + if (!Number.isInteger(sessionTimeout)) { + throw $ERR_OUT_OF_RANGE("options.sessionTimeout", "an integer", sessionTimeout); + } + if (sessionTimeout < 0 || sessionTimeout > 2147483647) { + throw $ERR_OUT_OF_RANGE("options.sessionTimeout", ">= 0 && <= 2147483647", sessionTimeout); + } + } +} + +// SSL_OP_CIPHER_SERVER_PREFERENCE: `honorCipherOrder` folds into secureOptions. +const SSL_OP_CIPHER_SERVER_PREFERENCE = 0x00400000; + let NativeSecureContext; /** @@ -179,9 +283,11 @@ function processPfxOptions(options) { } export { + SSL_OP_CIPHER_SERVER_PREFERENCE, processPfxOptions, secureProtocolToVersionRange, throwOnInvalidTLSArray, tlsStringToProtocolVersion, + validateSecureContextOptions, validateSecureProtocol, }; diff --git a/src/js/node/_http_server.ts b/src/js/node/_http_server.ts index 45fb072718c6..0bbd86fdd24e 100644 --- a/src/js/node/_http_server.ts +++ b/src/js/node/_http_server.ts @@ -323,6 +323,11 @@ function Server(options, callback): void { this[isTlsSymbol] = true; } + const crl = options.crl; + if (crl && this[isTlsSymbol]) { + tlsHelpers.throwOnInvalidTLSArray("options.crl", crl); + } + let passphrase = options.passphrase; if (passphrase && typeof passphrase !== "string") { throw $ERR_INVALID_ARG_TYPE("options.passphrase", "string", passphrase); @@ -339,11 +344,21 @@ function Server(options, callback): void { } if (this[isTlsSymbol]) { - const { validateSecureProtocol, secureProtocolToVersionRange, tlsStringToProtocolVersion } = tlsHelpers; + const { + validateSecureContextOptions, + secureProtocolToVersionRange, + tlsStringToProtocolVersion, + SSL_OP_CIPHER_SERVER_PREFERENCE, + } = tlsHelpers; + // The same checks tls.createServer runs (ecdhCurve, sigalgs, + // sessionTimeout, secureProtocol, ...), so a bad option throws here + // instead of reaching the native config. + validateSecureContextOptions(options); + // Node's tls.Server defaults honorCipherOrder to true. + if (options.honorCipherOrder !== false) secureOptions |= SSL_OP_CIPHER_SERVER_PREFERENCE; // Translate minVersion/maxVersion/secureProtocol into the integer // protocol range the native layer applies (secureProtocol wins, like // Node's SecureContext::Init); 0 keeps the native defaults. - validateSecureProtocol(options.secureProtocol); let minVersion, maxVersion; const range = secureProtocolToVersionRange(options.secureProtocol); if (range) { @@ -358,6 +373,7 @@ function Server(options, callback): void { key, cert, ca, + crl, passphrase, secureOptions, minVersion, @@ -365,6 +381,10 @@ function Server(options, callback): void { ciphers: typeof options.ciphers === "string" && options.ciphers ? options.ciphers : undefined, requestCert: options.requestCert, rejectUnauthorized: options.rejectUnauthorized, + sessionTimeout: options.sessionTimeout ?? undefined, + ecdhCurve: options.ecdhCurve, + sigalgs: options.sigalgs, + allowPartialTrustChain: !!options.allowPartialTrustChain, }); } else { this[tlsSymbol] = null; diff --git a/src/js/node/tls.ts b/src/js/node/tls.ts index cf07c368767e..496bb3206fed 100644 --- a/src/js/node/tls.ts +++ b/src/js/node/tls.ts @@ -11,6 +11,8 @@ const { secureProtocolToVersionRange, processPfxOptions, validateSecureProtocol, + validateSecureContextOptions, + SSL_OP_CIPHER_SERVER_PREFERENCE, } = require("internal/tls"); const { validateString, @@ -185,104 +187,6 @@ function validateCiphers(ciphers: string, name: string = "options") { } } -const VALID_TLS_VERSIONS = new Set(["TLSv1", "TLSv1.1", "TLSv1.2", "TLSv1.3"]); - -const SUPPORTED_ECDH_GROUPS = new Set([ - "P-256", - "prime256v1", - "P-384", - "secp384r1", - "P-521", - "secp521r1", - "X25519", - "x25519", - "X25519MLKEM768", - "MLKEM1024", -]); - -// Subset of Node's configSecureContext() validations: -// https://github.com/nodejs/node/blob/843dc5f0d5ad/lib/internal/tls/secure-context.js#L318 -function validateSecureContextOptions(options) { - const { - ciphers, - passphrase, - ecdhCurve, - minVersion, - maxVersion, - sessionTimeout, - sigalgs, - ticketKeys, - clientCertEngine, - dhparam, - secureProtocol, - } = options; - validateSecureProtocol(secureProtocol); - if (ciphers !== undefined && ciphers !== null) validateString(ciphers, "options.ciphers"); - if (passphrase !== undefined && passphrase !== null) validateString(passphrase, "options.passphrase"); - if (sigalgs !== undefined && sigalgs !== null) { - validateString(sigalgs, "options.sigalgs"); - if (sigalgs === "") throw $ERR_INVALID_ARG_VALUE("options.sigalgs", sigalgs); - } - if (ecdhCurve !== undefined) { - validateString(ecdhCurve, "options.ecdhCurve"); - if (ecdhCurve !== "auto") { - for (const curve of StringPrototypeSplit.$call(ecdhCurve, ":")) { - if (!SUPPORTED_ECDH_GROUPS.has(curve)) { - // Not $ERR_*: Node's THROW_ERR_CRYPTO_OPERATION_FAILED has no bracketed - // toString; test-tls-ecdh-multiple.js pins /Error: Failed to set ECDH curve/. - const err = new Error("Failed to set ECDH curve") as Error & { code: string }; - err.code = "ERR_CRYPTO_OPERATION_FAILED"; - throw err; - } - } - } - } - // clientCertEngine must be a string (engine name); a provided engine then - // fails because BoringSSL (which Bun always uses) has no OpenSSL ENGINE - // support, matching Node's setClientCertEngine. Node: - // https://github.com/nodejs/node/blob/614050b657e9757c1097aa85f92f2cb51149dc0d/lib/internal/tls/secure-context.js#L296 - if (clientCertEngine !== undefined && clientCertEngine !== null) { - if (typeof clientCertEngine !== "string") { - throw $ERR_INVALID_ARG_TYPE("options.clientCertEngine", ["string", "null", "undefined"], clientCertEngine); - } - throw $ERR_CRYPTO_CUSTOM_ENGINE_NOT_SUPPORTED("Custom engines not supported by this OpenSSL"); - } - // BoringSSL (always used by Bun) has no automatic DH parameter selection. - // Matches Node's setDHParam('auto') throwing ERR_CRYPTO_UNSUPPORTED_OPERATION. - // https://github.com/nodejs/node/blob/614050b657e9757c1097aa85f92f2cb51149dc0d/lib/internal/tls/secure-context.js#L254 - if (dhparam === "auto") { - throw $ERR_CRYPTO_UNSUPPORTED_OPERATION("Automatic DH parameter selection is not supported"); - } - if (minVersion != null && !VALID_TLS_VERSIONS.has(minVersion)) - throw $ERR_TLS_INVALID_PROTOCOL_VERSION(String(minVersion), "minimum"); - if (maxVersion != null && !VALID_TLS_VERSIONS.has(maxVersion)) - throw $ERR_TLS_INVALID_PROTOCOL_VERSION(String(maxVersion), "maximum"); - if (ticketKeys !== undefined && ticketKeys !== null) { - validateBuffer(ticketKeys, "options.ticketKeys"); - const ticketKeysByteLength = ticketKeys.byteLength; - if (ticketKeysByteLength !== 48) { - throw $ERR_INVALID_ARG_VALUE("options.ticketKeys", ticketKeysByteLength, "must be exactly 48 bytes"); - } - } - // Negative session timeouts are rejected (min 0), matching Node — newer - // OpenSSL/BoringSSL do not handle negative values as users expect. - // https://github.com/nodejs/node/blob/614050b657e9757c1097aa85f92f2cb51149dc0d/lib/internal/tls/secure-context.js#L319 - if (sessionTimeout !== undefined && sessionTimeout !== null) { - // Node validates this with validateInt32(..., 0), whose range message - // reads ">= 0 && <= 2147483647"; the shared validator here words it - // differently, so spell the check out to match. - if (typeof sessionTimeout !== "number") { - throw $ERR_INVALID_ARG_TYPE("options.sessionTimeout", "number", sessionTimeout); - } - if (!Number.isInteger(sessionTimeout)) { - throw $ERR_OUT_OF_RANGE("options.sessionTimeout", "an integer", sessionTimeout); - } - if (sessionTimeout < 0 || sessionTimeout > 2147483647) { - throw $ERR_OUT_OF_RANGE("options.sessionTimeout", ">= 0 && <= 2147483647", sessionTimeout); - } - } -} - const SymbolReplace = Symbol.replace; const RegExpPrototypeSymbolReplace = RegExp.prototype[SymbolReplace]; const RegExpPrototypeExec = RegExp.prototype.exec; @@ -536,8 +440,6 @@ function normalizePemKeyOption(key, ctxPassphrase) { }); } -const SSL_OP_CIPHER_SERVER_PREFERENCE = 0x00400000; - function newNativeSecureContext(options, cached = false) { maybeWarnAboutExtraCACerts(); // tls.createSecureContext() with no options still goes through the version diff --git a/test/js/node/tls/node-tls-server.test.ts b/test/js/node/tls/node-tls-server.test.ts index 707474890f20..a72844635abc 100644 --- a/test/js/node/tls/node-tls-server.test.ts +++ b/test/js/node/tls/node-tls-server.test.ts @@ -1068,6 +1068,172 @@ it("createServer({pfx, requestCert}) verifies client certificates against the pf } }); +describe("https.createServer forwards every TLS server option", () => { + // ca2 signs agent3 and agent4; ca2-crl-agent3.pem revokes agent3 only. + const fixtures = join(import.meta.dir, "../test/fixtures/keys"); + const read = (name: string) => readFileSync(join(fixtures, name), "utf8"); + const mtls = { + key: read("agent1-key.pem"), + cert: read("agent1-cert.pem"), + ca: read("ca2-cert.pem"), + crl: read("ca2-crl-agent3.pem"), + requestCert: true, + }; + + type Verdict = { authorized: boolean | undefined; authorizationError: unknown }; + + async function httpsRequest(port: number, agent: string) { + const outcome = Promise.withResolvers(); + const req = https.request( + { + host: "127.0.0.1", + port, + path: `/${agent}`, + key: read(`${agent}-key.pem`), + cert: read(`${agent}-cert.pem`), + rejectUnauthorized: false, + }, + res => { + const chunks: Buffer[] = []; + res.on("data", chunk => chunks.push(chunk)); + res.on("end", () => outcome.resolve(`served ${Buffer.concat(chunks)}`)); + }, + ); + req.on("error", () => outcome.resolve("refused")); + req.end(); + return outcome.promise; + } + + it("refuses a client certificate that the crl revokes", async () => { + const verdicts: Record = {}; + await using server = https.createServer({ ...mtls, rejectUnauthorized: true }, (req, res) => { + const socket = req.socket as TLSSocket; + const name = req.url!.slice(1); + verdicts[name] = { authorized: socket.authorized, authorizationError: socket.authorizationError }; + res.end(name); + }); + await once(server.listen(0, "127.0.0.1"), "listening"); + const { port } = server.address() as AddressInfo; + + expect({ + revoked: await httpsRequest(port, "agent3"), + good: await httpsRequest(port, "agent4"), + verdicts, + }).toEqual({ + revoked: "refused", + good: "served agent4", + verdicts: { agent4: { authorized: true, authorizationError: null } }, + }); + }); + + it("reports CERT_REVOKED on req.socket when rejectUnauthorized is false", async () => { + const verdicts: Record = {}; + await using server = https.createServer({ ...mtls, rejectUnauthorized: false }, (req, res) => { + const socket = req.socket as TLSSocket; + const name = req.url!.slice(1); + verdicts[name] = { authorized: socket.authorized, authorizationError: socket.authorizationError }; + res.end(name); + }); + await once(server.listen(0, "127.0.0.1"), "listening"); + const { port } = server.address() as AddressInfo; + + expect({ + revoked: await httpsRequest(port, "agent3"), + good: await httpsRequest(port, "agent4"), + verdicts, + }).toEqual({ + revoked: "served agent3", + good: "served agent4", + verdicts: { + agent3: { authorized: false, authorizationError: "CERT_REVOKED" }, + agent4: { authorized: true, authorizationError: null }, + }, + }); + }); + + it("restricts the key-share groups to ecdhCurve", async () => { + await using server = https.createServer({ ...COMMON_CERT, ecdhCurve: "P-384" }, (_req, res) => res.end("ok")); + server.on("tlsClientError", () => {}); + await once(server.listen(0, "127.0.0.1"), "listening"); + const { port } = server.address() as AddressInfo; + + const handshake = async (ecdhCurve: string) => { + const outcome = Promise.withResolvers(); + const client = connect({ port, host: "127.0.0.1", rejectUnauthorized: false, ecdhCurve }); + client.once("secureConnect", () => { + client.destroy(); + outcome.resolve("ok"); + }); + client.once("error", err => { + client.destroy(); + const code = (err as Error & { code?: string }).code ?? "error"; + outcome.resolve(code.includes("HANDSHAKE_FAILURE") ? "handshake_failure" : code); + }); + client.once("close", () => outcome.resolve("closed")); + return outcome.promise; + }; + + expect({ + x25519Only: await handshake("X25519"), + p384Only: await handshake("P-384"), + }).toEqual({ + x25519Only: "handshake_failure", + p384Only: "ok", + }); + }); + + it("honors the server cipher order unless honorCipherOrder is false", async () => { + const aes256 = "ECDHE-RSA-AES256-GCM-SHA384"; + const aes128 = "ECDHE-RSA-AES128-GCM-SHA256"; + const negotiated = async (honorCipherOrder: boolean | undefined) => { + await using server = https.createServer( + { ...COMMON_CERT, ciphers: `${aes256}:${aes128}`, honorCipherOrder }, + (_req, res) => res.end("ok"), + ); + await once(server.listen(0, "127.0.0.1"), "listening"); + const { port } = server.address() as AddressInfo; + const client = connect({ + port, + host: "127.0.0.1", + rejectUnauthorized: false, + ciphers: `${aes128}:${aes256}`, + maxVersion: "TLSv1.2", + }); + await once(client, "secureConnect"); + const name = client.getCipher().name; + client.destroy(); + return name; + }; + expect({ + default: await negotiated(undefined), + honored: await negotiated(true), + clientOrder: await negotiated(false), + }).toEqual({ default: aes256, honored: aes256, clientOrder: aes128 }); + }); + + it("validates the secure context options like tls.createServer", () => { + const check = (options: object) => { + try { + https.createServer({ ...COMMON_CERT, ...options }); + } catch (e: any) { + return e.code; + } + return "no error"; + }; + expect({ + ecdhCurve: check({ ecdhCurve: "not-a-curve" }), + sessionTimeout: check({ sessionTimeout: -1 }), + sigalgs: check({ sigalgs: "" }), + crl: check({ crl: 42 }), + }).toEqual({ + ecdhCurve: "ERR_CRYPTO_OPERATION_FAILED", + sessionTimeout: "ERR_OUT_OF_RANGE", + sigalgs: "ERR_INVALID_ARG_VALUE", + crl: "ERR_INVALID_ARG_TYPE", + }); + }); +}); + it("SNICallback errors abort the handshake and surface as tlsClientError", async () => { // Node drops the connection before the handshake completes (no TLS alert is // sent) and emits 'tlsClientError' on the server with the callback's error. From a90c68e94f2e81b8d62a9e974bb25db32c59ca7f Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 6 Sep 2026 13:46:52 +0000 Subject: [PATCH 2/4] node:tls: drop the unused validateSecureProtocol import --- src/js/node/tls.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/js/node/tls.ts b/src/js/node/tls.ts index 496bb3206fed..dec056420fbb 100644 --- a/src/js/node/tls.ts +++ b/src/js/node/tls.ts @@ -10,7 +10,6 @@ const { tlsStringToProtocolVersion, secureProtocolToVersionRange, processPfxOptions, - validateSecureProtocol, validateSecureContextOptions, SSL_OP_CIPHER_SERVER_PREFERENCE, } = require("internal/tls"); From 3f70b7e749a32534a207a4ce6f1ef91c63cb9d4d Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 6 Sep 2026 13:48:51 +0000 Subject: [PATCH 3/4] node:https: shorten a comment --- src/js/node/_http_server.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/js/node/_http_server.ts b/src/js/node/_http_server.ts index 0bbd86fdd24e..12adec5e7ea5 100644 --- a/src/js/node/_http_server.ts +++ b/src/js/node/_http_server.ts @@ -350,9 +350,7 @@ function Server(options, callback): void { tlsStringToProtocolVersion, SSL_OP_CIPHER_SERVER_PREFERENCE, } = tlsHelpers; - // The same checks tls.createServer runs (ecdhCurve, sigalgs, - // sessionTimeout, secureProtocol, ...), so a bad option throws here - // instead of reaching the native config. + // The same checks tls.createServer runs. validateSecureContextOptions(options); // Node's tls.Server defaults honorCipherOrder to true. if (options.honorCipherOrder !== false) secureOptions |= SSL_OP_CIPHER_SERVER_PREFERENCE; From 2e0899f14f7c58beac2a36957fc3d4ab0b784a00 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 7 Sep 2026 05:12:35 +0000 Subject: [PATCH 4/4] node:https: fall back to tls.DEFAULT_ECDH_CURVE when ecdhCurve is omitted tls.Server reads tls.DEFAULT_ECDH_CURVE when no ecdhCurve is given. Do the same on the https path so both servers negotiate the same groups. --- src/js/node/_http_server.ts | 2 +- test/js/node/tls/node-tls-server.test.ts | 57 ++++++++++++++++-------- 2 files changed, 40 insertions(+), 19 deletions(-) diff --git a/src/js/node/_http_server.ts b/src/js/node/_http_server.ts index 12adec5e7ea5..2998a7390232 100644 --- a/src/js/node/_http_server.ts +++ b/src/js/node/_http_server.ts @@ -380,7 +380,7 @@ function Server(options, callback): void { requestCert: options.requestCert, rejectUnauthorized: options.rejectUnauthorized, sessionTimeout: options.sessionTimeout ?? undefined, - ecdhCurve: options.ecdhCurve, + ecdhCurve: options.ecdhCurve ?? require("node:tls").DEFAULT_ECDH_CURVE, sigalgs: options.sigalgs, allowPartialTrustChain: !!options.allowPartialTrustChain, }); diff --git a/test/js/node/tls/node-tls-server.test.ts b/test/js/node/tls/node-tls-server.test.ts index a72844635abc..bb8a915bfb31 100644 --- a/test/js/node/tls/node-tls-server.test.ts +++ b/test/js/node/tls/node-tls-server.test.ts @@ -1151,37 +1151,58 @@ describe("https.createServer forwards every TLS server option", () => { }); }); + const ecdhHandshake = async (port: number, ecdhCurve: string) => { + const outcome = Promise.withResolvers(); + const client = connect({ port, host: "127.0.0.1", rejectUnauthorized: false, ecdhCurve }); + client.once("secureConnect", () => { + client.destroy(); + outcome.resolve("ok"); + }); + client.once("error", err => { + client.destroy(); + const code = (err as Error & { code?: string }).code ?? "error"; + outcome.resolve(code.includes("HANDSHAKE_FAILURE") ? "handshake_failure" : code); + }); + client.once("close", () => outcome.resolve("closed")); + return outcome.promise; + }; + it("restricts the key-share groups to ecdhCurve", async () => { await using server = https.createServer({ ...COMMON_CERT, ecdhCurve: "P-384" }, (_req, res) => res.end("ok")); server.on("tlsClientError", () => {}); await once(server.listen(0, "127.0.0.1"), "listening"); const { port } = server.address() as AddressInfo; - const handshake = async (ecdhCurve: string) => { - const outcome = Promise.withResolvers(); - const client = connect({ port, host: "127.0.0.1", rejectUnauthorized: false, ecdhCurve }); - client.once("secureConnect", () => { - client.destroy(); - outcome.resolve("ok"); - }); - client.once("error", err => { - client.destroy(); - const code = (err as Error & { code?: string }).code ?? "error"; - outcome.resolve(code.includes("HANDSHAKE_FAILURE") ? "handshake_failure" : code); - }); - client.once("close", () => outcome.resolve("closed")); - return outcome.promise; - }; - expect({ - x25519Only: await handshake("X25519"), - p384Only: await handshake("P-384"), + x25519Only: await ecdhHandshake(port, "X25519"), + p384Only: await ecdhHandshake(port, "P-384"), }).toEqual({ x25519Only: "handshake_failure", p384Only: "ok", }); }); + it("falls back to tls.DEFAULT_ECDH_CURVE when ecdhCurve is omitted", async () => { + const saved = tls.DEFAULT_ECDH_CURVE; + tls.DEFAULT_ECDH_CURVE = "P-384"; + try { + await using server = https.createServer({ ...COMMON_CERT }, (_req, res) => res.end("ok")); + server.on("tlsClientError", () => {}); + await once(server.listen(0, "127.0.0.1"), "listening"); + const { port } = server.address() as AddressInfo; + + expect({ + x25519Only: await ecdhHandshake(port, "X25519"), + p384Only: await ecdhHandshake(port, "P-384"), + }).toEqual({ + x25519Only: "handshake_failure", + p384Only: "ok", + }); + } finally { + tls.DEFAULT_ECDH_CURVE = saved; + } + }); + it("honors the server cipher order unless honorCipherOrder is false", async () => { const aes256 = "ECDHE-RSA-AES256-GCM-SHA384"; const aes128 = "ECDHE-RSA-AES128-GCM-SHA256";