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]>
106 lines
5.2 KiB
Markdown
106 lines
5.2 KiB
Markdown
# 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`.
|