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.
This commit is contained in:
Chris Tate
2026-10-22 05:58:32 -05:00
parent 1360830149
commit 3cefd421a1
8 changed files with 220 additions and 2 deletions
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/7094-tls-socket-failed-handshakes.ts"
],
"diags": []
}
+1
View File
@@ -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;
+3
View File
@@ -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
+47 -2
View File
@@ -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 */
}
@@ -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);
@@ -0,0 +1 @@
{ "plain": 2618, "san": 3905 }
+45
View File
@@ -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).
+68
View File
@@ -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);
});
},
);