Diffinery

codician/diffinerypublic

Open-source, agent-first GitHub and GitHub Actions alternative that runs on your own Cloudflare account: git hosting, pull requests, code review and drop-in GitHub Actions YAML on Workers, Durable Objects, R2 and Containers.

101 lines · 5.6 KBRaw History
1import net from "node:net";
2import path from "node:path";
3import tls from "node:tls";
4import { afterAll, beforeAll, describe, expect, it } from "vitest";
5import { clientHelloServerName, startJailProxy } from "../src/jail.js";
6import { 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
12const u16 = (n: number) => Buffer.from([n >> 8, n & 255]);
13const ext = (type: number, data: Buffer) => Buffer.concat([u16(type), u16(data.length), data]);
14const 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. */
19const 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};
24const record = (hs: Buffer) => Buffer.concat([Buffer.from([0x16, 3, 1]), u16(hs.length), hs]);
25
26describe("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});