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,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