/** * Fase 3 / Epic A — TLS-terminating capture for TPROXY (decrypt 2/N). * * The decrypt engine TLS-terminates an intercepted client socket with a per-SNI * leaf from the dynamic CA (#4173), feeds the plaintext to Node's HTTP parser, * captures the exchange (source "tproxy"), and forwards it re-encrypted. The * decrypt + capture path needs NO root/iptables/native addon, so it is proven * here with a real local TLS round-trip: a client that trusts the dynamic CA * connects, its request is decrypted + recorded, and the response from a real * local HTTPS upstream flows back. (The anti-loop forward — `realForward` via * `connectMarked` — is the only kernel-dependent piece, validated e2e on the VPS.) */ import test from "node:test"; import assert from "node:assert/strict"; import net from "node:net"; import tls from "node:tls"; import https from "node:https"; import { isTlsClientHello, resolveCaptureHost, buildForwardHeaders, createForward, createTlsCaptureServer, } from "../../src/mitm/tproxy/tlsCapture.ts"; import { DynamicCertStore, generateMitmCa } from "../../src/mitm/tproxy/dynamicCert.ts"; import { globalTrafficBuffer } from "../../src/mitm/inspector/buffer.ts"; test("isTlsClientHello matches only the TLS handshake content-type byte (0x16)", () => { assert.equal(isTlsClientHello(0x16), true); assert.equal(isTlsClientHello(0x47), false); // 'G' (start of an HTTP GET) assert.equal(isTlsClientHello(0x00), false); }); test("resolveCaptureHost prefers SNI, then Host header (port stripped), then dest IP", () => { assert.equal(resolveCaptureHost("api.example.com", "other:443", "1.2.3.4"), "api.example.com"); assert.equal(resolveCaptureHost(undefined, "host.example:8443", "1.2.3.4"), "host.example"); assert.equal(resolveCaptureHost("", " ", "1.2.3.4"), "1.2.3.4"); }); test("buildForwardHeaders drops hop-by-hop, keeps auth, and pins host", () => { const out = buildForwardHeaders( { host: "old:443", connection: "keep-alive", "transfer-encoding": "chunked", "content-length": "10", authorization: "Bearer keep-me", accept: "application/json", }, "api.example.com" ); assert.equal(out.host, "api.example.com"); assert.equal(out.authorization, "Bearer keep-me"); // upstream still authenticates assert.equal(out.accept, "application/json"); assert.equal(out.connection, undefined); assert.equal(out["transfer-encoding"], undefined); assert.equal(out["content-length"], undefined); }); async function startHttpsUpstream(): Promise<{ port: number; close: () => Promise }> { const up = await generateMitmCa("test upstream"); // any self-signed key+cert pair const server = https.createServer({ key: up.key, cert: up.cert }, (req, res) => { const body = `decrypted-roundtrip:${req.url ?? ""}`; res.writeHead(200, { "content-type": "text/plain", "content-length": Buffer.byteLength(body), connection: "close", }); res.end(body); }); await new Promise((r) => server.listen(0, "127.0.0.1", () => r())); const addr = server.address(); const port = typeof addr === "object" && addr ? addr.port : 0; return { port, close: () => new Promise((res) => { server.closeAllConnections?.(); server.close(() => res()); }), }; } function startEngineListener( certStore: DynamicCertStore, dest: { ip: string; port: number }, forward: ReturnType ): Promise<{ port: number; close: () => Promise }> { const engine = createTlsCaptureServer(certStore, { forward }); const listener = net.createServer((sock) => engine.terminate(sock, dest)); return new Promise((resolve) => { listener.listen(0, "127.0.0.1", () => { const addr = listener.address(); const port = typeof addr === "object" && addr ? addr.port : 0; resolve({ port, close: async () => { await new Promise((r) => listener.close(() => r())); await engine.close(); }, }); }); }); } function tlsRequest( port: number, servername: string, ca: string, rawRequest: string ): Promise { return new Promise((resolve, reject) => { const client = tls.connect({ host: "127.0.0.1", port, ca, servername }, () => { client.write(rawRequest); }); const chunks: Buffer[] = []; let settled = false; const settle = (callback: () => void) => { if (settled) return; settled = true; clearTimeout(timeout); client.destroy(); callback(); }; const resolveWithChunks = () => settle(() => resolve(Buffer.concat(chunks).toString("utf8"))); const timeout = setTimeout( () => settle(() => reject(new Error("TLS capture test request timed out"))), 5_000 ); client.on("data", (chunk) => { chunks.push(chunk); const body = Buffer.concat(chunks).toString("utf8"); if (/decrypted-roundtrip:|502 Bad Gateway/.test(body)) resolveWithChunks(); }); client.on("end", resolveWithChunks); client.once("error", (error) => settle(() => reject(error))); }); } test("decrypts an intercepted HTTPS request and captures it as source 'tproxy'", async () => { globalTrafficBuffer.clear(); const upstream = await startHttpsUpstream(); const certStore = new DynamicCertStore("OmniRoute MITM CA (test)"); const caPem = await certStore.getCaCertPem(); // forward = direct TLS to the test upstream (no SO_MARK — native addon not needed // here). The test upstream uses a self-signed cert, so opt out of verification // explicitly (production `realForward` keeps the secure-by-default `true`). const forward = createForward((ip, port) => net.connect(port, ip), { rejectUnauthorized: false }); const engine = await startEngineListener( certStore, { ip: "127.0.0.1", port: upstream.port }, forward ); try { const body = await tlsRequest( engine.port, "api.example.com", caPem, "GET /v1/models HTTP/1.1\r\n" + "Host: api.example.com\r\n" + "authorization: Bearer sk-supersecret-token-abcdef0123456789\r\n" + "Connection: close\r\n\r\n" ); assert.match(body, /decrypted-roundtrip:\/v1\/models/, "client gets the upstream response"); await new Promise((r) => setTimeout(r, 40)); // buffer.update runs on the async path const entry = globalTrafficBuffer.list().at(-1); assert.ok(entry, "an entry was captured"); assert.equal(entry.source, "tproxy"); assert.equal(entry.method, "GET"); assert.equal(entry.host, "api.example.com", "host comes from the SNI servername"); assert.equal(entry.path, "/v1/models"); assert.equal(entry.status, 200); assert.match(entry.responseBody ?? "", /decrypted-roundtrip/, "decrypted body is captured"); // the bearer token in the request must be masked in the buffer assert.ok( !JSON.stringify(entry.requestHeaders).includes("sk-supersecret-token-abcdef0123456789"), "request secret is masked in the captured headers" ); } finally { await engine.close(); await upstream.close(); } }); test("the forward dials its upstream through connectRaw — the bypass-marked seam (anti-loop regression)", async () => { // Regression for the bug the VPS e2e caught: `https.request({ createConnection })` // is silently IGNORED whenever an agent is present — and `agent: false` still // installs a fresh default Agent — so the forward opened its OWN unmarked socket. // On a TPROXY host that re-intercepts the proxy's own forward, that loops // infinitely. The fix puts the marked socket on the Agent's createConnection; // this asserts connectRaw (the anti-loop seam) is actually invoked. globalTrafficBuffer.clear(); const upstream = await startHttpsUpstream(); const certStore = new DynamicCertStore("OmniRoute MITM CA (test)"); const caPem = await certStore.getCaCertPem(); let connectRawCalls = 0; const forward = createForward( (ip, port) => { connectRawCalls += 1; return net.connect(port, ip); }, { rejectUnauthorized: false } ); const engine = await startEngineListener( certStore, { ip: "127.0.0.1", port: upstream.port }, forward ); try { const body = await tlsRequest( engine.port, "api.example.com", caPem, "GET /seam HTTP/1.1\r\nHost: api.example.com\r\nConnection: close\r\n\r\n" ); assert.match(body, /decrypted-roundtrip:\/seam/, "client still gets the upstream response"); assert.ok( connectRawCalls >= 1, "the forward MUST dial through connectRaw (the bypass-marked seam), not a default socket" ); } finally { await engine.close(); await upstream.close(); } }); test("a forward failure is recorded as an error entry and the client gets 502", async () => { globalTrafficBuffer.clear(); const certStore = new DynamicCertStore("OmniRoute MITM CA (test)"); const caPem = await certStore.getCaCertPem(); const failingForward = () => Promise.reject(new Error("upstream unreachable")); const engine = await startEngineListener( certStore, { ip: "127.0.0.1", port: 1 }, failingForward as ReturnType ); try { const body = await tlsRequest( engine.port, "api.example.com", caPem, "GET /boom HTTP/1.1\r\nHost: api.example.com\r\nConnection: close\r\n\r\n" ); assert.match(body, /502 Bad Gateway/, "client receives a 502 status line"); await new Promise((r) => setTimeout(r, 40)); const entry = globalTrafficBuffer.list().at(-1); assert.ok(entry); assert.equal(entry.source, "tproxy"); assert.equal(entry.status, "error"); assert.ok(entry.error && !entry.error.includes("at /"), "error is sanitized (no stack trace)"); } finally { await engine.close(); } });