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:
@@ -0,0 +1,73 @@
|
||||
# Control-board Host/Origin guard review
|
||||
|
||||
Verdict: **approve**. Rocko for Sage and Dewey, 2026-09-26.
|
||||
|
||||
Reviewed against base `ef0020ad`. Verified candidate pins:
|
||||
|
||||
- NOTES.md: `480c4577d27612b7b394c26dbffcdd5953424d522cbcb148b965a91436c05489`
|
||||
- candidate.patch: `de9ff9421eb388e4db71f04de0429dcf59d0d3b07bf03e7d0be5a4f875c3c87e`
|
||||
- packages/control-board/src/serve.mjs: `d0a9bbed4c427690ded16432a1db40829a3c2e3362160aded1ba603d218b7f94`
|
||||
- packages/control-board/tests/serve.test.mjs: `e01d7bd9e51fe48c3baf07b21e4ea27ecdc537c9422645f4376a327f52292f2c`
|
||||
|
||||
No blocking findings. The guard runs before routing, scanning, JSON body
|
||||
handling and pane delivery. Foreign DNS names cannot acquire access merely
|
||||
by resolving to loopback. Missing Origin is an intentional allowance for
|
||||
nonbrowser clients, not authentication; local clients remain trusted.
|
||||
|
||||
## 1. Low, nonblocking — packet overstates raw Host syntax rejection
|
||||
|
||||
`new URL()` normalizes input before `plain` is tested. Direct HTTP requests
|
||||
with Host `127.0.0.1:PORT/`, `@127.0.0.1:PORT`, or
|
||||
`127.0.0.1:PORT?` pass without Origin. A nonempty `/x` path and nonempty
|
||||
credentials are refused. Empty userinfo and query delimiters disappear,
|
||||
and the explicit root slash is indistinguishable from an implicit one.
|
||||
|
||||
These remain parsed loopback authorities on the listener's port. I found
|
||||
no foreign browser origin bypass from this; it does not block the security
|
||||
fix. Suggested fix: describe the check as validating the parsed authority,
|
||||
or reject raw userinfo/path/query/fragment delimiters before parsing if
|
||||
strict Host syntax is intended. Add boundary cases when tightening it.
|
||||
|
||||
## Requested boundaries
|
||||
|
||||
Executed raw HTTP requests against an ephemeral candidate board server's
|
||||
health route, without scanning or contacting live panes:
|
||||
|
||||
| Input | Result |
|
||||
|---|---|
|
||||
| `[::1]:PORT`, expanded `[0:0:0:0:0:0:0:1]:PORT` | 200 |
|
||||
| `LOCALHOST:PORT`, Origin absent | 200 |
|
||||
| `LOCALHOST:PORT`, lowercase `http://localhost:PORT` Origin | 403 |
|
||||
| `localhost.:PORT` | 403 |
|
||||
| `127.0.0.1.:PORT` | 200, numeric URL canonicalization |
|
||||
| `[::ffff:127.0.0.1]:PORT` | 403 |
|
||||
| Empty or `null` Origin | 403 |
|
||||
| Foreign Host, nonempty Host path | 403 |
|
||||
|
||||
Exact raw Origin comparison is deliberately stricter than semantic URL
|
||||
comparison: mixed-case raw Host plus canonical lowercase Origin is refused.
|
||||
That fails closed; ordinary browser URLs are canonicalized. Trailing-dot
|
||||
localhost is unsupported and fails closed. Expanded IPv6 works without
|
||||
Origin or with its exact matching Origin; the candidate suite also covers
|
||||
bracketed `::1` with matching Origin. These are Host-header tests over IPv4,
|
||||
not a claim that this host's IPv6 listener was exercised.
|
||||
|
||||
The WebUI source creates its own fetch headers and does not forward the
|
||||
incoming Host or Origin. An executed WebUI-to-capture-server request with a
|
||||
`http://localhost:PORT` board URL produced `Host: 127.0.0.1:PORT`, no Origin,
|
||||
and matching socket.localPort. Its port check therefore works behind the
|
||||
actual proxy. The existing real-board proxy fixture also passed.
|
||||
|
||||
Refused bodies are precisely `{error: "non-local Host refused"}` or
|
||||
`{error: "cross-origin request refused"}` (with JSON formatting/newline).
|
||||
No board data or attacker-controlled header is interpolated. No CORS
|
||||
allow-origin header is sent. Candidate tests assert both board/reply
|
||||
refusals leave the scan index and agent-send capture absent.
|
||||
|
||||
## Verification
|
||||
|
||||
`timeout 40s node --test --test-concurrency=1 packages/control-board/tests/serve.test.mjs packages/webui/tests/serve.test.mjs`
|
||||
|
||||
Result: 39 tests passed, zero failures, exit 0. Additional in-memory HTTP
|
||||
boundary checks and proxy capture above passed. This report is the only
|
||||
repository write for the review. No commit, live restart or live reply.
|
||||
Reference in New Issue
Block a user