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); + }); + }, +);