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.

Terminal task jail: open a tunnel only when the TLS ClientHello asks for the CONNECT host (#163)

The jail's proxy allowed tunnels by CONNECT host name and then passed TLS through. registry.npmjs.org and
chatgpt.com sit behind Cloudflare, whose edge serves every site it hosts on the same addresses, so a task
could CONNECT to an allowed host and open TLS naming any other Cloudflare site (an attacker's workers.dev
included) and reach it. The proxy now answers the CONNECT, reads the client's ClientHello (also when split
over records), and connects only if its one server name is the CONNECT host; no name, several names,
Encrypted Client Hello, anything that isn't a ClientHello, or nothing within 10 seconds closes the
tunnel without connecting, and is logged.


Claude-Session: https://claude.ai/code/session_016yhgAu6oKfqes812Yd4qjo

Co-authored-by: Claude <noreply@anthropic.com>
Canberk Demirci authored · GitHub committed signed1 parent 1c15c4dcommit a7e3f13bad6f998ba4a4de5e8aa17cdb127abcb3Browse files
3 files changed+215−9
modifiedapps/diffinery/docs/agents/terminal.md+1−1View file
@@ -63,7 +63,7 @@
6363 | --- | --- |
6464 | Files | The task's clone (read and write), your checkout's git objects (read only), and copies of the CLI's own sign-in and settings (`~/.claude/.credentials.json` and `~/.claude.json`, or `~/.codex/auth.json` and `~/.codex/config.toml`). The copies are never written back, so nothing the agent writes there reaches your terminal. If the CLI refreshes its sign-in during a task and the provider retires the old refresh token, the terminal's CLI may ask you to sign in again. Nothing else of your home directory, your checkout or its git config (which holds the sandbox's git credential), or `/tmp`. The rest of the system is read only. |
6565 | Processes | Its own: it can't see or signal your shell or anything else in the sandbox. No capabilities. |
66-| Network | None of its own. Its only way out is a proxy outside the jail (set as `HTTPS_PROXY`) that connects only to the hosts below, by name, on port 443. The proxy doesn't decrypt anything. |
66+| Network | None of its own. Its only way out is a proxy outside the jail (set as `HTTPS_PROXY`) that connects only to the hosts below, by name, on port 443, and only when the connection's TLS handshake asks for that same host (some of them share addresses with every other site on their CDN). The proxy doesn't decrypt anything. |
6767 | Environment | Only what the CLI needs: no sandbox tokens. Web search and fetch are off, and Claude Code is told not to read or edit `~/.claude`, `~/.claude.json`, `~/.codex`, `~/.config` or the checkout's git config. |
6868
6969 The hosts:
modifiedcontainers/runner/src/jail.ts+113−8View file
@@ -17,6 +17,8 @@
1717 *
1818 * The jail's only way out is a CONNECT proxy, outside it, that opens tunnels to the hosts in JAIL_HOSTS and
1919 * nothing else. It never terminates TLS and handles no tokens: the CLI signs itself in, as in the terminal.
20+ * Several of those hosts sit behind CDNs that serve every site they host on the same addresses, so a tunnel
21+ * opens only once the client's TLS ClientHello names the same host (see `clientHelloServerName`).
2022 * Inside, a relay on 127.0.0.1 forwards to the proxy's Unix socket and HTTPS_PROXY points at it.
2123 *
2224 * `finish` jails have no way out at all: they commit what the agent left and write a git bundle, which the
@@ -200,8 +202,86 @@
200202 return args;
201203 }
202204
205+const CLIENT_HELLO_TIMEOUT_MS = 10_000;
206+/** A ClientHello spread over more than this is refused. */
207+const MAX_CLIENT_HELLO_BYTES = 64 * 1024;
208+
209+/**
210+ * The server name a TLS client's first bytes ask for (SNI): the name, lower case; `null` when they aren't a
211+ * ClientHello, name no single host name, or carry Encrypted Client Hello (whose real name is hidden); or
212+ * `undefined` while more bytes are needed. Handles a ClientHello split over several records.
213+ */
214+export function clientHelloServerName(data: Uint8Array): string | null | undefined {
215+ const fragments: Uint8Array[] = [];
216+ let have = 0;
217+ let pos = 0;
218+ for (;;) {
219+ if (data.length < pos + 5) return data.length > MAX_CLIENT_HELLO_BYTES ? null : undefined;
220+ if (data[pos] !== 0x16) return null; // not a handshake record
221+ const len = (data[pos + 3]! << 8) | data[pos + 4]!;
222+ if (len === 0 || len > 16384) return null;
223+ if (data.length < pos + 5 + len) return pos + 5 + len > MAX_CLIENT_HELLO_BYTES ? null : undefined;
224+ fragments.push(data.subarray(pos + 5, pos + 5 + len));
225+ have += len;
226+ pos += 5 + len;
227+ const hs = Buffer.concat(fragments, have);
228+ if (hs[0] !== 0x01) return null; // not a ClientHello
229+ if (hs.length < 4) continue;
230+ const size = (hs[1]! << 16) | (hs[2]! << 8) | hs[3]!;
231+ if (size + 4 > MAX_CLIENT_HELLO_BYTES) return null;
232+ if (hs.length < size + 4) continue;
233+ try {
234+ return serverNameOf(hs.subarray(4, 4 + size));
235+ } catch {
236+ return null;
237+ }
238+ }
239+}
240+
241+function serverNameOf(b: Buffer): string | null {
242+ const at = (n: number) => {
243+ if (n >= b.length) throw new RangeError("short ClientHello");
244+ return b[n]!;
245+ };
246+ const u16 = (n: number) => (at(n) << 8) | at(n + 1);
247+ let p = 2 + 32; // version, random
248+ p += 1 + at(p); // session id
249+ p += 2 + u16(p); // cipher suites
250+ p += 1 + at(p); // compression methods
251+ if (p + 2 > b.length) return null; // no extensions, so no server name
252+ const end = p + 2 + u16(p);
253+ if (end > b.length) return null;
254+ p += 2;
255+ const names: string[] = [];
256+ while (p < end) {
257+ const type = u16(p);
258+ const len = u16(p + 2);
259+ const body = p + 4;
260+ if (body + len > end) return null;
261+ if (type === 0xfe0d) return null; // encrypted_client_hello
262+ if (type === 0x0000) {
263+ const listEnd = body + 2 + u16(body);
264+ if (listEnd > body + len) return null;
265+ for (let q = body + 2; q < listEnd; ) {
266+ const nameType = at(q);
267+ const nameLen = u16(q + 1);
268+ if (q + 3 + nameLen > listEnd) return null;
269+ if (nameType === 0) names.push(b.subarray(q + 3, q + 3 + nameLen).toString("latin1"));
270+ q += 3 + nameLen;
271+ }
272+ }
273+ p = body + len;
274+ }
275+ if (names.length !== 1 || !/^[A-Za-z0-9.-]+$/.test(names[0]!)) return null;
276+ return names[0]!.toLowerCase().replace(/\.$/, "");
277+}
278+
203279 /** The CONNECT proxy outside the jail: tunnels to JAIL_HOSTS by name, plain-HTTP downloads from package mirrors. */
204-export function startJailProxy(listen: string | number, log: (line: string) => void = (l) => process.stderr.write(`${l}\n`)): Promise<Server> {
280+export function startJailProxy(
281+ listen: string | number,
282+ log: (line: string) => void = (l) => process.stderr.write(`${l}\n`),
283+ connectUpstream: (port: number, host: string, onConnect: () => void) => net.Socket = (port, host, onConnect) => net.connect(port, host, onConnect),
284+): Promise<Server> {
205285 const server = createServer((req, res) => {
206286 let url: URL;
207287 try {
@@ -236,13 +316,38 @@
236316 socket.end("HTTP/1.1 403 Forbidden\r\ncontent-type: text/plain\r\n\r\nnot reachable from a terminal task\n");
237317 return;
238318 }
239- const up = net.connect(port, host, () => {
240- socket.write("HTTP/1.1 200 Connection Established\r\n\r\n");
241- if (head.length) up.write(head);
242- up.pipe(socket);
243- socket.pipe(up);
244- });
245- up.on("error", () => socket.end("HTTP/1.1 502 Bad Gateway\r\n\r\n"));
319+ // The tunnel opens only once the client's ClientHello asks for this same host: CONNECT to an allowed
320+ // host behind a CDN, then TLS naming another site on that CDN, would otherwise reach that site.
321+ socket.write("HTTP/1.1 200 Connection Established\r\n\r\n");
322+ const want = host.toLowerCase().replace(/\.$/, "");
323+ let buf = Buffer.from(head);
324+ const refuse = (why: string) => {
325+ log(`diffinery: refused a connection to ${req.url} from the task: ${why}`);
326+ socket.destroy();
327+ };
328+ const timer = setTimeout(() => refuse("no TLS ClientHello"), CLIENT_HELLO_TIMEOUT_MS);
329+ const check = () => {
330+ const name = clientHelloServerName(buf);
331+ if (name === undefined) return;
332+ socket.off("data", onData);
333+ socket.pause();
334+ clearTimeout(timer);
335+ if (name !== want) return refuse(name === null ? "its TLS ClientHello names no single server, or hides it (Encrypted Client Hello)" : `its TLS ClientHello asks for ${name}`);
336+ const up = connectUpstream(port, host, () => {
337+ up.write(buf);
338+ up.pipe(socket);
339+ socket.pipe(up);
340+ socket.resume();
341+ });
342+ up.on("error", () => socket.destroy());
343+ };
344+ const onData = (d: Buffer) => {
345+ buf = Buffer.concat([buf, d]);
346+ check();
347+ };
348+ socket.on("data", onData);
349+ socket.on("close", () => clearTimeout(timer));
350+ if (buf.length) check();
246351 });
247352 return new Promise((resolve, reject) => {
248353 server.once("error", reject);
addedcontainers/runner/test/jail-sni.test.ts+101−0View file
@@ -1,0 +1,101 @@
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+});