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

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/10602-on-the-check-host-a-crew-sandbox-s-proxy → main 2 commits fork

  • Merged. scout/10602-on-the-check-host-a-crew-sandbox-s-proxy was merged into main as fc09698.

Review

Sign in to review this change.
2 files changed +69 −39
docs/ops/RED-TEAM.md +8 −6
@@ -42,12 +42,14 @@ forge's check runs it.
4242 | 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 |
4343
4444 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.
5153
5254 ## Not done here
5355
test/crew-sandbox-escape.test.ts +61 −33
@@ -24,6 +24,11 @@
2424 * through the sandbox's proxy. The control reaches the forge's host:port,
2525 * which is allowed, through the proxy.
2626 *
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 *
2732 * Like crew-sandbox-real.test.ts this needs a sandbox of its own, so inside a
2833 * crew run or a container that refuses user namespaces it is skipped, naming
2934 * itself and the reason on the console; the forge's check runs it. Where a
@@ -72,6 +77,21 @@ const WRITTEN = "escape-written.txt";
7277 /** Hosts off the allowlist that name the outside server. */
7378 const OFF_LIST = ["127.0.0.1", "localhost"] as const;
7479
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
7595 function routed(route: Route, path: string, pid: number): string {
7696 if (route === "by its path") return path;
7797 return `${route.replace("<pid>", String(pid))}${path}`;
@@ -151,10 +171,38 @@ const has = (path: string, needle: string) => {
151171 try { return readFileSync(path, "utf8").includes(needle); } catch { return false; }
152172 };
153173 // 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 });
158206 // Direct is a raw TCP connection, so no proxy setting in the environment
159207 // can carry it: what comes back is the server's bytes or the refusal.
160208 const direct = (hostname: string, port: number) =>
@@ -178,7 +226,7 @@ const own = Bun.serve({ hostname: "127.0.0.1", port: 0, fetch: () => new Respons
178226 const ownDirect = await direct("127.0.0.1", own.port);
179227 own.stop(true);
180228 // 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";
182230 const net: Record<string, string> = {};
183231 for (const host of ${JSON.stringify(OFF_LIST)}) {
184232 const url = "http://" + host + ":" + ${outside.port} + "/";
@@ -237,6 +285,7 @@ console.log(JSON.stringify({
237285 );
238286 const proc = Bun.spawn([srt, "--settings", settings, "-c", `'${process.execPath}' probe.ts`], {
239287 cwd: work,
288 env: withoutParentProxy(process.env),
240289 stdin: "ignore",
241290 stdout: "pipe",
242291 stderr: "pipe",
@@ -257,21 +306,6 @@ if (nestedReason === undefined)
257306 probeError = e instanceof Error ? e.message : String(e);
258307 });
259308
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
275309 describe.skipIf(nestedReason !== undefined)(
276310 "read attempts from a real sandboxed shell, /proc unmasked",
277311 () => {
@@ -311,12 +345,9 @@ describe.skipIf(nestedReason !== undefined)(
311345 expect(readFileSync(join(work, "own-written.txt"), "utf8")).toBe(CANARY);
312346 });
313347
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 });
320351
321352 for (const label of Object.keys(DIRS))
322353 for (const route of ROUTES)
@@ -340,13 +371,10 @@ describe.skipIf(nestedReason !== undefined)(
340371 expect(report.net[`${host}|direct`]).not.toContain("outside");
341372 });
342373
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 });
350378 }
351379 },
352380 );

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs