From a7e84cd48c34bfee5ab3c5e09460e0d85d5b069d Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Wed, 21 Oct 2026 13:56:07 -0500 Subject: [PATCH 1/2] Support SNICallback in tls.createServer tls.createServer({ cert, key, SNICallback }) hands each ClientHello's servername to the callback and serves the SecureContext it answers, synchronously or later, or the default cert and key when it answers none. An error answer closes that connection. This uses the SNI handling that http2.createSecureServer already has. A callback expression with side effects, or SNICallback combined with ALPNProtocols, minVersion, maxVersion or the other options the runtime walk reads, fails with SC2020. A tls.connect or https.request client whose peer closes or resets the connection before the handshake completes now fails with Node's ECONNRESET "Client network socket disconnected before secure TLS connection was established" instead of closing without an error. Refs #1438 Co-authored-by: saboursepideh <25549001+saboursepideh@users.noreply.github.com> --- .../compiler/src/backend/llvm/lib-http2.ts | 34 ++-- packages/compiler/src/backend/llvm/lib-tls.ts | 22 +++ .../src/frontend/lowering/lower-server.ts | 157 ++++++++++++------ .../compiler/src/ir/builtin-signatures.ts | 5 + .../corpus/6933-tls-sni-callback.ts.json | 6 + .../6934-tls-handshake-disconnect.ts.json | 6 + .../tls-sni-callback-fences.ts.json | 6 + packages/runtime/src/scr_error.c | 6 +- packages/runtime/src/scr_tls.c | 35 +++- packages/runtime/src/scr_tls.h | 3 + tests/corpus/6933-tls-sni-callback.ts | 65 ++++++++ tests/corpus/6934-tls-handshake-disconnect.ts | 27 +++ tests/diagnostics/tls-sni-callback-fences.ts | 15 ++ .../tls-sni-callback-fences.ts.txt | 17 ++ 14 files changed, 330 insertions(+), 74 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6933-tls-sni-callback.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6934-tls-handshake-disconnect.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/tls-sni-callback-fences.ts.json create mode 100644 tests/corpus/6933-tls-sni-callback.ts create mode 100644 tests/corpus/6934-tls-handshake-disconnect.ts create mode 100644 tests/diagnostics/tls-sni-callback-fences.ts create mode 100644 tests/harness/__snapshots__/tls-sni-callback-fences.ts.txt diff --git a/packages/compiler/src/backend/llvm/lib-http2.ts b/packages/compiler/src/backend/llvm/lib-http2.ts index 0254db555..791987f86 100644 --- a/packages/compiler/src/backend/llvm/lib-http2.ts +++ b/packages/compiler/src/backend/llvm/lib-http2.ts @@ -3,6 +3,7 @@ import type { IrType } from "../../ir/ir.js"; import { typeKey } from "../../ir/ir.js"; import type { LibCallExpr, LlValue, LlvmEmitterContext } from "./expr-context.js"; import { + type AbiArgument, abiValue, callbackAdapter, callbackType, @@ -84,6 +85,23 @@ function sniAnswer(host: LlvmEmitterContext, type: IrType): string { return symbol; } +/** The (closure, answer thunk) pair of an SNICallback argument: a function, + * or a function | undefined union whose undefined arm passes no callback. */ +export function sniCallbackArgs(host: LlvmEmitterContext, cb: LlValue): AbiArgument[] { + let type = cb.type, + value = cb.name; + if (type.kind === "union") { + const arms = host.unionsById.get(type.unionId)?.arms ?? []; + const tag = arms.findIndex((arm) => arm.kind === "func"); + if (tag < 0) throw new InternalCompilerError("SNI callback union lacks its function arm"); + type = arms[tag]!; + value = host.unwrapNullableClosure(value, tag); + } else host.toHeap(cb); + if (type.kind !== "func" || type.params[1] === undefined) + throw new InternalCompilerError("SNI callback lacks its answer function"); + return [ptr(value), ptr("@" + sniAnswer(host, type.params[1]))]; +} + export function emitHttp2LibCall(host: LlvmEmitterContext, e: LibCallExpr): LlValue { const args = e.args.map((arg) => host.emitExpr(arg)), a = args.map((arg) => abiValue(host, arg)); @@ -160,21 +178,7 @@ export function emitHttp2LibCall(host: LlvmEmitterContext, e: LibCallExpr): LlVa let handler = [ptr(), ptr()], sni = [ptr(), ptr()]; if (e.fn === "http2.createSecureServerReq") handler = requestHandler(host, args[2]!); - if (e.fn === "http2.createSecureServerSni") { - const cb = args[2]!; - let type = cb.type, - value = cb.name; - if (type.kind === "union") { - const arms = host.unionsById.get(type.unionId)?.arms ?? []; - const tag = arms.findIndex((arm) => arm.kind === "func"); - if (tag < 0) throw new InternalCompilerError("SNI callback union lacks its function arm"); - type = arms[tag]!; - value = host.unwrapNullableClosure(value, tag); - } else host.toHeap(cb); - if (type.kind !== "func" || type.params[1] === undefined) - throw new InternalCompilerError("SNI callback lacks its answer function"); - sni = [ptr(value), ptr("@" + sniAnswer(host, type.params[1]))]; - } + if (e.fn === "http2.createSecureServerSni") sni = sniCallbackArgs(host, args[2]!); return finishRuntimeCall(host, e, "scr_http2_create_secure_server_allow_http1", [ ...rawBytes(host, args[0]!), ...rawBytes(host, args[1]!), diff --git a/packages/compiler/src/backend/llvm/lib-tls.ts b/packages/compiler/src/backend/llvm/lib-tls.ts index 18fc006d4..c5c77d3a6 100644 --- a/packages/compiler/src/backend/llvm/lib-tls.ts +++ b/packages/compiler/src/backend/llvm/lib-tls.ts @@ -11,6 +11,7 @@ import { rawBytes, requestHandler, } from "./lib-abi.js"; +import { sniCallbackArgs } from "./lib-http2.js"; import { emitAlwaysThrowLibCall } from "./lib-shared.js"; export function emitTlsLibCall(host: LlvmEmitterContext, e: LibCallExpr): LlValue { @@ -43,6 +44,27 @@ export function emitTlsLibCall(host: LlvmEmitterContext, e: LibCallExpr): LlValu [...rawBytes(host, args[0]!), ...rawBytes(host, args[1]!), ...handler], ); } + case "tls.createServerSni": + case "tls.createServerSniCb": { + const cb = args[3]; + const handler = + cb === undefined + ? [ptr(), ptr()] + : callbackAdapter( + host, + cb, + callbackType(cb).params.length === 0 + ? "scr_net_conn_thunk0" + : "scr_net_conn_thunk_sock", + ["ptr", "ptr"], + ); + return finishRuntimeCall(host, e, "scr_tls_create_server_sni", [ + ...rawBytes(host, args[0]!), + ...rawBytes(host, args[1]!), + ...handler, + ...sniCallbackArgs(host, args[2]!), + ]); + } case "tls.createServerDyn": case "tls.createServerDynCb": case "https.createServerDyn": diff --git a/packages/compiler/src/frontend/lowering/lower-server.ts b/packages/compiler/src/frontend/lowering/lower-server.ts index 4a7c00d84..c27b2513e 100644 --- a/packages/compiler/src/frontend/lowering/lower-server.ts +++ b/packages/compiler/src/frontend/lowering/lower-server.ts @@ -4749,7 +4749,8 @@ function lowerTlsServerOptions( lowerer: Lowerer, node: ts.Expression, what: string, -): { cert: IrExpr; key: IrExpr } { + allowSni = false, +): { cert: IrExpr; key: IrExpr; sni?: IrExpr } { if (!ts.isObjectLiteralExpression(node)) { lowerer.noLowering( `${what} with a non-literal options argument`, @@ -4760,6 +4761,7 @@ function lowerTlsServerOptions( const loc = locOf(node); let cert: IrExpr | null = null; let key: IrExpr | null = null; + let sni: IrExpr | undefined; for (const prop of node.properties.nodes) { let initializer: ts.Expression | null; if ( @@ -4777,6 +4779,23 @@ function lowerTlsServerOptions( ); } const k = (prop.name as ts.Identifier | ts.StringLiteral).text; + if (k === "SNICallback" && allowSni) { + // The callback is a value read (a function or a binding), so it may + // be read after cert and key without changing what the program sees. + if (initializer !== null && !sideEffectFreeOptionValue(initializer)) { + lowerer.noLowering( + `a ${what} SNICallback expression with side effects`, + prop, + "pass a function or a variable that holds one", + ); + } + const value = + initializer !== null + ? lowerer.lowerExpr(initializer) + : lowerer.lowerShorthandValue(prop as ts.ShorthandPropertyAssignment); + sni = checkSniCallbackValue(lowerer, value, prop, what); + continue; + } if (k !== "cert" && k !== "key") { const init = ts.isPropertyAssignment(prop) ? prop.initializer : null; if ( @@ -4801,7 +4820,7 @@ function lowerTlsServerOptions( "cert and key (PEM strings or Buffers) are the supported options", { SNICallback: - "SNICallback lowers only on http2.createSecureServer({ allowHTTP1: true, ... }) — serve one cert/key pair here", + "SNICallback lowers on tls.createServer and http2.createSecureServer({ allowHTTP1: true, ... }) — serve one cert/key pair here", requestCert: "client-certificate handshakes are not modeled (SEMANTICS.md divergence 55) — requestCert: false is the lowered value", }, @@ -4848,7 +4867,7 @@ function lowerTlsServerOptions( "the supported options object is { cert, key } (PEM strings or Buffers)", ); } - return { cert, key }; + return sni === undefined ? { cert, key } : { cert, key, sni }; } /** The options argument of tls/https createServer, split three ways: an @@ -4911,12 +4930,24 @@ function lowerTlsServerOptionsOrDyn( what: string, includeHttpTimeoutOptions = false, runtimeKeys: ReadonlySet = new Set(), -): { cert: IrExpr; key: IrExpr; dyn?: undefined } | { dyn: IrExpr } { - if ( - ts.isObjectLiteralExpression(node) && - !tlsLiteralNeedsRuntimeWalk(node, includeHttpTimeoutOptions, runtimeKeys) - ) { - return lowerTlsServerOptions(lowerer, node, what); + allowSni = false, +): { cert: IrExpr; key: IrExpr; sni?: IrExpr; dyn?: undefined } | { dyn: IrExpr } { + if (ts.isObjectLiteralExpression(node)) { + if (!tlsLiteralNeedsRuntimeWalk(node, includeHttpTimeoutOptions, runtimeKeys)) + return lowerTlsServerOptions(lowerer, node, what, allowSni); + const sniOption = node.properties.nodes.find( + (p) => + (ts.isPropertyAssignment(p) || ts.isShorthandPropertyAssignment(p)) && + (ts.isIdentifier(p.name) || ts.isStringLiteral(p.name)) && + p.name.text === "SNICallback", + ); + if (allowSni && sniOption !== undefined) { + lowerer.noLowering( + `${what} with SNICallback and options the runtime walk reads`, + sniOption, + "SNICallback lowers with cert and key; ALPNProtocols, minVersion, maxVersion and the validated options are not combined with it yet", + ); + } } const v = lowerer.lowerExpr(node); if (v.type.kind === "dyn") return { dyn: v }; @@ -5078,10 +5109,25 @@ function lowerTlsModuleCall( "tls.createServer", false, TLS_SERVER_RUNTIME_OPTIONS, + true, ); if (args.length === 1) { - return opts.dyn !== undefined - ? { kind: "libCall", fn: "tls.createServerDyn", args: [opts.dyn], type: NETSERVER_T, loc } + if (opts.dyn !== undefined) + return { + kind: "libCall", + fn: "tls.createServerDyn", + args: [opts.dyn], + type: NETSERVER_T, + loc, + }; + return opts.sni !== undefined + ? { + kind: "libCall", + fn: "tls.createServerSni", + args: [opts.cert, opts.key, opts.sni], + type: NETSERVER_T, + loc, + } : { kind: "libCall", fn: "tls.createServer", @@ -5099,11 +5145,19 @@ function lowerTlsModuleCall( "use (socket) or ()", [NETSOCKET_T], ); - return opts.dyn !== undefined + if (opts.dyn !== undefined) + return { + kind: "libCall", + fn: "tls.createServerDynCb", + args: [opts.dyn, cb], + type: NETSERVER_T, + loc, + }; + return opts.sni !== undefined ? { kind: "libCall", - fn: "tls.createServerDynCb", - args: [opts.dyn, cb], + fn: "tls.createServerSniCb", + args: [opts.cert, opts.key, opts.sni, cb], type: NETSERVER_T, loc, } @@ -5481,6 +5535,44 @@ const SNI_HINT = "...(x ? { SNICallback: x } : {}) — the callback is (servername, cb) => void " + "with cb: (err: Error | null, ctx?: tls.SecureContext) => void"; +/** An SNICallback option value: (servername: string, cb) => void where cb + * is (err: Error | null, ctx?: SecureContext) => void, or that function + * unioned with undefined (no callback). cb may declare fewer params (the + * emitted answer thunk matches its ABI), but what it does declare must + * carry the arms the thunk decodes. */ +function lowerSniCallbackValue( + lowerer: Lowerer, + node: ts.Expression, + blame: ts.Node, + what: string, +): IrExpr { + return checkSniCallbackValue(lowerer, lowerer.lowerExpr(node), blame, what); +} + +function checkSniCallbackValue(lowerer: Lowerer, v: IrExpr, blame: ts.Node, what: string): IrExpr { + const shapeOk = (t: IrType): boolean => { + if (t.kind !== "func" || t.ret.kind !== "void" || t.params.length !== 2) return false; + if (t.params[0]!.kind !== "string") return false; + const cbT = t.params[1]!; + if (cbT.kind !== "func" || cbT.ret.kind !== "void" || cbT.params.length > 2) return false; + const armKinds = (u: IrType): string[] => + u.kind === "union" ? (lowerer.unions.get(u.unionId)?.arms ?? []).map((a) => a.kind) : []; + const p0 = cbT.params[0]; + if (p0 !== undefined && !armKinds(p0).includes("nullT")) return false; + const p1 = cbT.params[1]; + if (p1 !== undefined && !armKinds(p1).includes("secureCtx")) return false; + return true; + }; + const arms = v.type.kind === "union" ? (lowerer.unions.get(v.type.unionId)?.arms ?? []) : []; + const funcArm = v.type.kind === "func" ? v.type : arms.find((a) => a.kind === "func"); + const unionOk = + v.type.kind !== "union" || (arms.length === 2 && arms.some((a) => a.kind === "undefinedT")); + if (funcArm === undefined || !unionOk || !shapeOk(funcArm)) { + lowerer.noLowering(`a ${what} SNICallback of '${lowerer.fmt(v.type)}' values`, blame, SNI_HINT); + } + return v; +} + /** Strips parentheses and type assertions: the portless spelling wraps * an options spread as `...({ ... } as Record)`. */ function stripParensAndCasts(node: ts.Expression): ts.Expression { @@ -5733,43 +5825,8 @@ function lowerHttp2ModuleCall( let sni: IrExpr | null = null; let allowHttp1 = false; let enableConnectProtocol = false; - // The SNICallback value shape: (servername: string, cb) => void where - // cb is (err: Error | null, ctx?: SecureContext) => void — cb may - // declare fewer params (the emitted answer thunk matches its ABI), - // but what it does declare must carry the arms the thunk decodes. - const sniShapeOk = (t: IrType): boolean => { - if (t.kind !== "func" || t.ret.kind !== "void" || t.params.length !== 2) return false; - if (t.params[0]!.kind !== "string") return false; - const cbT = t.params[1]!; - if (cbT.kind !== "func" || cbT.ret.kind !== "void" || cbT.params.length > 2) return false; - const armKinds = (u: IrType): string[] => - u.kind === "union" ? (lowerer.unions.get(u.unionId)?.arms ?? []).map((a) => a.kind) : []; - const p0 = cbT.params[0]; - if (p0 !== undefined && !armKinds(p0).includes("nullT")) return false; - const p1 = cbT.params[1]; - if (p1 !== undefined && !armKinds(p1).includes("secureCtx")) return false; - return true; - }; const lowerSniValue = (node: ts.Expression, blame: ts.Node): void => { - const v = lowerer.lowerExpr(node); - const funcArm = - v.type.kind === "func" - ? v.type - : v.type.kind === "union" - ? (lowerer.unions.get(v.type.unionId)?.arms ?? []).find((a) => a.kind === "func") - : undefined; - const unionOk = - v.type.kind !== "union" || - ((lowerer.unions.get(v.type.unionId)?.arms ?? []).length === 2 && - (lowerer.unions.get(v.type.unionId)?.arms ?? []).some((a) => a.kind === "undefinedT")); - if (funcArm === undefined || !unionOk || !sniShapeOk(funcArm)) { - lowerer.noLowering( - `a createSecureServer SNICallback of '${lowerer.fmt(v.type)}' values`, - blame, - SNI_HINT, - ); - } - sni = v; + sni = lowerSniCallbackValue(lowerer, node, blame, "createSecureServer"); }; const lowerOption = (prop: ts.ObjectLiteralElementLike): void => { if (ts.isSpreadAssignment(prop)) { diff --git a/packages/compiler/src/ir/builtin-signatures.ts b/packages/compiler/src/ir/builtin-signatures.ts index 9f383c695..da045a6f5 100644 --- a/packages/compiler/src/ir/builtin-signatures.ts +++ b/packages/compiler/src/ir/builtin-signatures.ts @@ -2110,6 +2110,11 @@ export const LIB_FN_SIGS = defineLibFnSignatures({ * surface — the handles are the same kinds. */ "tls.createServer": { argTypes: [null, null], result: NETSERVER_T }, "tls.createServerCb": { argTypes: [null, null, null], result: NETSERVER_T }, + /** The SNICallback forms: (cert, key, SNICallback[, handler]). The + * callback is http2.createSecureServerSni's shape, a function or a + * function | undefined union. */ + "tls.createServerSni": { argTypes: [null, null, null], result: NETSERVER_T }, + "tls.createServerSniCb": { argTypes: [null, null, null, null], result: NETSERVER_T }, // The runtime options records (divergence 66's stance): a dyn options // value whose members read at runtime; pemDyn extracts a runtime-valued // cert/key member (arg 1 is the precomposed fence label). diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6933-tls-sni-callback.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6933-tls-sni-callback.ts.json new file mode 100644 index 000000000..4cf80592f --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6933-tls-sni-callback.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6933-tls-sni-callback.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6934-tls-handshake-disconnect.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6934-tls-handshake-disconnect.ts.json new file mode 100644 index 000000000..ae362a423 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6934-tls-handshake-disconnect.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6934-tls-handshake-disconnect.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/tls-sni-callback-fences.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/tls-sni-callback-fences.ts.json new file mode 100644 index 000000000..ee2ea72f0 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/tls-sni-callback-fences.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/diagnostics/tls-sni-callback-fences.ts" + ], + "diags": [] +} diff --git a/packages/runtime/src/scr_error.c b/packages/runtime/src/scr_error.c index 94523fc43..b974474bf 100644 --- a/packages/runtime/src/scr_error.c +++ b/packages/runtime/src/scr_error.c @@ -409,13 +409,15 @@ SCR_TL ScrError *scr_event_error_payload = NULL; /* The code Node attaches to the runtime's event-error messages whose text * carries no errno name: connResetException's "socket hang up" and - * "aborted", the HTTP parser's header overflow, and the TLS verification - * failures (the openssl reason Node reports as the code). NULL for any + * "aborted", the TLS client's disconnect before the handshake completes, + * the HTTP parser's header overflow, and the TLS verification failures (the openssl reason Node reports as the code). NULL for any * other message. */ const char *scr_error_message_code(const ScrStr *msg) { static const struct { const char *prefix; bool whole; const char *code; } known[] = { { "socket hang up", true, "ECONNRESET" }, { "aborted", true, "ECONNRESET" }, + { "Client network socket disconnected before secure TLS connection was established", true, + "ECONNRESET" }, { "Parse Error: Header overflow", true, "HPE_HEADER_OVERFLOW" }, { "certificate has expired", true, "CERT_HAS_EXPIRED" }, { "self-signed certificate in certificate chain", true, "SELF_SIGNED_CERT_IN_CHAIN" }, diff --git a/packages/runtime/src/scr_tls.c b/packages/runtime/src/scr_tls.c index 526dd7760..b4d125cb2 100644 --- a/packages/runtime/src/scr_tls.c +++ b/packages/runtime/src/scr_tls.c @@ -1057,10 +1057,10 @@ static int scr_tls_handshake(void *tctx) { if (r == MBEDTLS_ERR_X509_CERT_VERIFY_FAILED) { scr_tls_verify_msg(t, msg, sizeof msg); } else if (r == MBEDTLS_ERR_SSL_CONN_EOF || r == MBEDTLS_ERR_NET_CONN_RESET) { - /* the peer died mid-handshake: the http layer's own premature-close - * shape ('socket hang up') is Node's message — tear down without an error - * at this level and let the closed hook classify it */ - scr_net_sock_transport_error(t->sock, NULL); + /* The peer closed or reset the connection mid-handshake: Node's + * ECONNRESET for tls.connect and https.request alike. */ + scr_net_sock_transport_error( + t->sock, "Client network socket disconnected before secure TLS connection was established"); return -1; } else if (r == MBEDTLS_ERR_SSL_FATAL_ALERT_MESSAGE && t->ssl.MBEDTLS_PRIVATE(in_msg) != NULL && @@ -1170,8 +1170,15 @@ static ScrNetServer *scr_tls_create_server_alpn(const char *cert, size_t cert_le const char *key, size_t key_len, ScrClosure *handler /*moves, nullable*/, ScrNetConnFn fn, char **alpn /*moves, nullable*/, - int min_version, int max_version) { + int min_version, int max_version, + ScrClosure *sni_cb /*moves, nullable*/, + void *sni_answer_fn) { ScrTlsSrv *srv = scr_tls_srv_new(cert, cert_len, key, key_len, false); + if (sni_cb != NULL) { + srv->sni_cb = sni_cb; /* moves; released with srv */ + srv->sni_answer_fn = sni_answer_fn; + mbedtls_ssl_conf_sni(&srv->conf, &scr_tls_f_sni, NULL); + } srv->max_tls12 = max_version == 12; if (!scr_tls_conf_versions(&srv->conf, min_version, max_version)) { /* Node's server then fails every handshake; nothing here models @@ -1198,7 +1205,21 @@ static ScrNetServer *scr_tls_create_server_alpn(const char *cert, size_t cert_le ScrNetServer *scr_tls_create_server(const char *cert, size_t cert_len, const char *key, size_t key_len, ScrClosure *handler /*moves, nullable*/, ScrNetConnFn fn) { - return scr_tls_create_server_alpn(cert, cert_len, key, key_len, handler, fn, NULL, 12, 13); + return scr_tls_create_server_alpn(cert, cert_len, key, key_len, handler, fn, NULL, 12, 13, NULL, + NULL); +} + +/* tls.createServer({ cert, key, SNICallback }): the pre-parse machinery + * (design note atop the SNI section) hands each ClientHello's servername + * to the callback and serves the context it answers, or the default pair + * for none. A NULL sni_cb (the callback union's undefined arm) is + * scr_tls_create_server. */ +ScrNetServer *scr_tls_create_server_sni(const char *cert, size_t cert_len, const char *key, + size_t key_len, ScrClosure *handler /*moves, nullable*/, + ScrNetConnFn fn, ScrClosure *sni_cb /*moves, nullable*/, + void *sni_answer_fn) { + return scr_tls_create_server_alpn(cert, cert_len, key, key_len, handler, fn, NULL, 12, 13, sni_cb, + sni_answer_fn); } ScrNetServer *scr_https_create_server(const char *cert, size_t cert_len, const char *key, @@ -2024,7 +2045,7 @@ ScrNetServer *scr_tls_create_server_dyn(const ScrDyn *opts /*borrowed*/, } ScrNetServer *s = scr_tls_create_server_alpn((const char *)cert->data, cert->len, (const char *)key->data, key->len, handler, fn, - alpn, min_version, max_version); + alpn, min_version, max_version, NULL, NULL); scr_bytes_release(cert); scr_bytes_release(key); return s; diff --git a/packages/runtime/src/scr_tls.h b/packages/runtime/src/scr_tls.h index 0b1714eb3..fcb221b42 100644 --- a/packages/runtime/src/scr_tls.h +++ b/packages/runtime/src/scr_tls.h @@ -15,6 +15,9 @@ * (port 443 default, `ca` / rejectUnauthorized for local-CA flows — * cert/key/ca all arrive as borrowed PEM bytes). */ ScrNetServer *scr_tls_create_server(const char *cert, size_t cert_len, const char *key, size_t key_len, ScrClosure *handler /*moves, nullable*/, ScrNetConnFn fn); /* +1 */ +/* The SNICallback form: sni_cb (moves, nullable) receives (servername, answer), and + * sni_answer_fn is the emitted thunk that hands the answer to scr_tls_sni_answer. */ +ScrNetServer *scr_tls_create_server_sni(const char *cert, size_t cert_len, const char *key, size_t key_len, ScrClosure *handler /*moves, nullable*/, ScrNetConnFn fn, ScrClosure *sni_cb /*moves, nullable*/, void *sni_answer_fn); /* +1 */ /* ── RUNTIME tls/https options records (the divergence-66 stance: a * non-literal options value reads its members at runtime; members whose diff --git a/tests/corpus/6933-tls-sni-callback.ts b/tests/corpus/6933-tls-sni-callback.ts new file mode 100644 index 000000000..7958882d5 --- /dev/null +++ b/tests/corpus/6933-tls-sni-callback.ts @@ -0,0 +1,65 @@ +// tls.createServer with an SNICallback: the callback receives each +// ClientHello's servername and answers a SecureContext synchronously or +// later, or none, so the default cert/key pair serves; an error answer +// fails that handshake. Clients that trust the CA see the certificate the +// answer chose: alt.localhost verifies only with the alt context, and a +// name served the default localhost pair fails Node's hostname check. +import { readFileSync } from "node:fs"; +import * as net from "node:net"; +import * as tls from "node:tls"; + +const certs = "tests/fixtures/server/certs"; +const ca = readFileSync(`${certs}/ca.pem`); +const cert = readFileSync(`${certs}/localhost.pem`); +const key = readFileSync(`${certs}/localhost-key.pem`); +const altContext = tls.createSecureContext({ + cert: readFileSync(`${certs}/alt.pem`), + key: readFileSync(`${certs}/alt-key.pem`), +}); + +const server = tls.createServer( + { + cert, + key, + SNICallback: (servername: string, cb: (err: Error | null, ctx?: tls.SecureContext) => void) => { + console.log("sni", servername); + if (servername === "alt.localhost") setTimeout(() => cb(null, altContext), 5); + else if (servername === "fail.localhost") cb(new Error(`no certificate for ${servername}`)); + else cb(null, undefined); + }, + }, + (socket) => { + socket.end("hello"); + }, +); + +function attempt(port: number, servername: string, next: () => void): void { + let done = false; + const finish = (line: string): void => { + if (done) return; + done = true; + console.log(servername, line); + next(); + }; + const socket = tls.connect({ port, host: "127.0.0.1", servername, ca }, () => { + let text = ""; + socket.on("data", (data: Buffer) => { + text += data.toString(); + }); + socket.on("end", () => finish(`authorized ${socket.authorized} ${text}`)); + }); + socket.on("error", (error: Error) => { + finish(`error ${(error as NodeJS.ErrnoException).code}`); + }); +} + +server.listen(0, "127.0.0.1", () => { + const port = (server.address() as net.AddressInfo).port; + attempt(port, "localhost", () => + attempt(port, "alt.localhost", () => + attempt(port, "other.localhost", () => + attempt(port, "fail.localhost", () => server.close(() => console.log("closed"))), + ), + ), + ); +}); diff --git a/tests/corpus/6934-tls-handshake-disconnect.ts b/tests/corpus/6934-tls-handshake-disconnect.ts new file mode 100644 index 000000000..1f13c6684 --- /dev/null +++ b/tests/corpus/6934-tls-handshake-disconnect.ts @@ -0,0 +1,27 @@ +// A server that closes the connection before the TLS handshake completes: +// tls.connect and https.get fail with ECONNRESET and Node's "Client network +// socket disconnected before secure TLS connection was established". +import * as https from "node:https"; +import * as net from "node:net"; +import * as tls from "node:tls"; + +const server = net.createServer((socket) => { + socket.end(); +}); + +server.listen(0, "127.0.0.1", () => { + const port = (server.address() as net.AddressInfo).port; + const client = tls.connect({ port, host: "127.0.0.1", rejectUnauthorized: false }, () => { + console.log("tls connected"); + }); + client.on("error", (error: Error) => { + console.log("tls", (error as NodeJS.ErrnoException).code, error.message); + const request = https.get({ host: "127.0.0.1", port, rejectUnauthorized: false }, () => { + console.log("https response"); + }); + request.on("error", (err: Error) => { + console.log("https", (err as NodeJS.ErrnoException).code, err.message); + server.close(); + }); + }); +}); diff --git a/tests/diagnostics/tls-sni-callback-fences.ts b/tests/diagnostics/tls-sni-callback-fences.ts new file mode 100644 index 000000000..0c745365c --- /dev/null +++ b/tests/diagnostics/tls-sni-callback-fences.ts @@ -0,0 +1,15 @@ +// tls.createServer SNICallback forms that have no lowering. +import { readFileSync } from "node:fs"; +import * as tls from "node:tls"; + +const cert = readFileSync("cert.pem"); +const key = readFileSync("key.pem"); +type Answer = (err: Error | null, ctx?: tls.SecureContext) => void; +function makeSni(): (servername: string, cb: Answer) => void { + return (_servername, cb) => cb(null, undefined); +} + +// The callback expression has side effects. +tls.createServer({ cert, key, SNICallback: makeSni() }, (socket) => socket.end()); +// SNICallback with options the runtime options walk reads. +tls.createServer({ cert, key, ALPNProtocols: ["h2"], SNICallback: makeSni }); diff --git a/tests/harness/__snapshots__/tls-sni-callback-fences.ts.txt b/tests/harness/__snapshots__/tls-sni-callback-fences.ts.txt new file mode 100644 index 000000000..920e65532 --- /dev/null +++ b/tests/harness/__snapshots__/tls-sni-callback-fences.ts.txt @@ -0,0 +1,17 @@ +tls-sni-callback-fences.ts:13:31 - error SC2020: 'a tls.createServer SNICallback expression with side effects' is part of the standard library types but has no scriptc lowering yet + + 12 | // The callback expression has side effects. + 13 | tls.createServer({ cert, key, SNICallback: makeSni() }, (socket) => socket.end()); + | ^~~~~~~~~~~~~~~~~~~~~~ + 14 | // SNICallback with options the runtime options walk reads. + + hint: pass a function or a variable that holds one + +tls-sni-callback-fences.ts:15:54 - error SC2020: 'tls.createServer with SNICallback and options the runtime walk reads' is part of the standard library types but has no scriptc lowering yet + + 14 | // SNICallback with options the runtime options walk reads. + 15 | tls.createServer({ cert, key, ALPNProtocols: ["h2"], SNICallback: makeSni }); + | ^~~~~~~~~~~~~~~~~~~~ + 16 | + + hint: SNICallback lowers with cert and key; ALPNProtocols, minVersion, maxVersion and the validated options are not combined with it yet \ No newline at end of file From ca446b2c30b3ecf5347d64e241a766af9989d52c Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Wed, 21 Oct 2026 21:56:08 -0500 Subject: [PATCH 2/2] Read the tls.createServer SNICallback in source order The runtime call reads the cert and key options before the SNICallback, so a cert or key initializer after the callback that reassigned the callback's binding changed the callback the server used, and the server could serve the wrong certificate. When SNICallback comes before a cert or key entry, the callback is now saved just before the next of them is evaluated. With the callback read in order, a callback expression with side effects lowers instead of being refused. --- .../src/frontend/lowering/lower-server.ts | 27 +++++++++------ tests/corpus/6933-tls-sni-callback.ts | 34 ++++++++++++++++++- tests/diagnostics/tls-sni-callback-fences.ts | 2 -- .../tls-sni-callback-fences.ts.txt | 17 +++------- 4 files changed, 54 insertions(+), 26 deletions(-) diff --git a/packages/compiler/src/frontend/lowering/lower-server.ts b/packages/compiler/src/frontend/lowering/lower-server.ts index c27b2513e..058d7874a 100644 --- a/packages/compiler/src/frontend/lowering/lower-server.ts +++ b/packages/compiler/src/frontend/lowering/lower-server.ts @@ -4762,6 +4762,7 @@ function lowerTlsServerOptions( let cert: IrExpr | null = null; let key: IrExpr | null = null; let sni: IrExpr | undefined; + let pendingSni: IrStmt | null = null; for (const prop of node.properties.nodes) { let initializer: ts.Expression | null; if ( @@ -4780,20 +4781,22 @@ function lowerTlsServerOptions( } const k = (prop.name as ts.Identifier | ts.StringLiteral).text; if (k === "SNICallback" && allowSni) { - // The callback is a value read (a function or a binding), so it may - // be read after cert and key without changing what the program sees. - if (initializer !== null && !sideEffectFreeOptionValue(initializer)) { - lowerer.noLowering( - `a ${what} SNICallback expression with side effects`, - prop, - "pass a function or a variable that holds one", - ); - } const value = initializer !== null ? lowerer.lowerExpr(initializer) : lowerer.lowerShorthandValue(prop as ts.ShorthandPropertyAssignment); - sni = checkSniCallbackValue(lowerer, value, prop, what); + const checked = checkSniCallbackValue(lowerer, value, prop, what); + if (cert !== null && key !== null) { + sni = checked; + } else { + // The runtime call reads cert and key first. A cert or key entry + // after the callback can change the binding the callback reads, + // so the callback is saved ahead of the next of them, in source + // order. + const saved = lowerer.declareHiddenLocal("%sniCallback", checked.type); + pendingSni = { kind: "varDecl", localId: saved.id, init: checked, loc: locOf(prop) }; + sni = varRef(saved.id, checked.type, locOf(prop)); + } continue; } if (k !== "cert" && k !== "key") { @@ -4857,6 +4860,10 @@ function lowerTlsServerOptions( "cert/key are PEM strings or Buffers here", ); } + if (pendingSni !== null) { + v = { kind: "seqExpr", stmts: [pendingSni], result: v, type: v.type, loc }; + pendingSni = null; + } if (k === "cert") cert = v; else key = v; } diff --git a/tests/corpus/6933-tls-sni-callback.ts b/tests/corpus/6933-tls-sni-callback.ts index 7958882d5..8c9442806 100644 --- a/tests/corpus/6933-tls-sni-callback.ts +++ b/tests/corpus/6933-tls-sni-callback.ts @@ -33,6 +33,30 @@ const server = tls.createServer( }, ); +// The callback is read where it appears, effects included: a later cert +// initializer that reassigns its binding does not change the callback the +// server uses. +type Sni = (servername: string, cb: (err: Error | null, ctx?: tls.SecureContext) => void) => void; +let chosen: Sni = (servername, cb) => { + console.log("first callback", servername); + cb(null, altContext); +}; +const ordered = tls.createServer( + { + SNICallback: (console.log("read callback"), chosen), + cert: (console.log("read cert"), + (chosen = (servername, cb) => { + console.log("second callback", servername); + cb(null, undefined); + }), + cert), + key, + }, + (socket) => { + socket.end("ordered"); + }, +); + function attempt(port: number, servername: string, next: () => void): void { let done = false; const finish = (line: string): void => { @@ -58,7 +82,15 @@ server.listen(0, "127.0.0.1", () => { attempt(port, "localhost", () => attempt(port, "alt.localhost", () => attempt(port, "other.localhost", () => - attempt(port, "fail.localhost", () => server.close(() => console.log("closed"))), + attempt(port, "fail.localhost", () => + server.close(() => { + console.log("closed"); + ordered.listen(0, "127.0.0.1", () => { + const orderedPort = (ordered.address() as net.AddressInfo).port; + attempt(orderedPort, "alt.localhost", () => ordered.close(() => console.log("ordered closed"))); + }); + }), + ), ), ), ); diff --git a/tests/diagnostics/tls-sni-callback-fences.ts b/tests/diagnostics/tls-sni-callback-fences.ts index 0c745365c..20b6ce271 100644 --- a/tests/diagnostics/tls-sni-callback-fences.ts +++ b/tests/diagnostics/tls-sni-callback-fences.ts @@ -9,7 +9,5 @@ function makeSni(): (servername: string, cb: Answer) => void { return (_servername, cb) => cb(null, undefined); } -// The callback expression has side effects. -tls.createServer({ cert, key, SNICallback: makeSni() }, (socket) => socket.end()); // SNICallback with options the runtime options walk reads. tls.createServer({ cert, key, ALPNProtocols: ["h2"], SNICallback: makeSni }); diff --git a/tests/harness/__snapshots__/tls-sni-callback-fences.ts.txt b/tests/harness/__snapshots__/tls-sni-callback-fences.ts.txt index 920e65532..c45608bb9 100644 --- a/tests/harness/__snapshots__/tls-sni-callback-fences.ts.txt +++ b/tests/harness/__snapshots__/tls-sni-callback-fences.ts.txt @@ -1,17 +1,8 @@ -tls-sni-callback-fences.ts:13:31 - error SC2020: 'a tls.createServer SNICallback expression with side effects' is part of the standard library types but has no scriptc lowering yet +tls-sni-callback-fences.ts:13:54 - error SC2020: 'tls.createServer with SNICallback and options the runtime walk reads' is part of the standard library types but has no scriptc lowering yet - 12 | // The callback expression has side effects. - 13 | tls.createServer({ cert, key, SNICallback: makeSni() }, (socket) => socket.end()); - | ^~~~~~~~~~~~~~~~~~~~~~ - 14 | // SNICallback with options the runtime options walk reads. - - hint: pass a function or a variable that holds one - -tls-sni-callback-fences.ts:15:54 - error SC2020: 'tls.createServer with SNICallback and options the runtime walk reads' is part of the standard library types but has no scriptc lowering yet - - 14 | // SNICallback with options the runtime options walk reads. - 15 | tls.createServer({ cert, key, ALPNProtocols: ["h2"], SNICallback: makeSni }); + 12 | // SNICallback with options the runtime options walk reads. + 13 | tls.createServer({ cert, key, ALPNProtocols: ["h2"], SNICallback: makeSni }); | ^~~~~~~~~~~~~~~~~~~~ - 16 | + 14 | hint: SNICallback lowers with cert and key; ALPNProtocols, minVersion, maxVersion and the validated options are not combined with it yet \ No newline at end of file