Files
stack/agents/dewey/work/board-guard/NOTES.md
T
jason.woltjeandClaude Opus 5.5 d1629d610d 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]>
2026-09-26 15:40:34 -05:00

106 lines
5.2 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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`.