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); + } +});