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