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 @@ | ||
| 63 | 63 | | --- | --- | |
| 64 | 64 | | 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. | |
| 65 | 65 | | 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. | | |
| 67 | 67 | | 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. | |
| 68 | 68 | |
| 69 | 69 | The hosts: |
modifiedcontainers/runner/src/jail.ts+113−8View file
| @@ -17,6 +17,8 @@ | ||
| 17 | 17 | * |
| 18 | 18 | * The jail's only way out is a CONNECT proxy, outside it, that opens tunnels to the hosts in JAIL_HOSTS and |
| 19 | 19 | * 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`). | |
| 20 | 22 | * Inside, a relay on 127.0.0.1 forwards to the proxy's Unix socket and HTTPS_PROXY points at it. |
| 21 | 23 | * |
| 22 | 24 | * `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 @@ | ||
| 200 | 202 | return args; |
| 201 | 203 | } |
| 202 | 204 | |
| 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 | + | |
| 203 | 279 | /** 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> { | |
| 205 | 285 | const server = createServer((req, res) => { |
| 206 | 286 | let url: URL; |
| 207 | 287 | try { |
| @@ -236,13 +316,38 @@ | ||
| 236 | 316 | socket.end("HTTP/1.1 403 Forbidden\r\ncontent-type: text/plain\r\n\r\nnot reachable from a terminal task\n"); |
| 237 | 317 | return; |
| 238 | 318 | } |
| 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(); | |
| 246 | 351 | }); |
| 247 | 352 | return new Promise((resolve, reject) => { |
| 248 | 353 | 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 | +}); |