diff --git a/BUILD-LOG.md b/BUILD-LOG.md index 0535e78f..4fe05ff0 100644 --- a/BUILD-LOG.md +++ b/BUILD-LOG.md @@ -2980,3 +2980,20 @@ session transcript shows the real times: 20:19:22Z (tokens), 20:19:52Z wrong times as well. None of the rulings changed. Rocko's 6b R1 verdict (request changes) is noted under lead decision item 9, and the Discord restart stays held. + +## 2026-09-26: control board Host and Origin guard (Dewey, reviewed by Rocko) + +Before: `packages/control-board/src/serve.mjs` checked only its bind address. +After a DNS rebind, a web page could read `/api/board` and POST `/api/reply`, +which pastes text into a live seat pane. This was never tested in a browser. +After: `foreignRequest(req)` runs first on every route and returns 403 with a +fixed error body. It refuses a Host that isn't a loopback name on the +connection's port, a Host carrying userinfo or a path, and any Origin other +than `http://`, including `null` and an empty value. A request with no +Origin still passes, which is what the WebUI proxy sends. Tests are in section +11 of `serve.test.mjs`. HEAD fails them, and six mutations each fail at their +own case. Rocko approved pin de9ff942 (serve d0a9bbed, tests e01d7bd9) and +captured the proxy's real headers: Host 127.0.0.1:, no Origin. One low +finding is open: the notes' wording on raw Host syntax is broader than the +code (see DEFERRED Done). The running board keeps the old code until Sage +restarts it. diff --git a/agents/dewey/work/board-guard/NOTES.md b/agents/dewey/work/board-guard/NOTES.md new file mode 100644 index 00000000..cca067c4 --- /dev/null +++ b/agents/dewey/work/board-guard/NOTES.md @@ -0,0 +1,105 @@ +# Board Host/Origin guard (#1507, before CHAT-02) + +Author: Dewey. Sage's ruling, 2026-09-26: a separate small change that lands +before CHAT-02, covering the board's existing routes and matching the WebUI's +check, with failing-first tests for a bad Host and a bad Origin on +GET /api/board and POST /api/reply. Rocko reviews it. Uncommitted, nothing +published. + +## Why + +The board binds to loopback but checked neither Host nor Origin (BRIEF §1.5). +Binding to loopback does not stop DNS rebinding. Once a page's name resolves to +127.0.0.1, the browser treats the board as that page's origin and sends the +page's name as Host. The page could then read `/api/board` (session text) or +POST `/api/reply`, which pastes into a live tmux pane. + +## Change + +Two files. `candidate.patch` is the whole diff against HEAD `ef0020ad`. + +- `packages/control-board/src/serve.mjs`: new exported `foreignRequest(req)`, + called first in the request handler. Refusal is 403 JSON `{error}`, sent + before any route runs, and the request body is drained. It refuses: + - a Host that does not parse, or whose parsed authority carries a nonempty + username or password, a path other than `/`, a nonempty query or a + fragment. The check reads the authority after `new URL()` normalizes it, + so empty delimiters pass: `127.0.0.1:PORT/`, `@127.0.0.1:PORT` and + `127.0.0.1:PORT?` are accepted as the same loopback authority. Raw Host + syntax is not validated (Rocko R1, low; see below); + - a Host whose name is not loopback (`isLoopbackHost`: localhost, `::1`, + 127/8) after the IPv6 brackets are stripped; + - a Host whose port is not the port the connection arrived on + (`req.socket.localPort`); + - any Origin header other than `http://`, including `null`. + + No Origin passes, because Node's fetch (the WebUI proxy) and curl send none. + No CORS headers are sent. The header comment says the same. +- `packages/control-board/tests/serve.test.mjs`: section 11, two tests. + +This matches `packages/webui/src/serve.mjs` lines 54–58 in effect. Where it +differs, the board is stricter or equal: +- the board compares with the socket's local port and the WebUI with + `server.address().port`, which is the same value for a single listener; +- the board refuses credentials or a path in Host, which the WebUI's parse + accepts; +- an empty `Origin:` header is refused by the board, while the WebUI's + truthiness test lets it through; +- an unparsable Host gets 403 from the board and 400 (`invalid URL`) from the + WebUI. + +The WebUI proxy reaches the board through Node fetch with Host +`127.0.0.1:` and no Origin, so it passes. The webui suite confirms that. + +A request with no Host never reaches the guard: Node's HTTP server answers +HTTP/1.1 without Host with 400 first. The test asserts that 400. + +## Evidence + +- Working tree: `node --test --test-concurrency=1 packages/control-board/tests/ + packages/webui/tests/ packages/seat/tests/`: 149/149 pass. +- Failing first, run in a scratch copy (`git archive HEAD` of control-board, + discord, seat, tools/tmux and package.json, plus the candidate test file), + not the live tree: + + | serve.mjs variant | refusal test | acceptance test | first failure | + |---|---|---|---| + | HEAD `ef0020ad` | fail | pass | GET /api/board, foreign Host: 200, expected 403 | + | candidate | pass | pass | none | + | no port check | fail | pass | GET /api/board, loopback Host, wrong port | + | no Origin check | fail | pass | GET /api/board, cross-origin Origin | + | no loopback-name check | fail | pass | GET /api/board, foreign Host | + | no credentials/path check | fail | pass | GET /api/board, Host with credentials | + | guard skipped for POST | fail | pass | POST /api/reply, foreign Host | + + Each mutation was confirmed applied with a grep count before the run. +- The refusal test also asserts that no refused request ran `agent-send` (the + capture file stays absent) or rescanned (`index.json` stays absent). It checks + that `/`, `/healthz` and POST `/api/seen` refuse a foreign Host. + +## Hashes (sha256) + +- `packages/control-board/src/serve.mjs`: d0a9bbed4c427690ded16432a1db40829a3c2e3362160aded1ba603d218b7f94 +- `packages/control-board/tests/serve.test.mjs`: e01d7bd9e51fe48c3baf07b21e4ea27ecdc537c9422645f4376a327f52292f2c +- `candidate.patch`: de9ff9421eb388e4db71f04de0429dcf59d0d3b07bf03e7d0be5a4f875c3c87e +- HEAD serve.mjs (baseline): d6ac9b7b9661e6c85e912612b847c14ed38b7596b7db0a86a692da202f2a9f9c + +## Review + +Rocko, 2026-09-26: approve the pinned candidate (`candidate.patch` +de9ff942, serve d0a9bbed, tests e01d7bd9). Report +`agents/rocko/work/board-guard-review-2026-09-26.md` 5a12f08e. + +One low, nonblocking finding: these notes (R1, 480c4577) said the check +refuses a Host carrying "credentials, a path, a query or a fragment". It +tests the normalized URL, so empty userinfo, an empty query and a root slash +pass (they remain loopback authorities on the listener's port). Rocko found +no foreign-origin bypass. The wording above is corrected; the source is +unchanged, so the approved hashes stand. Strict raw Host syntax is not +needed for this fix. If it is wanted later, reject raw `@ / ? #` before +parsing and add those boundary cases. + +## After commit + +The running board (7331) keeps the old code until Sage restarts it. The D3 +routes in CHAT-02 reuse `foreignRequest`. diff --git a/agents/dewey/work/board-guard/candidate.patch b/agents/dewey/work/board-guard/candidate.patch new file mode 100644 index 00000000..5f74c110 --- /dev/null +++ b/agents/dewey/work/board-guard/candidate.patch @@ -0,0 +1,151 @@ +diff --git a/packages/control-board/src/serve.mjs b/packages/control-board/src/serve.mjs +index 4426b325..aacf4f08 100644 +--- a/packages/control-board/src/serve.mjs ++++ b/packages/control-board/src/serve.mjs +@@ -14,6 +14,14 @@ + // site in the browser cannot set that header without a CORS preflight, and this + // server answers no preflight, so a stray page cannot flip marks. + // ++// Every route first checks Host and Origin (#1507). Binding to loopback does ++// not stop DNS rebinding: a page whose name now resolves to 127.0.0.1 reaches ++// this server as its own origin, with its own name as Host, and could read ++// /api/board or post /api/reply into a live pane. A Host that is not a loopback ++// name on this server's port, or any Origin other than this server's own, gets ++// 403 before anything else runs. Same check as packages/webui/src/serve.mjs. ++// No CORS headers are ever sent. ++// + // Every /api/board request rescans, so the page is never staler than its + // refresh timer. The scan rewrites the derived board files as a side effect. + +@@ -126,6 +134,21 @@ export function isLoopbackHost(host) { + return isIP(host) === 4 && host.startsWith("127."); + } + ++// Returns the refusal text for a request that did not come from this server's ++// own loopback origin, or null. Uses the port the connection arrived on. ++export function foreignRequest(req) { ++ let authority; ++ try { ++ authority = new URL(`http://${req.headers.host}`); ++ } catch { ++ return "non-local Host refused"; ++ } ++ const plain = !authority.username && !authority.password && authority.pathname === "/" && !authority.search && !authority.hash; ++ if (!plain || !isLoopbackHost(authority.hostname.replace(/^\[|\]$/g, "")) || Number(authority.port || 80) !== req.socket.localPort) return "non-local Host refused"; ++ if (req.headers.origin !== undefined && req.headers.origin !== `http://${req.headers.host}`) return "cross-origin request refused"; ++ return null; ++} ++ + export function loadPage(path = join(import.meta.dirname, "page.html")) { + return readFileSync(path, "utf8"); + } +@@ -134,6 +157,11 @@ export function loadPage(path = join(import.meta.dirname, "page.html")) { + export function createServer({ specs, boardDir, isAlive, now, seatsDir = null, discordDataRoot = null, page = loadPage(), isPidAlive, agentSend = DEFAULT_AGENT_SEND, exec = spawnSync }) { + const rescan = () => scan(specs, { boardDir, isAlive, now, seatsDir, isPidAlive, discordDataRoot }); + return createHttpServer((req, res) => { ++ const refused = foreignRequest(req); ++ if (refused) { ++ req.resume(); ++ return sendJson(res, 403, { error: refused }); ++ } + const url = new URL(req.url, "http://localhost"); + if (req.method === "POST" && url.pathname === "/api/reply") { + return readJsonBody(req) +diff --git a/packages/control-board/tests/serve.test.mjs b/packages/control-board/tests/serve.test.mjs +index 174099dd..1729d1e5 100644 +--- a/packages/control-board/tests/serve.test.mjs ++++ b/packages/control-board/tests/serve.test.mjs +@@ -14,6 +14,7 @@ import { tmpdir } from "node:os"; + import { join, resolve, basename } from "node:path"; + import { spawnSync, spawn } from "node:child_process"; + import { createServer as createNetServer } from "node:net"; ++import { request as httpRequest } from "node:http"; + import { ConfigError, markSeen } from "../src/scan.mjs"; + import { isLoopbackHost, startServer, DEFAULT_AGENT_SEND, REPLY_LIMIT, REPLY_TRAILER } from "../src/serve.mjs"; + import { writeRegistration, makeRegistration } from "../../seat/src/seat.mjs"; +@@ -942,3 +943,85 @@ test("page.html: the task cell and detail show who set a registered task via set + assert.equal(canReplyFn[0].includes("taskSetBy"), false); + assert.equal(html.match(/function replyControl\(rec\) \{[\s\S]*?\n \}/)[0].includes("taskSetBy"), false); + }); ++ ++// --------------------------------------------------------------------------- ++// 11. Host and Origin guard (#1507). Binding to loopback does not stop a page ++// whose DNS name was rebound to 127.0.0.1: the browser then treats this ++// server as that page's own origin and sends its own name as Host. Every ++// route refuses a Host that is not a loopback name on this port, and any ++// Origin other than this server's own. Same check as the WebUI server. ++// --------------------------------------------------------------------------- ++ ++// fetch() will not send a chosen Host header, so these requests use node:http. ++function rawRequest(port, { method = "GET", path = "/api/board", headers = {}, body = null }) { ++ return new Promise((resolvePromise, reject) => { ++ const req = httpRequest({ host: "127.0.0.1", port, method, path, headers, setHost: false }, (res) => { ++ const chunks = []; ++ res.on("data", (c) => chunks.push(c)); ++ res.on("end", () => resolvePromise({ status: res.statusCode, headers: res.headers, text: Buffer.concat(chunks).toString("utf8") })); ++ }); ++ req.on("error", reject); ++ req.end(body ?? undefined); ++ }); ++} ++ ++test("Host/Origin guard: GET /api/board and POST /api/reply refuse a foreign Host, a wrong port and a cross-origin Origin with 403 JSON, before any scan or send, and never send CORS headers", async () => { ++ const f = replyFixture(); ++ const server = await startServer({ host: "127.0.0.1", port: 0, specs: f.specs, boardDir: f.boardDir, seatsDir: f.seatsDir, isAlive: () => true, page: "", agentSend: f.agentSend }); ++ const port = server.address().port; ++ const own = `127.0.0.1:${port}`; ++ const reply = JSON.stringify({ agent: "proj/agent1", text: "rebound page" }); ++ const cases = [ ++ ["foreign Host", { host: `rebind.example:${port}` }, "non-local Host refused"], ++ ["loopback Host, wrong port", { host: `127.0.0.1:${port + 1}` }, "non-local Host refused"], ++ ["Host with credentials", { host: `x@${own}` }, "non-local Host refused"], ++ ["cross-origin Origin", { host: own, origin: "http://rebind.example" }, "cross-origin request refused"], ++ ["opaque Origin", { host: own, origin: "null" }, "cross-origin request refused"], ++ ]; ++ try { ++ for (const [label, headers, error] of cases) { ++ const board = await rawRequest(port, { headers }); ++ assert.equal(board.status, 403, `GET /api/board, ${label}`); ++ assert.deepEqual(JSON.parse(board.text), { error }, `GET /api/board, ${label}`); ++ assert.equal(board.headers["access-control-allow-origin"], undefined); ++ const posted = await rawRequest(port, { method: "POST", path: "/api/reply", headers: { ...headers, "content-type": "application/json" }, body: reply }); ++ assert.equal(posted.status, 403, `POST /api/reply, ${label}`); ++ assert.deepEqual(JSON.parse(posted.text), { error }, `POST /api/reply, ${label}`); ++ assert.equal(posted.headers["access-control-allow-origin"], undefined); ++ } ++ // Node's HTTP server answers an HTTP/1.1 request with no Host 400 before the handler runs. ++ assert.equal((await rawRequest(port, {})).status, 400, "GET /api/board, missing Host"); ++ assert.equal((await rawRequest(port, { method: "POST", path: "/api/reply", headers: { "content-type": "application/json" }, body: reply })).status, 400, "POST /api/reply, missing Host"); ++ assert.equal(existsSync(f.capture), false, "agent-send was never run"); ++ assert.equal(existsSync(join(f.boardDir, "index.json")), false, "no refused request rescanned the board"); ++ for (const path of ["/", "/healthz"]) { ++ assert.equal((await rawRequest(port, { path, headers: { host: `rebind.example:${port}` } })).status, 403, path); ++ } ++ // Refusals come first on the other routes too: no method or body handling. ++ assert.equal((await rawRequest(port, { method: "POST", path: "/api/seen", headers: { host: `rebind.example:${port}`, "content-type": "application/json" }, body: "{}" })).status, 403); ++ } finally { ++ await closeServer(server); ++ } ++}); ++ ++test("Host/Origin guard: loopback names on this port are accepted, with or without a same-origin Origin", async () => { ++ const f = replyFixture(); ++ const server = await startServer({ host: "127.0.0.1", port: 0, specs: f.specs, boardDir: f.boardDir, seatsDir: f.seatsDir, isAlive: () => true, page: "", agentSend: f.agentSend }); ++ const port = server.address().port; ++ delete process.env.FAKE_SEND_EXIT; ++ delete process.env.FAKE_SEND_STDERR; ++ try { ++ for (const host of [`127.0.0.1:${port}`, `localhost:${port}`, `LOCALHOST:${port}`, `[::1]:${port}`]) { ++ assert.equal((await rawRequest(port, { headers: { host } })).status, 200, host); ++ assert.equal((await rawRequest(port, { headers: { host, origin: `http://${host}` } })).status, 200, `${host} with its own Origin`); ++ } ++ // The board's own page posts with its own Origin; the WebUI proxy (Node fetch) sends none. ++ const own = `127.0.0.1:${port}`; ++ const posted = await rawRequest(port, { method: "POST", path: "/api/reply", headers: { host: own, origin: `http://${own}`, "content-type": "application/json" }, body: JSON.stringify({ agent: "proj/agent1", text: "same origin" }) }); ++ assert.equal(posted.status, 200); ++ assert.equal(JSON.parse(posted.text).delivered, true); ++ assert.equal((await fetch(`http://${own}/api/board`)).status, 200, "fetch without Origin"); ++ } finally { ++ await closeServer(server); ++ } ++}); diff --git a/agents/rocko/work/board-guard-review-2026-09-26.md b/agents/rocko/work/board-guard-review-2026-09-26.md new file mode 100644 index 00000000..1fdbde5c --- /dev/null +++ b/agents/rocko/work/board-guard-review-2026-09-26.md @@ -0,0 +1,73 @@ +# Control-board Host/Origin guard review + +Verdict: **approve**. Rocko for Sage and Dewey, 2026-09-26. + +Reviewed against base `ef0020ad`. Verified candidate pins: + +- NOTES.md: `480c4577d27612b7b394c26dbffcdd5953424d522cbcb148b965a91436c05489` +- candidate.patch: `de9ff9421eb388e4db71f04de0429dcf59d0d3b07bf03e7d0be5a4f875c3c87e` +- packages/control-board/src/serve.mjs: `d0a9bbed4c427690ded16432a1db40829a3c2e3362160aded1ba603d218b7f94` +- packages/control-board/tests/serve.test.mjs: `e01d7bd9e51fe48c3baf07b21e4ea27ecdc537c9422645f4376a327f52292f2c` + +No blocking findings. The guard runs before routing, scanning, JSON body +handling and pane delivery. Foreign DNS names cannot acquire access merely +by resolving to loopback. Missing Origin is an intentional allowance for +nonbrowser clients, not authentication; local clients remain trusted. + +## 1. Low, nonblocking — packet overstates raw Host syntax rejection + +`new URL()` normalizes input before `plain` is tested. Direct HTTP requests +with Host `127.0.0.1:PORT/`, `@127.0.0.1:PORT`, or +`127.0.0.1:PORT?` pass without Origin. A nonempty `/x` path and nonempty +credentials are refused. Empty userinfo and query delimiters disappear, +and the explicit root slash is indistinguishable from an implicit one. + +These remain parsed loopback authorities on the listener's port. I found +no foreign browser origin bypass from this; it does not block the security +fix. Suggested fix: describe the check as validating the parsed authority, +or reject raw userinfo/path/query/fragment delimiters before parsing if +strict Host syntax is intended. Add boundary cases when tightening it. + +## Requested boundaries + +Executed raw HTTP requests against an ephemeral candidate board server's +health route, without scanning or contacting live panes: + +| Input | Result | +|---|---| +| `[::1]:PORT`, expanded `[0:0:0:0:0:0:0:1]:PORT` | 200 | +| `LOCALHOST:PORT`, Origin absent | 200 | +| `LOCALHOST:PORT`, lowercase `http://localhost:PORT` Origin | 403 | +| `localhost.:PORT` | 403 | +| `127.0.0.1.:PORT` | 200, numeric URL canonicalization | +| `[::ffff:127.0.0.1]:PORT` | 403 | +| Empty or `null` Origin | 403 | +| Foreign Host, nonempty Host path | 403 | + +Exact raw Origin comparison is deliberately stricter than semantic URL +comparison: mixed-case raw Host plus canonical lowercase Origin is refused. +That fails closed; ordinary browser URLs are canonicalized. Trailing-dot +localhost is unsupported and fails closed. Expanded IPv6 works without +Origin or with its exact matching Origin; the candidate suite also covers +bracketed `::1` with matching Origin. These are Host-header tests over IPv4, +not a claim that this host's IPv6 listener was exercised. + +The WebUI source creates its own fetch headers and does not forward the +incoming Host or Origin. An executed WebUI-to-capture-server request with a +`http://localhost:PORT` board URL produced `Host: 127.0.0.1:PORT`, no Origin, +and matching socket.localPort. Its port check therefore works behind the +actual proxy. The existing real-board proxy fixture also passed. + +Refused bodies are precisely `{error: "non-local Host refused"}` or +`{error: "cross-origin request refused"}` (with JSON formatting/newline). +No board data or attacker-controlled header is interpolated. No CORS +allow-origin header is sent. Candidate tests assert both board/reply +refusals leave the scan index and agent-send capture absent. + +## Verification + +`timeout 40s node --test --test-concurrency=1 packages/control-board/tests/serve.test.mjs packages/webui/tests/serve.test.mjs` + +Result: 39 tests passed, zero failures, exit 0. Additional in-memory HTTP +boundary checks and proxy capture above passed. This report is the only +repository write for the review. No commit, live restart or live reply. diff --git a/docs/SESSIONS.md b/docs/SESSIONS.md index 636046f8..7214186f 100644 --- a/docs/SESSIONS.md +++ b/docs/SESSIONS.md @@ -387,3 +387,6 @@ are never rewritten or removed; corrections are new entries. 2026-09-26T20:23:09Z | Filbert (T3 Claude Code, thread 9cb9731e) | #1508 queue-as-data plan round 4; CHAT-02 brief R2 review | Plan section 8 answers Rocko r3 T1-T8 with an 8.17 map, re-pinned 14dccfd0 (base 124b6f9e); Sage confirmed the 8.15 choices and sent it to Rocko round 4. CHAT-02 BRIEF.md ed177bf6: revise (agents/filbert/work/chat-02-brief-r2-review-2026-09-26.md 0f0154b7): Console-leg overclaim, answer attribution by order, missing cursor/touched/in-place/registration/Host fixtures, return-flow tightening. No source edits, commit or push. 2026-09-26T20:27:43Z | Dewey (T3 Claude Code, thread 84fb346c) | Row 5 CHAT-02 brief review cycle | Filbert R2 revise, R3 approve (3b81a3f2), R4 nits confirmed: BRIEF.md 636b0fac approved (review file fcd54625). Replay evidence pinned in agents/dewey/work/chat-02/evidence/replay-0913. Board Host/Origin gap on existing routes and D2 post-B1 owner raised to Sage. Build gated on Jason's go. No source edits, commit or push. 2026-09-26T20:31:58Z | Filbert (T3 Claude Code, thread 9cb9731e) | #1508 queue-as-data plan round 5; CHAT-02 brief R3/R4 review | Plan section 8 answers Rocko r4 (fcb8933d) F1-F5 with an 8.18 map, re-pinned 7d61f18d: pre-commit queue guard plus shared-index reconcile (F1, checked in a scratch repo on git 2.55), witness-first unlocked reads with a locked recheck (F2), genesis-first bootstrap with an isolated validator given its base (F3), Gate G freshness from the launch and linear files (F4), caller op ids capped at 72 characters (F5); Jason's per-seat token ruling folded into 8.9. CHAT-02 BRIEF.md R3 3b81a3f2 approved, R4 636b0fac confirmed (review file fcd54625, with a correction entry for my R2 count). No source edits, commit or push. +- 2026-09-26T20:35:39Z · Dewey (T3, claude-opus-5-5) · #1507 board Host/Origin guard before CHAT-02: serve.mjs foreignRequest + 2 failing-first tests, 7 scratch variants, suites 149/149 · candidate ready for Rocko's review via Sage; uncommitted +2026-09-26T20:35:44Z | Dewey (T3 Claude Code, thread 84fb346c) | #1507 board Host/Origin guard before CHAT-02 | Correction: the entry above at 20:35:39Z used the wrong line format; this one restates it. packages/control-board serve.mjs foreignRequest (d0a9bbed) plus two failing-first tests in serve.test.mjs (e01d7bd9); HEAD fails at foreign Host, six mutations each fail, control-board/webui/seat suites 149/149. Packet agents/dewey/work/board-guard/NOTES.md for Rocko via Sage. No commit or push. +2026-09-26T20:38:14Z | Sage (T3 Claude Code, thread 1ef1e4f8) | Integration: board Host/Origin guard | Rocko approved de9ff942 (review 5a12f08e); Sage committed the guard with records after suites on an index export, pushed, and restarted the control board. Discord restart still held on 6b R2. diff --git a/docs/plans/DEFERRED.md b/docs/plans/DEFERRED.md index f325ae87..209bf2c1 100644 --- a/docs/plans/DEFERRED.md +++ b/docs/plans/DEFERRED.md @@ -113,12 +113,6 @@ at every gate. Started 2026-09-12 during the control board MVP. same path could run a board reply as a slash command. Belongs to CHAT-03I/B3. No tools/tmux change until that piece. Found by Dewey in the CHAT-02 brief evidence. (2026-09-26, #1507) -- **Control board checks the bind address but not Host or Origin.** After a - DNS rebind, a web page could read `/api/board` and POST `/api/reply`, which - pastes text into a live seat pane. The WebUI server already checks both. - Not tested against a browser, so the exposure is unproven. Dewey writes the - guard as its own small change before CHAT-02, and Rocko reviews it. - (2026-09-26, #1507, found by Dewey) ## Queue @@ -130,3 +124,4 @@ Moved to `docs/plans/QUEUE.md` on 2026-09-13. This file holds only gaps. - Relaunched seat shows the old last message (darkwing, Step 3 log, 2026-09-12): closed by #1512 relaunch activity, af4203ca. Filbert authored it; Darkwing and Dewey reviewed. The live board restart and Gate F remain separate. - Sage launcher hook uncommitted (2026-09-12): closed by the agents/sage launch files commit, Dewey review R1 revise, R2 approve (`agents/dewey/work/sage-launch-review-2026-09-26.md`), launcher test now covers sage. - Ledger counts T3 agent messages as human (2026-09-26, #1506): fixed in ef0020ad (Darkwing 6a, Filbert reviewed). `messageKind` now counts a T3 header as agent and the control-board sender as board. It changes no current count, because no source the ledger reads has a T3 header yet. That waits for the T3 thread source (Gate F). +- Control board checked the bind address but not Host or Origin (2026-09-26, #1507, found by Dewey): closed by the board guard commit. `foreignRequest` refuses a non-loopback Host, a wrong port, userinfo or a path in Host, and any foreign Origin on every route. Dewey authored it, and Rocko approved it (`agents/rocko/work/board-guard-review-2026-09-26.md`, 5a12f08e). Rocko's one low finding: URL normalization accepts a root slash, empty userinfo and an empty query in Host, so the notes' wording overstates what the raw-syntax check refuses. No bypass was found. diff --git a/packages/control-board/src/serve.mjs b/packages/control-board/src/serve.mjs index 4426b325..aacf4f08 100644 --- a/packages/control-board/src/serve.mjs +++ b/packages/control-board/src/serve.mjs @@ -14,6 +14,14 @@ // site in the browser cannot set that header without a CORS preflight, and this // server answers no preflight, so a stray page cannot flip marks. // +// Every route first checks Host and Origin (#1507). Binding to loopback does +// not stop DNS rebinding: a page whose name now resolves to 127.0.0.1 reaches +// this server as its own origin, with its own name as Host, and could read +// /api/board or post /api/reply into a live pane. A Host that is not a loopback +// name on this server's port, or any Origin other than this server's own, gets +// 403 before anything else runs. Same check as packages/webui/src/serve.mjs. +// No CORS headers are ever sent. +// // Every /api/board request rescans, so the page is never staler than its // refresh timer. The scan rewrites the derived board files as a side effect. @@ -126,6 +134,21 @@ export function isLoopbackHost(host) { return isIP(host) === 4 && host.startsWith("127."); } +// Returns the refusal text for a request that did not come from this server's +// own loopback origin, or null. Uses the port the connection arrived on. +export function foreignRequest(req) { + let authority; + try { + authority = new URL(`http://${req.headers.host}`); + } catch { + return "non-local Host refused"; + } + const plain = !authority.username && !authority.password && authority.pathname === "/" && !authority.search && !authority.hash; + if (!plain || !isLoopbackHost(authority.hostname.replace(/^\[|\]$/g, "")) || Number(authority.port || 80) !== req.socket.localPort) return "non-local Host refused"; + if (req.headers.origin !== undefined && req.headers.origin !== `http://${req.headers.host}`) return "cross-origin request refused"; + return null; +} + export function loadPage(path = join(import.meta.dirname, "page.html")) { return readFileSync(path, "utf8"); } @@ -134,6 +157,11 @@ export function loadPage(path = join(import.meta.dirname, "page.html")) { export function createServer({ specs, boardDir, isAlive, now, seatsDir = null, discordDataRoot = null, page = loadPage(), isPidAlive, agentSend = DEFAULT_AGENT_SEND, exec = spawnSync }) { const rescan = () => scan(specs, { boardDir, isAlive, now, seatsDir, isPidAlive, discordDataRoot }); return createHttpServer((req, res) => { + const refused = foreignRequest(req); + if (refused) { + req.resume(); + return sendJson(res, 403, { error: refused }); + } const url = new URL(req.url, "http://localhost"); if (req.method === "POST" && url.pathname === "/api/reply") { return readJsonBody(req) diff --git a/packages/control-board/tests/serve.test.mjs b/packages/control-board/tests/serve.test.mjs index 174099dd..1729d1e5 100644 --- a/packages/control-board/tests/serve.test.mjs +++ b/packages/control-board/tests/serve.test.mjs @@ -14,6 +14,7 @@ import { tmpdir } from "node:os"; import { join, resolve, basename } from "node:path"; import { spawnSync, spawn } from "node:child_process"; import { createServer as createNetServer } from "node:net"; +import { request as httpRequest } from "node:http"; import { ConfigError, markSeen } from "../src/scan.mjs"; import { isLoopbackHost, startServer, DEFAULT_AGENT_SEND, REPLY_LIMIT, REPLY_TRAILER } from "../src/serve.mjs"; import { writeRegistration, makeRegistration } from "../../seat/src/seat.mjs"; @@ -942,3 +943,85 @@ test("page.html: the task cell and detail show who set a registered task via set assert.equal(canReplyFn[0].includes("taskSetBy"), false); assert.equal(html.match(/function replyControl\(rec\) \{[\s\S]*?\n \}/)[0].includes("taskSetBy"), false); }); + +// --------------------------------------------------------------------------- +// 11. Host and Origin guard (#1507). Binding to loopback does not stop a page +// whose DNS name was rebound to 127.0.0.1: the browser then treats this +// server as that page's own origin and sends its own name as Host. Every +// route refuses a Host that is not a loopback name on this port, and any +// Origin other than this server's own. Same check as the WebUI server. +// --------------------------------------------------------------------------- + +// fetch() will not send a chosen Host header, so these requests use node:http. +function rawRequest(port, { method = "GET", path = "/api/board", headers = {}, body = null }) { + return new Promise((resolvePromise, reject) => { + const req = httpRequest({ host: "127.0.0.1", port, method, path, headers, setHost: false }, (res) => { + const chunks = []; + res.on("data", (c) => chunks.push(c)); + res.on("end", () => resolvePromise({ status: res.statusCode, headers: res.headers, text: Buffer.concat(chunks).toString("utf8") })); + }); + req.on("error", reject); + req.end(body ?? undefined); + }); +} + +test("Host/Origin guard: GET /api/board and POST /api/reply refuse a foreign Host, a wrong port and a cross-origin Origin with 403 JSON, before any scan or send, and never send CORS headers", async () => { + const f = replyFixture(); + const server = await startServer({ host: "127.0.0.1", port: 0, specs: f.specs, boardDir: f.boardDir, seatsDir: f.seatsDir, isAlive: () => true, page: "", agentSend: f.agentSend }); + const port = server.address().port; + const own = `127.0.0.1:${port}`; + const reply = JSON.stringify({ agent: "proj/agent1", text: "rebound page" }); + const cases = [ + ["foreign Host", { host: `rebind.example:${port}` }, "non-local Host refused"], + ["loopback Host, wrong port", { host: `127.0.0.1:${port + 1}` }, "non-local Host refused"], + ["Host with credentials", { host: `x@${own}` }, "non-local Host refused"], + ["cross-origin Origin", { host: own, origin: "http://rebind.example" }, "cross-origin request refused"], + ["opaque Origin", { host: own, origin: "null" }, "cross-origin request refused"], + ]; + try { + for (const [label, headers, error] of cases) { + const board = await rawRequest(port, { headers }); + assert.equal(board.status, 403, `GET /api/board, ${label}`); + assert.deepEqual(JSON.parse(board.text), { error }, `GET /api/board, ${label}`); + assert.equal(board.headers["access-control-allow-origin"], undefined); + const posted = await rawRequest(port, { method: "POST", path: "/api/reply", headers: { ...headers, "content-type": "application/json" }, body: reply }); + assert.equal(posted.status, 403, `POST /api/reply, ${label}`); + assert.deepEqual(JSON.parse(posted.text), { error }, `POST /api/reply, ${label}`); + assert.equal(posted.headers["access-control-allow-origin"], undefined); + } + // Node's HTTP server answers an HTTP/1.1 request with no Host 400 before the handler runs. + assert.equal((await rawRequest(port, {})).status, 400, "GET /api/board, missing Host"); + assert.equal((await rawRequest(port, { method: "POST", path: "/api/reply", headers: { "content-type": "application/json" }, body: reply })).status, 400, "POST /api/reply, missing Host"); + assert.equal(existsSync(f.capture), false, "agent-send was never run"); + assert.equal(existsSync(join(f.boardDir, "index.json")), false, "no refused request rescanned the board"); + for (const path of ["/", "/healthz"]) { + assert.equal((await rawRequest(port, { path, headers: { host: `rebind.example:${port}` } })).status, 403, path); + } + // Refusals come first on the other routes too: no method or body handling. + assert.equal((await rawRequest(port, { method: "POST", path: "/api/seen", headers: { host: `rebind.example:${port}`, "content-type": "application/json" }, body: "{}" })).status, 403); + } finally { + await closeServer(server); + } +}); + +test("Host/Origin guard: loopback names on this port are accepted, with or without a same-origin Origin", async () => { + const f = replyFixture(); + const server = await startServer({ host: "127.0.0.1", port: 0, specs: f.specs, boardDir: f.boardDir, seatsDir: f.seatsDir, isAlive: () => true, page: "", agentSend: f.agentSend }); + const port = server.address().port; + delete process.env.FAKE_SEND_EXIT; + delete process.env.FAKE_SEND_STDERR; + try { + for (const host of [`127.0.0.1:${port}`, `localhost:${port}`, `LOCALHOST:${port}`, `[::1]:${port}`]) { + assert.equal((await rawRequest(port, { headers: { host } })).status, 200, host); + assert.equal((await rawRequest(port, { headers: { host, origin: `http://${host}` } })).status, 200, `${host} with its own Origin`); + } + // The board's own page posts with its own Origin; the WebUI proxy (Node fetch) sends none. + const own = `127.0.0.1:${port}`; + const posted = await rawRequest(port, { method: "POST", path: "/api/reply", headers: { host: own, origin: `http://${own}`, "content-type": "application/json" }, body: JSON.stringify({ agent: "proj/agent1", text: "same origin" }) }); + assert.equal(posted.status, 200); + assert.equal(JSON.parse(posted.text).delivered, true); + assert.equal((await fetch(`http://${own}/api/board`)).status, 200, "fetch without Origin"); + } finally { + await closeServer(server); + } +});