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..058d7874a 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,8 @@ function lowerTlsServerOptions( const loc = locOf(node); 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 ( @@ -4777,6 +4780,25 @@ function lowerTlsServerOptions( ); } const k = (prop.name as ts.Identifier | ts.StringLiteral).text; + if (k === "SNICallback" && allowSni) { + const value = + initializer !== null + ? lowerer.lowerExpr(initializer) + : lowerer.lowerShorthandValue(prop as ts.ShorthandPropertyAssignment); + 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") { const init = ts.isPropertyAssignment(prop) ? prop.initializer : null; if ( @@ -4801,7 +4823,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", }, @@ -4838,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; } @@ -4848,7 +4874,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 +4937,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 +5116,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 +5152,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 +5542,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 +5832,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 771b53152..fa7876579 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..8c9442806 --- /dev/null +++ b/tests/corpus/6933-tls-sni-callback.ts @@ -0,0 +1,97 @@ +// 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"); + }, +); + +// 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 => { + 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"); + 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/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..20b6ce271 --- /dev/null +++ b/tests/diagnostics/tls-sni-callback-fences.ts @@ -0,0 +1,13 @@ +// 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); +} + +// 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..c45608bb9 --- /dev/null +++ b/tests/harness/__snapshots__/tls-sni-callback-fences.ts.txt @@ -0,0 +1,8 @@ +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 | // SNICallback with options the runtime options walk reads. + 13 | tls.createServer({ cert, key, ALPNProtocols: ["h2"], SNICallback: makeSni }); + | ^~~~~~~~~~~~~~~~~~~~ + 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