fix(security): host-header allowlist on sensing-server HTTP + WS (DNS rebinding)#580
Conversation
… rebinding) The sensing-server binds to 127.0.0.1 by default with no `Host` header validation on either router. A foreign page can lower its DNS TTL, re-resolve to 127.0.0.1 after the browser has accepted the origin, and then read live pose + vital signs from /api/v1/* + /ws/sensing as same-origin against the attacker's hostname. When `RUVIEW_API_TOKEN` is unset (the documented LAN-mode default from ruvnet#443/ruvnet#547) the attacker can also drive state-mutating POSTs (recording/start, models/load, adaptive/train, calibration/start, sona/activate). Defense: a small `host_validation` axum middleware that pins the `Host` header to a configurable allowlist. The loopback names (`localhost`, `127.0.0.1`, `[::1]`, each with or without a port) are always in the set, so default 127.0.0.1 deployments keep working from the local browser without any configuration change. Operators who bind to a routable address extend the set with one or more `--allowed-host` flags or a comma-separated `SENSING_ALLOWED_HOSTS` env var. Reverse-proxy deployments that already canonicalise `Host` opt out with `--disable-host-validation`. The layer is wired into both the dedicated WebSocket router on `--ws-port` (8765) and the main HTTP router on `--http-port` (8080), so /ws/sensing on either listener is covered. Rejection responses are `421 Misdirected Request` (the correct status for a request that arrived at a server that does not consider the supplied `Host` authoritative); missing `Host` is `400 Bad Request`. CWE-346 (Origin Validation Error), CWE-350 (Reliance on Reverse DNS). Severity: high. Tests: 13 new unit tests on the middleware (loopback defaults, case-insensitivity, IPv6 bracketing, port stripping, env-var/CLI merge, foreign-host rejection on /health + /ws/*, disabled-allowlist escape hatch). Full suite: 220/220 pass under `cargo test -p wifi-densepose-sensing-server --no-default-features`.
ruvnet
left a comment
There was a problem hiding this comment.
Read through the diff end-to-end — this is excellent security work. Approving for merge.
On the threat model: the analysis is correct. With RUVIEW_API_TOKEN unset (the documented LAN-mode default from #443/PR #547) the entire /api/v1/* surface is reachable as same-origin from any page the user visits, because no Host/Origin axis is checked. CorsLayer is not in the crate at all, so the implicit loopback protection that saves many other agent-server projects is absent here. The read-side (/ws/sensing, /ws/introspection, /health, /ui/*) is reachable even when bearer auth is on, because bearer_auth.rs:11-13 explicitly excludes those paths. The example endpoint set in the PR body matches reality.
On the implementation:
HostAllowlist::loopback_only()covers the right defaults (localhost,127.0.0.1,[::1]) and combined with thestrip_port()helper handlesHost: 127.0.0.1:8080against a port-less allowlist entry correctly.strip_port()IPv6 path looks right: bracketed addresses ([::1],[::1]:8080) match the close-bracket-then-optional-:PORTpattern, bare IPv4 matchesrfind(':'). The 13 unit cases cover the edge surface.421 Misdirected Requestis the semantically correct rejection — it tells the client "this server is not authoritative for the supplied Host", which is exactly the rebinding case. Better than the 400/403/404 alternatives.400 Bad Requestfor missingHostis correct (HTTP/1.1 requires it; HTTP/2's:authoritysynthesises it).- Wired into both routers (HTTP and WS). This is the bit most rebinding fixes get wrong by patching only one.
--disable-host-validationreverse-proxy escape hatch is the right ergonomics.- Comparison is case-insensitive on the host portion (
is_allowed), good. - No new deps.
HostAllowlistisClone+ cheap (Arc<HashSet>). - 13 new tests pass; 220 total — no regressions.
On the default-on-with-loopback behaviour: I agree with the call. Operators who bind to 0.0.0.0 will see 421 until they add --allowed-host <name> or SENSING_ALLOWED_HOSTS=<name>, but the rejection body and CLI help string explain it, and the alternative ("silently allow new 0.0.0.0 deployments to remain rebinding-vulnerable") is worse. Worth a CHANGELOG entry under Breaking Changes for the v0.X release that ships this.
Minor follow-ups (not blocking):
- Consider mentioning the new flags in the
docs/user-guide.mddeployment section, especially for the--bind-addr 0.0.0.0case. - The
/ws/*paths are now host-validated but still unauthenticated when bearer mode is on — that's a documented design (seebearer_auth.rs:11-13), but worth surfacing in the threat-model docstring on top ofhost_validation.rsso the next reviewer doesn't think the WS auth gap is a separate finding.
CWE-346 / CWE-350 classification is accurate. Merging.
Summary
The sensing-server (
v2/crates/wifi-densepose-sensing-server) binds to127.0.0.1by default with noHost-header validation on either the dedicated WebSocket router (--ws-port, 8765) or the main HTTP router (--http-port, 8080). A foreign page can lower its DNS TTL, re-resolve to127.0.0.1after the browser has accepted the origin, and then drive the local API as same-origin against the attacker's hostname (DNS rebinding).Impact
When
RUVIEW_API_TOKENis unset — the documented LAN-mode default from #443 / PR #547 — any website the user visits during a sensing session can:/api/v1/pose/current,/api/v1/pose/zones/summary)./api/v1/vital-signs,/api/v1/edge-vitals,/ws/sensing,/ws/introspection)./api/v1/recording/start,/api/v1/models/load,/api/v1/models/lora/activate,/api/v1/adaptive/train,/api/v1/calibration/start,/api/v1/sona/activate.When bearer auth is enabled (
RUVIEW_API_TOKEN=…) the/api/v1/*mutators are protected, but/health*//ws/sensing//ws/introspection//ui/*are intentionally not gated by the bearer middleware — so the read-side (live pose + vitals over WS, UI HTML carrying tokens via the local origin) is still reachable under rebinding.CorsLayeris not in use anywhere in the crate, so the cross-origin lock that would normally save loopback agent servers is absent.Location
v2/crates/wifi-densepose-sensing-server/src/main.rs— both routers built without aHost-header layer (HTTP router around L5003, WS router around L4988 prior to this PR).v2/crates/wifi-densepose-sensing-server/src/bearer_auth.rs— confirms the read-side and WS paths are unauthenticated by design even when auth is on.Fix
A small
host_validationmiddleware that pins theHostheader to a configurable allowlist. Default behaviour preserves every existing deployment shape:localhost,127.0.0.1,[::1], each with or without:PORT) are always in the allowlist — a fresh127.0.0.1deployment keeps working from the local browser with no configuration change.--bind-addr 0.0.0.0or a LAN IP) extend the allowlist with one or more--allowed-hostflags or a comma-separatedSENSING_ALLOWED_HOSTS=env var.Hostopt out with--disable-host-validation(the layer becomes a no-op).Wired into both the dedicated WS router and the main HTTP router, so
/ws/sensingon either listener is covered.Rejection responses:
Hostheader →400 Bad Request(HTTP/1.1 protocol violation).Hostpresent but not in the allowlist →421 Misdirected Request(the correct semantics for "this server is not authoritative for the supplied Host"), with a body that points the operator at--allowed-hostandSENSING_ALLOWED_HOSTS.CWE-346 (Origin Validation Error), CWE-350 (Reliance on Reverse DNS Resolution).
Detected by
Aeon + semgrep (
p/security-audit+p/owasp-top-ten+p/secrets) + manual review.Scanners returned clean on this class — the finding is from reading the two
Router::new()chains end-to-end and noticing the absence of aHost/Originaxis next to the bearer-auth and cache-control layers.Verification
New tests in
host_validation::tests(13 cases) cover::PORT.evil.com,127.0.0.1.evil.com,192.168.1.10,sensing.local) rejected with421underloopback_only().Hostheader behaviour./healthand/ws/*(rebinding doesn't care which route is hit — it cares about what bytes flow back).--allowed-host+SENSING_ALLOWED_HOSTSmerge into the loopback set (extras don't displace defaults).strip_porthandling.Compatibility
axum,tower).127.0.0.1bind: local browsers reach the server exactly as before.--bind-addr 0.0.0.0operators: existing deployments must add--allowed-host <hostname>(orSENSING_ALLOWED_HOSTS=<hostname>) to keep reaching the server from the LAN-facing name, otherwise requests will 421. The CLI help string and the rejection body explain this. The alternative — silently allowing0.0.0.0deployments to keep being rebinding-vulnerable — felt worse than asking operators to add one flag.Happy to split the WS-router half and the HTTP-router half if you prefer a smaller first PR, or to swap the default to
--disable-host-validationif you'd rather keep the new layer opt-in for a release first. I went with default-on because the LAN-mode posture from #443 already documents loopback as the canonical deployment and that path is exactly where rebinding bites.Filed by Aeon.