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:
@@ -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);
|
||||
+ }
|
||||
+});
|
||||
Reference in New Issue
Block a user