| 1 | import net from "node:net"; |
| 2 | import path from "node:path"; |
| 3 | import tls from "node:tls"; |
| 4 | import { afterAll, beforeAll, describe, expect, it } from "vitest"; |
| 5 | import { clientHelloServerName, startJailProxy } from "../src/jail.js"; |
| 6 | import { tmp } from "./helpers.js"; |
| 7 | |
| 8 | // Several hosts a task may reach (registry.npmjs.org, chatgpt.com) are behind a CDN that serves every site it |
| 9 | // hosts on the same addresses. A tunnel the proxy opened by the CONNECT host alone could carry TLS for any of |
| 10 | // them, an attacker's included, so it opens only when the ClientHello asks for that same host. |
| 11 | |
| 12 | const u16 = (n: number) => Buffer.from([n >> 8, n & 255]); |
| 13 | const ext = (type: number, data: Buffer) => Buffer.concat([u16(type), u16(data.length), data]); |
| 14 | const sni = (...names: string[]) => { |
| 15 | const list = Buffer.concat(names.map((n) => Buffer.concat([Buffer.from([0]), u16(n.length), Buffer.from(n)]))); |
| 16 | return ext(0, Buffer.concat([u16(list.length), list])); |
| 17 | }; |
| 18 | /** A TLS 1.3-style ClientHello handshake message with these extensions. */ |
| 19 | const hello = (...exts: Buffer[]) => { |
| 20 | const e = Buffer.concat(exts); |
| 21 | const body = Buffer.concat([u16(0x0303), Buffer.alloc(32, 7), Buffer.from([0]), u16(2), u16(0x1301), Buffer.from([1, 0]), u16(e.length), e]); |
| 22 | return Buffer.concat([Buffer.from([1, body.length >> 16, (body.length >> 8) & 255, body.length & 255]), body]); |
| 23 | }; |
| 24 | const record = (hs: Buffer) => Buffer.concat([Buffer.from([0x16, 3, 1]), u16(hs.length), hs]); |
| 25 | |
| 26 | describe("the jail proxy's ClientHello check", () => { |
| 27 | it("reads the server name a ClientHello asks for", () => { |
| 28 | expect(clientHelloServerName(record(hello(ext(0x2b, Buffer.from([2, 3, 4])), sni("Registry.NPMjs.org."))))).toBe("registry.npmjs.org"); |
| 29 | // Split over two records, as a large post-quantum key share can make it. |
| 30 | const hs = hello(ext(0x33, Buffer.alloc(3000, 1)), sni("registry.npmjs.org")); |
| 31 | const split = Buffer.concat([record(hs.subarray(0, 100)), record(hs.subarray(100))]); |
| 32 | expect(clientHelloServerName(split)).toBe("registry.npmjs.org"); |
| 33 | expect(clientHelloServerName(split.subarray(0, split.length - 1))).toBeUndefined(); |
| 34 | expect(clientHelloServerName(split.subarray(0, 3))).toBeUndefined(); |
| 35 | }); |
| 36 | |
| 37 | it("finds no usable name in anything else", () => { |
| 38 | expect(clientHelloServerName(record(hello()))).toBeNull(); // no SNI |
| 39 | expect(clientHelloServerName(record(hello(sni("a.test", "b.test"))))).toBeNull(); |
| 40 | expect(clientHelloServerName(record(hello(sni("a.test"), sni("b.test"))))).toBeNull(); |
| 41 | expect(clientHelloServerName(record(hello(sni("registry.npmjs.org"), ext(0xfe0d, Buffer.alloc(40, 9)))))).toBeNull(); // Encrypted Client Hello |
| 42 | expect(clientHelloServerName(record(hello(sni("bad name\u0000.test"))))).toBeNull(); |
| 43 | expect(clientHelloServerName(Buffer.from("GET / HTTP/1.1\r\nHost: registry.npmjs.org\r\n\r\n"))).toBeNull(); |
| 44 | const truncated = hello(sni("registry.npmjs.org")); |
| 45 | truncated[3] = truncated[3]! + 50; // claims more than the record holds, which never arrives |
| 46 | expect(clientHelloServerName(Buffer.concat([record(truncated), Buffer.alloc(70 * 1024)]))).toBeNull(); |
| 47 | }); |
| 48 | |
| 49 | describe("in the proxy", () => { |
| 50 | let close: () => void; |
| 51 | let sock: string; |
| 52 | const logs: string[] = []; |
| 53 | const upstream: Array<{ host: string; bytes: Buffer }> = []; |
| 54 | let tlsServer: net.Server; |
| 55 | beforeAll(async () => { |
| 56 | sock = path.join(tmp("jail-sni-"), "p.sock"); |
| 57 | // Stands in for the real host: records what the tunnel sends it. |
| 58 | tlsServer = net.createServer((s) => s.on("data", (d) => upstream.at(-1)!.bytes = Buffer.concat([upstream.at(-1)!.bytes, d]))); |
| 59 | await new Promise<void>((r) => tlsServer.listen(0, "127.0.0.1", () => r())); |
| 60 | const port = (tlsServer.address() as net.AddressInfo).port; |
| 61 | const server = await startJailProxy(sock, (l) => logs.push(l), (_p, host, onConnect) => { |
| 62 | upstream.push({ host, bytes: Buffer.alloc(0) }); |
| 63 | return net.connect(port, "127.0.0.1", onConnect); |
| 64 | }); |
| 65 | close = () => (server.close(), tlsServer.close()); |
| 66 | }); |
| 67 | afterAll(() => close()); |
| 68 | |
| 69 | /** CONNECT to `target`, then start TLS asking for `servername`; resolves once the proxy has decided. */ |
| 70 | const tunnel = (target: string, servername: string) => |
| 71 | new Promise<"closed" | "open">((resolve) => { |
| 72 | const c = net.connect(sock, () => c.write(`CONNECT ${target} HTTP/1.1\r\nHost: ${target}\r\n\r\n`)); |
| 73 | let got = ""; |
| 74 | const onData = (d: Buffer) => { |
| 75 | got += d; |
| 76 | if (!got.includes("\r\n\r\n")) return; |
| 77 | c.off("data", onData); |
| 78 | const t = tls.connect({ socket: c, servername, rejectUnauthorized: false }); |
| 79 | t.on("error", () => {}); |
| 80 | c.on("close", () => resolve("closed")); |
| 81 | setTimeout(() => (resolve("open"), c.destroy()), 500); |
| 82 | }; |
| 83 | c.on("data", onData); |
| 84 | c.on("error", () => resolve("closed")); |
| 85 | }); |
| 86 | |
| 87 | it("opens the tunnel when the ClientHello asks for the CONNECT host", async () => { |
| 88 | expect(await tunnel("registry.npmjs.org:443", "registry.npmjs.org")).toBe("open"); |
| 89 | const sent = upstream.at(-1)!; |
| 90 | expect(sent.host).toBe("registry.npmjs.org"); |
| 91 | expect(clientHelloServerName(sent.bytes)).toBe("registry.npmjs.org"); |
| 92 | }); |
| 93 | |
| 94 | it("closes it, without connecting anywhere, when the ClientHello asks for another site", async () => { |
| 95 | const before = upstream.length; |
| 96 | expect(await tunnel("registry.npmjs.org:443", "attacker.workers.dev")).toBe("closed"); |
| 97 | expect(upstream.length).toBe(before); |
| 98 | expect(logs.some((l) => l.includes("refused a connection to registry.npmjs.org:443") && l.includes("asks for attacker.workers.dev"))).toBe(true); |
| 99 | }); |
| 100 | }); |
| 101 | }); |