fix(board): refuse foreign Host and Origin on every control-board route (#1507)

After a DNS rebind, a web page could read /api/board and POST /api/reply,
which pastes into a live seat pane. foreignRequest() now runs first and
returns 403 for a non-loopback Host, a wrong port, userinfo or a path in
Host, or any Origin other than http://<Host>. A missing Origin still passes,
which covers the WebUI proxy. Dewey authored it; Rocko approved de9ff942
(review 5a12f08e) with one low wording finding, now fixed in the notes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-09-26 15:40:34 -05:00
co-authored by Claude Opus 5.5
parent 401cc850bb
commit d1629d610d
8 changed files with 461 additions and 6 deletions
+17
View File
@@ -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://<Host>`, 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:<port>, 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.
+105
View File
@@ -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://<Host>`, 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:<port>` 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`.
@@ -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: "<html></html>", 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: "<html></html>", 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);
+ }
+});
@@ -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.
+3
View File
@@ -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.
+1 -6
View File
@@ -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.
+28
View File
@@ -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)
@@ -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: "<html></html>", 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: "<html></html>", 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);
}
});