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

5.2 KiB
Raw Blame History

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.