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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/10632-a-looks-capture-gets-a-picture-of-a-loop → main 2 commits fork 2 threads · 2 unresolved
3 files
changed +53
−12
src/looks/capture.ts +12
| @@ -458,6 +458,18 @@ async function shoot( | ||
| 458 | 458 | flatten: true, |
| 459 | 459 | })) as { sessionId: string }; |
| 460 | 460 | await dt.send("Page.enable", {}, sessionId); |
| 461 | // Loopback-only machines read as offline; say the page is online (the host mapping still limits reach). | |
| 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 | ); | |
| 461 | 473 | await dt.send( |
| 462 | 474 | "Emulation.setDeviceMetricsOverride", |
| 463 | 475 | { |
test/look-loopback-only.child.ts +31 −2
| @@ -7,19 +7,37 @@ | ||
| 7 | 7 | import { existsSync, mkdtempSync, rmSync, statSync } from "node:fs"; |
| 8 | 8 | import { tmpdir } from "node:os"; |
| 9 | 9 | import { join } from "node:path"; |
| 10 | import { dlopen, FFIType, ptr } from "bun:ffi"; | |
| 10 | 11 | import { takeLook } from "../src/looks/capture.ts"; |
| 11 | 12 | |
| 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 | ||
| 12 | 27 | const server = Bun.serve({ |
| 13 | 28 | hostname: "127.0.0.1", |
| 14 | 29 | 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>", { | |
| 17 | 34 | headers: { "content-type": "text/html; charset=utf-8" }, |
| 18 | 35 | }), |
| 19 | 36 | }); |
| 20 | 37 | const dir = mkdtempSync(join(tmpdir(), "look-lo-")); |
| 21 | 38 | const out = join(dir, "look.png"); |
| 22 | 39 | let line: string; |
| 40 | let away: string; | |
| 23 | 41 | try { |
| 24 | 42 | const got = await takeLook({ |
| 25 | 43 | url: `http://127.0.0.1:${server.port}/`, |
| @@ -33,11 +51,22 @@ try { | ||
| 33 | 51 | ? `png ${statSync(out).size}` |
| 34 | 52 | : "error no picture written" |
| 35 | 53 | : `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}`; | |
| 36 | 63 | } catch (e) { |
| 64 | away = "refused"; | |
| 37 | 65 | line = `error ${e instanceof Error ? e.message : String(e)}`; |
| 38 | 66 | } finally { |
| 39 | 67 | server.stop(true); |
| 40 | 68 | rmSync(dir, { recursive: true, force: true }); |
| 41 | 69 | } |
| 42 | 70 | console.log(`loopback-only look: ${line.replace(/\s+/g, " ").trim()}`); |
| 71 | console.log(`loopback-only away: ${away.replace(/\s+/g, " ").trim()}`); | |
| 43 | 72 | process.exit(0); |
test/look-loopback-only.test.ts +10 −10
| @@ -1,10 +1,9 @@ | ||
| 1 | 1 | /** |
| 2 | 2 | * What Chrome does when the only interface is loopback (#10631, first half of |
| 3 | 3 | * #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. | |
| 8 | 7 | */ |
| 9 | 8 | import { describe, expect, test } from "bun:test"; |
| 10 | 9 | import { findChromium } from "../src/looks/capture.ts"; |
| @@ -15,9 +14,8 @@ const CHILD = new URL("./look-loopback-only.child.ts", import.meta.url).pathname | ||
| 15 | 14 | function missing(): string | undefined { |
| 16 | 15 | if (process.platform !== "linux") return "not linux"; |
| 17 | 16 | if (Bun.which("unshare") === null) return "unshare is not installed"; |
| 18 | if (Bun.which("ip") === null) return "ip is not installed"; | |
| 19 | 17 | 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"], { | |
| 21 | 19 | stdout: "ignore", |
| 22 | 20 | stderr: "pipe", |
| 23 | 21 | }); |
| @@ -31,12 +29,11 @@ if (why !== undefined) console.log(`loopback-only look: skipped, ${why}`); | ||
| 31 | 29 | |
| 32 | 30 | describe("takeLook with only loopback up", () => { |
| 33 | 31 | (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}`, | |
| 35 | 33 | async () => { |
| 36 | 34 | const proc = Bun.spawn( |
| 37 | 35 | [ |
| 38 | "unshare", "-rn", "sh", "-c", | |
| 39 | `ip link set lo up && exec "${process.execPath}" "${CHILD}"`, | |
| 36 | "unshare", "-rn", process.execPath, CHILD, | |
| 40 | 37 | ], |
| 41 | 38 | { stdout: "pipe", stderr: "pipe", env: process.env }, |
| 42 | 39 | ); |
| @@ -54,7 +51,10 @@ describe("takeLook with only loopback up", () => { | ||
| 54 | 51 | if (timedOut) throw new Error(`loopback-only look: no answer within ${BUDGET_MS / 1000} s`); |
| 55 | 52 | const line = out.split("\n").find((l) => l.startsWith("loopback-only look: ")); |
| 56 | 53 | 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 /); | |
| 58 | 58 | }, |
| 59 | 59 | BUDGET_MS + 5_000, |
| 60 | 60 | ); |
Unverified: no check log shows a png from this head. Quote the after line in the description, and the before line too.