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