From 3cefd421a132f1d1502e45405bf7e11981fb4286 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 22 Oct 2026 05:58:32 -0500 Subject: [PATCH] Fence requestCert beside a secureContext and free failed TLSSockets new tls.TLSSocket(socket, { isServer: true, secureContext, requestCert: true }) skipped the options walk that refuses requestCert, so the server asked for no client certificate and accepted every client, even with rejectUnauthorized: true. With a secureContext, Node still reads the socket's own options, so a truthy requestCert, SNICallback and ALPNCallback now throw the fence there too. A TLS socket's transport kept its 'secure' and 'session' listeners until the socket was freed. A handshake that fails never fires 'secure', so a listener that captures the socket kept the socket and its TLS state alive after it closed. The transport now drops those listeners when the socket settles, and ignores listeners added after that. --- .../7094-tls-socket-failed-handshakes.ts.json | 6 ++ packages/runtime/src/scr_net.c | 1 + packages/runtime/src/scr_net.h | 3 + packages/runtime/src/scr_tls.c | 49 ++++++++++++- .../7094-tls-socket-failed-handshakes.ts | 49 +++++++++++++ .../7094-tls-socket-failed-handshakes.ts.json | 1 + tests/harness/errors.test.ts | 45 ++++++++++++ tests/harness/tls-socket-leaks.test.ts | 68 +++++++++++++++++++ 8 files changed, 220 insertions(+), 2 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7094-tls-socket-failed-handshakes.ts.json create mode 100644 tests/corpus/7094-tls-socket-failed-handshakes.ts create mode 100644 tests/harness/corpus-costs/7094-tls-socket-failed-handshakes.ts.json create mode 100644 tests/harness/tls-socket-leaks.test.ts diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7094-tls-socket-failed-handshakes.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7094-tls-socket-failed-handshakes.ts.json new file mode 100644 index 000000000..8fa524155 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7094-tls-socket-failed-handshakes.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/7094-tls-socket-failed-handshakes.ts" + ], + "diags": [] +} diff --git a/packages/runtime/src/scr_net.c b/packages/runtime/src/scr_net.c index 8f9a68424..29a4af393 100644 --- a/packages/runtime/src/scr_net.c +++ b/packages/runtime/src/scr_net.c @@ -5354,6 +5354,7 @@ static bool scr_net_sock_settle(ScrNetSocket *sock) { scr_net_ls_drop(&sock->wcb_ls); scr_net_ls_drop(&sock->ready_ls); scr_net_ls_drop(&sock->lookup_ls); + if (sock->tops && sock->tops->settle) sock->tops->settle(sock->tctx); if (sock->pipe_dst) { scr_net_sock_release(sock->pipe_dst); sock->pipe_dst = NULL; diff --git a/packages/runtime/src/scr_net.h b/packages/runtime/src/scr_net.h index 8f26bbf4d..ffa9b2f72 100644 --- a/packages/runtime/src/scr_net.h +++ b/packages/runtime/src/scr_net.h @@ -354,6 +354,9 @@ typedef struct ScrNetTransportOps { /* nullable: runs after the established socket's 'connect' listeners * (the TLS 'secure' event, which Node emits after secureConnect) */ void (*after_established)(void *tctx); + /* nullable: runs when the closed socket settles, after its own + * listeners drop; the transport drops the listeners it holds */ + void (*settle)(void *tctx); } ScrNetTransportOps; void scr_net_sock_set_transport(ScrNetSocket *s, const ScrNetTransportOps *ops, void *tctx); /* new tls.TLSSocket(socket) and tls.connect({ socket }): a fresh socket diff --git a/packages/runtime/src/scr_tls.c b/packages/runtime/src/scr_tls.c index 6fa1f169d..e7436ab69 100644 --- a/packages/runtime/src/scr_tls.c +++ b/packages/runtime/src/scr_tls.c @@ -664,6 +664,7 @@ typedef struct ScrTlsSock { size_t head_len; /* the 'secure' event's listeners */ ScrNetLs secure_ls; + bool settled; /* the socket closed: listeners added later never fire */ } ScrTlsSock; /* checkServerIdentity for an IP literal: the address must be one of the @@ -1237,6 +1238,17 @@ static void scr_tls_after_established(void *tctx) { scr_net_ls_drop(&t->secure_ls); } +/* The closed socket settles: its 'secure' and 'session' listeners can no + * longer fire, and a listener that captures the socket would otherwise + * keep it, and this transport, alive (a handshake that fails never fires + * 'secure'). */ +static void scr_tls_settle(void *tctx) { + ScrTlsSock *t = (ScrTlsSock *)tctx; + t->settled = true; + scr_net_ls_drop(&t->secure_ls); + scr_net_ls_drop(&t->session_ls); +} + static const ScrNetTransportOps SCR_TLS_OPS = { .xread = scr_tls_xread, .xwrite = scr_tls_xwrite, @@ -1245,6 +1257,7 @@ static const ScrNetTransportOps SCR_TLS_OPS = { .pending = scr_tls_pending, .shutdown_write = scr_tls_shutdown_write, .free = scr_tls_free, + .settle = scr_tls_settle, .after_established = scr_tls_after_established, }; @@ -2202,6 +2215,29 @@ static const char *const SCR_TLS_WRAP_FENCED_OPTIONS[] = { "server", "session", "requestOCSP", "pskCallback", "enableTrace", NULL, }; +/* The socket-level options of a wrap that serves a secureContext, which + * the server-options walk would otherwise check: requestCert (client + * certificates are not modeled), SNICallback and ALPNCallback fence. + * False with the exception pending. */ +static bool scr_tls_wrap_socket_opts_check(const ScrDyn *record, const char *api) { + for (size_t i = 0; record != NULL && i < record->v.obj.len; i++) { + const ScrDynEntry *e = &record->v.obj.entries[i]; + if (e->value->kind == SCR_DYN_UNDEF) continue; + if (strcmp(e->key, "requestCert") == 0) { + if (!scr_tls_dyn_truthy(e->value)) continue; + scr_tls_opt_fence(api, "requestCert", + "client-certificate handshakes are not modeled (SEMANTICS.md divergence 55) — requestCert stays at Node's false default"); + return false; + } + if (strcmp(e->key, "SNICallback") == 0 || strcmp(e->key, "ALPNCallback") == 0) { + scr_tls_opt_fence(api, e->key, + "isServer, cert, key, secureContext, ALPNProtocols, minVersion and maxVersion are the implemented members"); + return false; + } + } + return true; +} + ScrNetSocket *scr_tls_wrap_socket(ScrNetSocket *raw, const ScrDyn *opts /*borrowed*/, ScrSecureCtx *ctx /*borrowed, nullable*/) { static const char api[] = "new tls.TLSSocket"; @@ -2233,12 +2269,17 @@ ScrNetSocket *scr_tls_wrap_socket(ScrNetSocket *raw, const ScrDyn *opts /*borrow return NULL; } /* Node serves a secureContext as it is: the record's cert, key and - * other context options are not read then */ + * other context options are not read then. The socket's own options + * still apply, so the ones without a lowering fence either way: + * requestCert asks for a client certificate this runtime cannot + * verify. */ int min_version = 12, max_version = 13; ScrBytes *cert = NULL, *key = NULL; if (ctx == NULL) { if (!scr_tls_versions_dyn(record, &min_version, &max_version)) return NULL; if (!scr_tls_srv_opts_walk_ex(record, api, &cert, &key, true)) return NULL; + } else if (!scr_tls_wrap_socket_opts_check(record, api)) { + return NULL; } char **alpn = NULL; const ScrDyn *protocols = record != NULL ? scr_dyn_obj_get(record, "ALPNProtocols", 13) : NULL; @@ -2867,7 +2908,7 @@ void scr_tls_sock_on_secure_connect(ScrNetSocket *s, ScrClosure *cb /*moves*/, b * handshake comes too late, as in Node. */ void scr_tls_sock_on_secure(ScrNetSocket *s, ScrClosure *cb /*moves*/, bool once) { ScrTlsSock *t = scr_tls_sock_of(s); - if (t == NULL || t->established) { + if (t == NULL || t->established || t->settled) { scr_closure_release(cb); return; } @@ -2880,6 +2921,10 @@ void scr_tls_sock_on_session(ScrNetSocket *s, ScrClosure *cb /*moves*/, void *fn scr_closure_release(cb); /* a plain socket never fires it, like Node */ return; } + if (t->settled) { + scr_closure_release(cb); + return; + } scr_net_ls_add(&t->session_ls, cb, fn, once); if (t->established) scr_tls_fire_session(t); /* the ticket already arrived */ } diff --git a/tests/corpus/7094-tls-socket-failed-handshakes.ts b/tests/corpus/7094-tls-socket-failed-handshakes.ts new file mode 100644 index 000000000..daad49a4e --- /dev/null +++ b/tests/corpus/7094-tls-socket-failed-handshakes.ts @@ -0,0 +1,49 @@ +// A TLSSocket whose handshake never completes still closes and releases +// its listeners, including a 'secure' listener that holds the socket: one +// peer closes before sending anything and the next sends a plain HTTP +// request. +import { readFileSync } from "node:fs"; +import * as net from "node:net"; +import * as tls from "node:tls"; + +const certs = "tests/fixtures/server/certs"; +const cert = readFileSync(`${certs}/localhost.pem`); +const key = readFileSync(`${certs}/localhost-key.pem`); +const context = tls.createSecureContext({ cert, key }); + +const peers: ((port: number) => void)[] = [ + (port) => { + const quiet = net.connect(port, "127.0.0.1", () => quiet.end()); + }, + (port) => { + const plain = net.connect(port, "127.0.0.1", () => plain.write("GET / HTTP/1.1\r\nHost: x\r\n\r\n")); + plain.on("error", () => {}); + plain.resume(); + }, + (port) => { + const quiet = net.connect(port, "127.0.0.1", () => quiet.destroy()); + }, +]; +let served = 0; +const server = net.createServer((raw) => { + served++; + const secure = + served === 3 + ? new tls.TLSSocket(raw, { isServer: true, secureContext: context }) + : new tls.TLSSocket(raw, { isServer: true, cert, key }); + secure.on("secure", () => console.log("secure", served, secure.alpnProtocol)); + secure.on("error", (err: NodeJS.ErrnoException) => console.log("error", served, err.code)); + secure.on("close", (hadError: boolean) => { + console.log("close", served, hadError); + next(); + }); +}); +function next(): void { + const peer = peers.shift(); + if (peer === undefined) { + server.close(() => console.log("server closed")); + return; + } + peer((server.address() as net.AddressInfo).port); +} +server.listen(0, "127.0.0.1", next); diff --git a/tests/harness/corpus-costs/7094-tls-socket-failed-handshakes.ts.json b/tests/harness/corpus-costs/7094-tls-socket-failed-handshakes.ts.json new file mode 100644 index 000000000..ec5193be7 --- /dev/null +++ b/tests/harness/corpus-costs/7094-tls-socket-failed-handshakes.ts.json @@ -0,0 +1 @@ +{ "plain": 2618, "san": 3905 } diff --git a/tests/harness/errors.test.ts b/tests/harness/errors.test.ts index 96949201d..31f2b6fd9 100644 --- a/tests/harness/errors.test.ts +++ b/tests/harness/errors.test.ts @@ -905,6 +905,51 @@ listener.listen(0, () => { }); }); +// With a secureContext, the TLSSocket's own options still apply: a truthy +// requestCert, which asks for client certificates this runtime does not +// verify, and SNICallback throw the fence instead of being dropped. +describe("tls.TLSSocket socket options beside a secureContext", () => { + test("requestCert and SNICallback throw the fence", async () => { + const r = await compileAndRun( + "tls-socket-context-fences", + `import { readFileSync } from "node:fs"; +import * as net from "node:net"; +import * as tls from "node:tls"; +const certs = "tests/fixtures/server/certs"; +const cert = readFileSync(\`\${certs}/localhost.pem\`); +const key = readFileSync(\`\${certs}/localhost-key.pem\`); +const context = tls.createSecureContext({ cert, key }); +function report(label: string, run: () => void): void { + try { + run(); + console.log(label, "no error"); + } catch (error) { + console.log(label, (error as Error).message); + } +} +const listener = net.createServer((raw) => { + report("requestCert", () => new tls.TLSSocket(raw, { isServer: true, secureContext: context, requestCert: true, rejectUnauthorized: true })); + report("SNICallback", () => new tls.TLSSocket(raw, { isServer: true, secureContext: context, SNICallback: (name: string, cb: (err: Error | null, ctx?: tls.SecureContext) => void) => cb(null, context) })); + report("requestCert false", () => new tls.TLSSocket(raw, { isServer: true, secureContext: context, requestCert: false, rejectUnauthorized: true }).destroy()); + listener.close(); +}); +listener.listen(0, () => { + const c = net.connect((listener.address() as net.AddressInfo).port, "localhost"); + c.on("error", () => {}); + c.on("close", () => console.log("client closed")); +}); +`, + ); + expect(r.stderr).toBe(""); + expect(r.stdout).toBe( + "requestCert new tls.TLSSocket option 'requestCert' has no static lowering — client-certificate handshakes are not modeled (SEMANTICS.md divergence 55) — requestCert stays at Node's false default\n" + + "SNICallback new tls.TLSSocket option 'SNICallback' has no static lowering — isServer, cert, key, secureContext, ALPNProtocols, minVersion and maxVersion are the implemented members\n" + + "requestCert false no error\n" + + "client closed\n", + ); + }); +}); + // A curve Node lists in crypto.getCurves() but the runtime does not // implement throws a coded SC2020 error naming the curve when the name is // computed; literal names are refused at compile time (node-types-fenced). diff --git a/tests/harness/tls-socket-leaks.test.ts b/tests/harness/tls-socket-leaks.test.ts new file mode 100644 index 000000000..64edfac25 --- /dev/null +++ b/tests/harness/tls-socket-leaks.test.ts @@ -0,0 +1,68 @@ +/* new tls.TLSSocket under LeakSanitizer. A TLS socket's transport holds + * its 'secure' and 'session' listeners, which often capture the socket; + * every socket that closes, including one whose handshake fails, must drop + * them. The sanitized lanes otherwise run with detect_leaks=0, so this runs + * the TLSSocket programs with LeakSanitizer explicitly on. 7094's + * handshakes all fail. Linux only (Apple's ASan has no LeakSanitizer); + * runs in the SCRIPTC_SAN=1 lane. */ +import { execFile } from "node:child_process"; +import { createHash } from "node:crypto"; +import { mkdirSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { promisify } from "node:util"; +import { describe, expect, test } from "vitest"; +import { compile } from "@scriptc/compiler"; + +const execFileAsync = promisify(execFile); +const repoRoot = join(import.meta.dirname, "../.."); +const cacheDir = join(repoRoot, "node_modules/.cache/scriptc-tests"); +const sanitize = process.env["SCRIPTC_SAN"] === "1"; + +const programs = [ + "tests/corpus/7087-tls-socket-wrap.ts", + "tests/corpus/7088-tls-socket-connect-tunnel.ts", + "tests/corpus/7094-tls-socket-failed-handshakes.ts", +]; + +describe.skipIf(!sanitize || process.platform !== "linux")( + "tls.TLSSocket under LeakSanitizer", + () => { + test.each(programs)("%s reports no leaks", { timeout: 300_000 }, async (program) => { + const file = join(repoRoot, program); + const key = createHash("sha256") + .update(file) + .update(readFileSync(file)) + .update("san") + .digest("hex") + .slice(0, 16); + const outDir = join(cacheDir, `tls-socket-leaks-${key}`); + mkdirSync(outDir, { recursive: true }); + const result = await compile(file, { + outPath: join(outDir, "program"), + outDir, + sanitize: true, + backend: "llvm", + }); + if (!result.ok) { + throw new Error( + "program failed to compile:\n" + + result.diagnostics.map((d) => `${d.code}: ${d.message}`).join("\n"), + ); + } + const run = await execFileAsync(result.binaryPath, [], { + env: { ...process.env, ASAN_OPTIONS: "detect_leaks=1" }, + maxBuffer: 16 * 1024 * 1024, + }).catch((error: { code?: number; stdout?: string; stderr?: string }) => error); + expect(run.stderr).toBe(""); + expect("code" in run ? run.code : 0).toBe(0); + const expected = await execFileAsync( + process.execPath, + ["--no-warnings", "--experimental-strip-types", file], + { + maxBuffer: 16 * 1024 * 1024, + }, + ); + expect(run.stdout).toBe(expected.stdout); + }); + }, +);