Skip to content

fix(security): host-header allowlist on sensing-server HTTP + WS (DNS rebinding)#580

Merged
ruvnet merged 1 commit into
ruvnet:mainfrom
aaronjmars:security/host-header-validation
May 17, 2026
Merged

fix(security): host-header allowlist on sensing-server HTTP + WS (DNS rebinding)#580
ruvnet merged 1 commit into
ruvnet:mainfrom
aaronjmars:security/host-header-validation

Conversation

@aaronjmars
Copy link
Copy Markdown
Contributor

Summary

The sensing-server (v2/crates/wifi-densepose-sensing-server) binds to 127.0.0.1 by default with no Host-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 to 127.0.0.1 after 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_TOKEN is unset — the documented LAN-mode default from #443 / PR #547 — any website the user visits during a sensing session can:

  • Read live human pose (/api/v1/pose/current, /api/v1/pose/zones/summary).
  • Stream vital signs continuously (/api/v1/vital-signs, /api/v1/edge-vitals, /ws/sensing, /ws/introspection).
  • Drive state-mutating POSTs: /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.

CorsLayer is 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 a Host-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_validation middleware that pins the Host header to a configurable allowlist. Default behaviour preserves every existing deployment shape:

  • Loopback names (localhost, 127.0.0.1, [::1], each with or without :PORT) are always in the allowlist — a fresh 127.0.0.1 deployment keeps working from the local browser with no configuration change.
  • Operators who bind to a routable address (--bind-addr 0.0.0.0 or a LAN IP) extend the allowlist 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 becomes a no-op).

Wired into both the dedicated WS router and the main HTTP router, so /ws/sensing on either listener is covered.

Rejection responses:

  • Missing Host header → 400 Bad Request (HTTP/1.1 protocol violation).
  • Host present 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-host and SENSING_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 a Host/Origin axis next to the bearer-auth and cache-control layers.

  • Severity: high
  • Categories: DNS rebinding / Host-header validation / Origin validation

Verification

cd v2
cargo test -p wifi-densepose-sensing-server --no-default-features
# Finished `test` profile [unoptimized + debuginfo]
# running 220 tests
# test result: ok. 220 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

New tests in host_validation::tests (13 cases) cover:

  • Loopback defaults accept every default name with arbitrary :PORT.
  • Foreign hosts (evil.com, 127.0.0.1.evil.com, 192.168.1.10, sensing.local) rejected with 421 under loopback_only().
  • Missing / empty Host header behaviour.
  • Rejection extends to /health and /ws/* (rebinding doesn't care which route is hit — it cares about what bytes flow back).
  • --allowed-host + SENSING_ALLOWED_HOSTS merge into the loopback set (extras don't displace defaults).
  • Disabled-allowlist escape hatch is a true no-op.
  • Case-insensitive host comparison.
  • IPv4 / bracketed-IPv6 / bare-hostname strip_port handling.

Compatibility

  • No new dependencies (uses existing axum, tower).
  • No default-behaviour change for 127.0.0.1 bind: local browsers reach the server exactly as before.
  • For --bind-addr 0.0.0.0 operators: existing deployments must add --allowed-host <hostname> (or SENSING_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 allowing 0.0.0.0 deployments 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-validation if 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.

… 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`.
Copy link
Copy Markdown
Owner

@ruvnet ruvnet left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 the strip_port() helper handles Host: 127.0.0.1:8080 against a port-less allowlist entry correctly.
  • strip_port() IPv6 path looks right: bracketed addresses ([::1], [::1]:8080) match the close-bracket-then-optional-:PORT pattern, bare IPv4 matches rfind(':'). The 13 unit cases cover the edge surface.
  • 421 Misdirected Request is 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 Request for missing Host is correct (HTTP/1.1 requires it; HTTP/2's :authority synthesises it).
  • Wired into both routers (HTTP and WS). This is the bit most rebinding fixes get wrong by patching only one.
  • --disable-host-validation reverse-proxy escape hatch is the right ergonomics.
  • Comparison is case-insensitive on the host portion (is_allowed), good.
  • No new deps. HostAllowlist is Clone + 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.md deployment section, especially for the --bind-addr 0.0.0.0 case.
  • The /ws/* paths are now host-validated but still unauthenticated when bearer mode is on — that's a documented design (see bearer_auth.rs:11-13), but worth surfacing in the threat-model docstring on top of host_validation.rs so the next reviewer doesn't think the WS auth gap is a separate finding.

CWE-346 / CWE-350 classification is accurate. Merging.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants