Public: anyone can read this; the crew can change it.
On the check host, a crew sandbox's proxy answers allowed hosts, so the escape test's through-proxy attempts run there #10695
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/10602-on-the-check-host-a-crew-sandbox-s-proxy → main 2 commits fork
2 files
changed +69
−39
docs/ops/RED-TEAM.md +8 −6
| @@ -42,12 +42,14 @@ forge's check runs it. | ||
| 42 | 42 | | Controls: read and write the own worktree, read the own environ, reach a listener in the own network namespace directly, reach the forge's host:port through the proxy | works | the four "control:" tests | |
| 43 | 43 | |
| 44 | 44 | The controls keep a probe that never ran, or a proxy that refuses |
| 45 | everything, from passing as "all refused". Where the allowed host does not | |
| 46 | answer through the proxy either (the forge's check host today, the same cause | |
| 47 | that keeps `test/crew-sandbox-real.test.ts` out of the gate; #10602 makes them run there), the | |
| 48 | attempts through the proxy and their control are skipped, printing | |
| 49 | `skipped: test/crew-sandbox-escape.test.ts, the attempts through the proxy: | |
| 50 | <reason>`; the direct attempts, the reads and the writes still run. | |
| 45 | everything, from passing as "all refused". The probe makes its through-proxy | |
| 46 | requests as raw requests to the proxy (with the credential `srt` puts in | |
| 47 | `HTTP_PROXY`), not with `fetch`: `srt` puts `127.0.0.1` and `localhost` in | |
| 48 | `NO_PROXY`, and a client that honours it goes direct and cannot connect, which | |
| 49 | is what the allowed forge host:port did on the check host (#10602). `srt` itself | |
| 50 | is launched without proxy variables, so it does not chain through the cell's | |
| 51 | egress proxy. Nothing is skipped for the proxy any more; a failure prints the | |
| 52 | proxy's own status line. | |
| 51 | 53 | |
| 52 | 54 | ## Not done here |
| 53 | 55 |
test/crew-sandbox-escape.test.ts +61 −33
| @@ -24,6 +24,11 @@ | ||
| 24 | 24 | * through the sandbox's proxy. The control reaches the forge's host:port, |
| 25 | 25 | * which is allowed, through the proxy. |
| 26 | 26 | * |
| 27 | * The through-proxy requests are raw requests to the proxy, not `fetch`: srt | |
| 28 | * puts 127.0.0.1 and localhost in NO_PROXY, which a client honours by going | |
| 29 | * direct (#10602). `srt` is launched without proxy variables (`withoutParentProxy`): it would | |
| 30 | * chain through them, and the check host's egress proxy refuses loopback. | |
| 31 | * | |
| 27 | 32 | * Like crew-sandbox-real.test.ts this needs a sandbox of its own, so inside a |
| 28 | 33 | * crew run or a container that refuses user namespaces it is skipped, naming |
| 29 | 34 | * itself and the reason on the console; the forge's check runs it. Where a |
| @@ -72,6 +77,21 @@ const WRITTEN = "escape-written.txt"; | ||
| 72 | 77 | /** Hosts off the allowlist that name the outside server. */ |
| 73 | 78 | const OFF_LIST = ["127.0.0.1", "localhost"] as const; |
| 74 | 79 | |
| 80 | /** | |
| 81 | * The environment `srt` is launched with, minus any proxy setting. `srt` | |
| 82 | * reads `HTTP_PROXY`/`HTTPS_PROXY`/`NO_PROXY` of its own process as its | |
| 83 | * *parent proxy* and chains every allowed request through it. On the check | |
| 84 | * host that is the cell's egress proxy, which refuses `127.0.0.1:<port>`, | |
| 85 | * so the allowed forge host:port got "Unable to connect" (#10602). A crew | |
| 86 | * run's real sandbox is not launched behind one, so the test is not either. | |
| 87 | */ | |
| 88 | function withoutParentProxy(env: NodeJS.ProcessEnv): Record<string, string> { | |
| 89 | const kept: Record<string, string> = {}; | |
| 90 | for (const [key, value] of Object.entries(env)) | |
| 91 | if (value !== undefined && !/^(http|https|all|ftp|no)_proxy$/i.test(key)) kept[key] = value; | |
| 92 | return kept; | |
| 93 | } | |
| 94 | ||
| 75 | 95 | function routed(route: Route, path: string, pid: number): string { |
| 76 | 96 | if (route === "by its path") return path; |
| 77 | 97 | return `${route.replace("<pid>", String(pid))}${path}`; |
| @@ -151,10 +171,38 @@ const has = (path: string, needle: string) => { | ||
| 151 | 171 | try { return readFileSync(path, "utf8").includes(needle); } catch { return false; } |
| 152 | 172 | }; |
| 153 | 173 | // The network first, before the shell touches /proc. |
| 154 | const get = async (url: string, proxy?: string) => { | |
| 155 | try { return await (await fetch(url, { proxy })).text(); } | |
| 156 | catch (e) { return "refused: " + String(e); } | |
| 157 | }; | |
| 174 | // Through the proxy is a raw request to it too, in the absolute form a proxy | |
| 175 | // takes, so no client's NO_PROXY handling (srt sets 127.0.0.1 and localhost | |
| 176 | // in it) can send it direct instead: what comes back is the body, or the | |
| 177 | // proxy's status line, or the refusal to connect. | |
| 178 | const get = (url: string, proxy: string) => | |
| 179 | new Promise<string>((resolve) => { | |
| 180 | const p = new URL(proxy); | |
| 181 | const target = new URL(url); | |
| 182 | const auth = p.username | |
| 183 | ? "proxy-authorization: Basic " + | |
| 184 | btoa(decodeURIComponent(p.username) + ":" + decodeURIComponent(p.password)) + "\\r\\n" | |
| 185 | : ""; | |
| 186 | let bytes = ""; | |
| 187 | const done = () => { | |
| 188 | const at = bytes.indexOf("\\r\\n\\r\\n"); | |
| 189 | const status = bytes.split("\\r\\n")[0] ?? ""; | |
| 190 | resolve(at >= 0 && / 2\\d\\d/.test(status) ? bytes.slice(at + 4) : "refused: " + (status || "no answer")); | |
| 191 | }; | |
| 192 | Bun.connect({ | |
| 193 | hostname: p.hostname, | |
| 194 | port: Number(p.port), | |
| 195 | socket: { | |
| 196 | open(socket) { | |
| 197 | socket.write("GET " + url + " HTTP/1.1\\r\\nhost: " + target.host + "\\r\\n" + auth + "connection: close\\r\\n\\r\\n"); | |
| 198 | }, | |
| 199 | data(_socket, chunk) { bytes += new TextDecoder().decode(chunk); }, | |
| 200 | close() { done(); }, | |
| 201 | error(_socket, e) { resolve("refused: " + String(e)); }, | |
| 202 | connectError(_socket, e) { resolve("refused: connecting to the proxy: " + String(e)); }, | |
| 203 | }, | |
| 204 | }).catch((e: unknown) => resolve("refused: " + String(e))); | |
| 205 | }); | |
| 158 | 206 | // Direct is a raw TCP connection, so no proxy setting in the environment |
| 159 | 207 | // can carry it: what comes back is the server's bytes or the refusal. |
| 160 | 208 | const direct = (hostname: string, port: number) => |
| @@ -178,7 +226,7 @@ const own = Bun.serve({ hostname: "127.0.0.1", port: 0, fetch: () => new Respons | ||
| 178 | 226 | const ownDirect = await direct("127.0.0.1", own.port); |
| 179 | 227 | own.stop(true); |
| 180 | 228 | // The allowed control first: a refusal must not be what decides it. |
| 181 | const forgeViaProxy = await get("http://127.0.0.1:${forge.port}/", process.env.HTTP_PROXY); | |
| 229 | const forgeViaProxy = process.env.HTTP_PROXY ? await get("http://127.0.0.1:${forge.port}/", process.env.HTTP_PROXY) : "no proxy in the shell"; | |
| 182 | 230 | const net: Record<string, string> = {}; |
| 183 | 231 | for (const host of ${JSON.stringify(OFF_LIST)}) { |
| 184 | 232 | const url = "http://" + host + ":" + ${outside.port} + "/"; |
| @@ -237,6 +285,7 @@ console.log(JSON.stringify({ | ||
| 237 | 285 | ); |
| 238 | 286 | const proc = Bun.spawn([srt, "--settings", settings, "-c", `'${process.execPath}' probe.ts`], { |
| 239 | 287 | cwd: work, |
| 288 | env: withoutParentProxy(process.env), | |
| 240 | 289 | stdin: "ignore", |
| 241 | 290 | stdout: "pipe", |
| 242 | 291 | stderr: "pipe", |
| @@ -257,21 +306,6 @@ if (nestedReason === undefined) | ||
| 257 | 306 | probeError = e instanceof Error ? e.message : String(e); |
| 258 | 307 | }); |
| 259 | 308 | |
| 260 | /** | |
| 261 | * Where the allowed host cannot be reached through the proxy either, the | |
| 262 | * proxy answers nothing here (the check host today, #10602, as for | |
| 263 | * crew-sandbox-real.test.ts), and a refusal through it would prove nothing: | |
| 264 | * those attempts and their control are skipped, named. | |
| 265 | */ | |
| 266 | const proxyReason = | |
| 267 | nestedReason === undefined && probeError === undefined && report!.forgeViaProxy !== "forge" | |
| 268 | ? `the sandbox's proxy does not answer here, even for an allowed host (#10602): ${report!.forgeViaProxy}` | |
| 269 | : undefined; | |
| 270 | if (proxyReason !== undefined) | |
| 271 | console.warn( | |
| 272 | `skipped: test/crew-sandbox-escape.test.ts, the attempts through the proxy: ${proxyReason}`, | |
| 273 | ); | |
| 274 | ||
| 275 | 309 | describe.skipIf(nestedReason !== undefined)( |
| 276 | 310 | "read attempts from a real sandboxed shell, /proc unmasked", |
| 277 | 311 | () => { |
| @@ -311,12 +345,9 @@ describe.skipIf(nestedReason !== undefined)( | ||
| 311 | 345 | expect(readFileSync(join(work, "own-written.txt"), "utf8")).toBe(CANARY); |
| 312 | 346 | }); |
| 313 | 347 | |
| 314 | test.skipIf(proxyReason !== undefined)( | |
| 315 | "control: the shell reaches the forge's host:port through the proxy", | |
| 316 | () => { | |
| 317 | expect(report.forgeViaProxy).toBe("forge"); | |
| 318 | }, | |
| 319 | ); | |
| 348 | test("control: the shell reaches the forge's host:port through the proxy", () => { | |
| 349 | expect(report.forgeViaProxy).toBe("forge"); | |
| 350 | }); | |
| 320 | 351 | |
| 321 | 352 | for (const label of Object.keys(DIRS)) |
| 322 | 353 | for (const route of ROUTES) |
| @@ -340,13 +371,10 @@ describe.skipIf(nestedReason !== undefined)( | ||
| 340 | 371 | expect(report.net[`${host}|direct`]).not.toContain("outside"); |
| 341 | 372 | }); |
| 342 | 373 | |
| 343 | test.skipIf(proxyReason !== undefined)( | |
| 344 | `cannot reach ${host}:<port off the allowlist>, through the proxy`, | |
| 345 | () => { | |
| 346 | expect(report.net[`${host}|proxy`]).not.toBe("outside"); | |
| 347 | expect(report.net[`${host}|proxy`]).not.toBe("no proxy"); | |
| 348 | }, | |
| 349 | ); | |
| 374 | test(`cannot reach ${host}:<port off the allowlist>, through the proxy`, () => { | |
| 375 | expect(report.net[`${host}|proxy`]).not.toBe("outside"); | |
| 376 | expect(report.net[`${host}|proxy`]).not.toBe("no proxy"); | |
| 377 | }); | |
| 350 | 378 | } |
| 351 | 379 | }, |
| 352 | 380 | ); |