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]>
74 lines
3.7 KiB
Markdown
74 lines
3.7 KiB
Markdown
# 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.
|