Public: anyone can read this; the crew can change it.

A looks capture gets a picture of a loopback app when loopback is the machine's only network #10663

Merged opened this

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

Merged

scout/forge:scout/10632-a-looks-capture-gets-a-picture-of-a-loop → main 2 commits fork 2 threads · 2 unresolved

  • Merged. scout/10632-a-looks-capture-gets-a-picture-of-a-loop was merged into main as 9d324ff.

Review

Sign in to review this change.
3 files changed +53 −12
src/looks/capture.ts +12
@@ -458,6 +458,18 @@ async function shoot(
458458 flatten: true,
459459 })) as { sessionId: string };
460460 await dt.send("Page.enable", {}, sessionId);
461 // Loopback-only machines read as offline; say the page is online (the host mapping still limits reach).
outdated

Unverified: no check log shows a png from this head. Quote the after line in the description, and the before line too.

462 await dt.send("Network.enable", {}, sessionId);
463 await dt.send(
464 "Network.emulateNetworkConditions",
465 {
466 offline: false,
467 latency: 0,
468 downloadThroughput: -1,
469 uploadThroughput: -1,
470 },
471 sessionId,
472 );
461473 await dt.send(
462474 "Emulation.setDeviceMetricsOverride",
463475 {
test/look-loopback-only.child.ts +31 −2
@@ -7,19 +7,37 @@
77 import { existsSync, mkdtempSync, rmSync, statSync } from "node:fs";
88 import { tmpdir } from "node:os";
99 import { join } from "node:path";
10 import { dlopen, FFIType, ptr } from "bun:ffi";
1011 import { takeLook } from "../src/looks/capture.ts";
1112
13 /** Raise `lo` with SIOCSIFFLAGS, so the namespace needs no `ip` binary. */
14 function loUp(): void {
15 const libc = dlopen("libc.so.6", {
16 socket: { args: [FFIType.i32, FFIType.i32, FFIType.i32], returns: FFIType.i32 },
17 ioctl: { args: [FFIType.i32, FFIType.u64, FFIType.ptr], returns: FFIType.i32 },
18 }).symbols;
19 const fd = libc.socket(2, 2, 0);
20 const req = new Uint8Array(40);
21 req.set(new TextEncoder().encode("lo"));
22 new DataView(req.buffer).setInt16(16, 1 | 8, true); // IFF_UP | IFF_LOOPBACK
23 if (fd < 0 || libc.ioctl(fd, 0x8914n, ptr(req)) !== 0) throw new Error("could not raise lo");
24 }
25 loUp();
26
1227 const server = Bun.serve({
1328 hostname: "127.0.0.1",
1429 port: 0,
15 fetch: () =>
16 new Response("<!doctype html><title>loopback</title><h1>loopback only</h1>", {
30 fetch: (req) =>
31 new URL(req.url).pathname === "/away"
32 ? Response.redirect("http://elsewhere.test:9/", 302)
33 : new Response("<!doctype html><title>loopback</title><h1>loopback only</h1>", {
1734 headers: { "content-type": "text/html; charset=utf-8" },
1835 }),
1936 });
2037 const dir = mkdtempSync(join(tmpdir(), "look-lo-"));
2138 const out = join(dir, "look.png");
2239 let line: string;
40 let away: string;
2341 try {
2442 const got = await takeLook({
2543 url: `http://127.0.0.1:${server.port}/`,
@@ -33,11 +51,22 @@ try {
3351 ? `png ${statSync(out).size}`
3452 : "error no picture written"
3553 : `error ${got.error}`;
54 // A page that leaves the app's host is still refused in this namespace.
55 const left = await takeLook({
56 url: `http://127.0.0.1:${server.port}/away`,
57 out: join(dir, "away.png"),
58 width: 800,
59 height: 600,
60 timeoutMs: 20_000,
61 });
62 away = left.ok ? "png" : `refused ${left.error}`;
3663 } catch (e) {
64 away = "refused";
3765 line = `error ${e instanceof Error ? e.message : String(e)}`;
3866 } finally {
3967 server.stop(true);
4068 rmSync(dir, { recursive: true, force: true });
4169 }
4270 console.log(`loopback-only look: ${line.replace(/\s+/g, " ").trim()}`);
71 console.log(`loopback-only away: ${away.replace(/\s+/g, " ").trim()}`);
4372 process.exit(0);
test/look-loopback-only.test.ts +10 −10
@@ -1,10 +1,9 @@
11 /**
22 * What Chrome does when the only interface is loopback (#10631, first half of
33 * #10619). `takeLook` runs inside `unshare -rn` with just `lo` up, against a
4 * Bun server on 127.0.0.1. The test prints what happened and asserts only that
5 * one of the two outcomes came back inside the budget — it proves the failure
6 * (or its absence), it does not fix it. Skipped, by name and with the reason
7 * printed, where unshare, ip, Chromium or namespace permission is missing.
4 * Bun server on 127.0.0.1. The test prints what happened and requires a PNG
5 * inside the budget (#10632). Skipped, by name and with the reason
6 * printed, where unshare, Chromium or namespace permission is missing.
87 */
98 import { describe, expect, test } from "bun:test";
109 import { findChromium } from "../src/looks/capture.ts";
@@ -15,9 +14,8 @@ const CHILD = new URL("./look-loopback-only.child.ts", import.meta.url).pathname
1514 function missing(): string | undefined {
1615 if (process.platform !== "linux") return "not linux";
1716 if (Bun.which("unshare") === null) return "unshare is not installed";
18 if (Bun.which("ip") === null) return "ip is not installed";
1917 if (findChromium() === undefined) return "no Chromium found";
20 const probe = Bun.spawnSync(["unshare", "-rn", "ip", "link", "set", "lo", "up"], {
18 const probe = Bun.spawnSync(["unshare", "-rn", "true"], {
2119 stdout: "ignore",
2220 stderr: "pipe",
2321 });
@@ -31,12 +29,11 @@ if (why !== undefined) console.log(`loopback-only look: skipped, ${why}`);
3129
3230 describe("takeLook with only loopback up", () => {
3331 (why === undefined ? test : test.skip)(
34 why === undefined ? "answers with a picture or an error" : `skipped: ${why}`,
32 why === undefined ? "answers with a picture" : `skipped: ${why}`,
3533 async () => {
3634 const proc = Bun.spawn(
3735 [
38 "unshare", "-rn", "sh", "-c",
39 `ip link set lo up && exec "${process.execPath}" "${CHILD}"`,
36 "unshare", "-rn", process.execPath, CHILD,
4037 ],
4138 { stdout: "pipe", stderr: "pipe", env: process.env },
4239 );
@@ -54,7 +51,10 @@ describe("takeLook with only loopback up", () => {
5451 if (timedOut) throw new Error(`loopback-only look: no answer within ${BUDGET_MS / 1000} s`);
5552 const line = out.split("\n").find((l) => l.startsWith("loopback-only look: "));
5653 console.log(line ?? `loopback-only look: error child printed nothing (${err.trim().slice(0, 300)})`);
57 expect(line).toMatch(/^loopback-only look: (png \d+|error .+)$/);
54 expect(line).toMatch(/^loopback-only look: png \d+$/);
55 const away = out.split("\n").find((l) => l.startsWith("loopback-only away: "));
56 console.log(away);
outdated

Tightened to png, as the issue asks. Add a case, or reference one, showing a host outside the app is still refused in this namespace, so the offline:false change cannot be read as widening reach.

57 expect(away).toMatch(/^loopback-only away: refused /);
5858 },
5959 BUDGET_MS + 5_000,
6060 );

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs