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

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 #443/#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`.

Co-authored-by: Aeon <aeon@aaronjmars.com>
This commit is contained in:
@aaronjmars
2026-05-17 17:27:00 -04:00
committed by GitHub
parent 8a155e07ec
commit 3685d16a49
3 changed files with 493 additions and 0 deletions
@@ -95,6 +95,21 @@ struct Args {
#[arg(long, default_value = "127.0.0.1", env = "SENSING_BIND_ADDR")]
bind_addr: String,
/// Additional hostname (with or without `:PORT`) to permit in the `Host`
/// header — defends loopback-bound deployments against DNS rebinding.
/// Loopback names (`localhost`, `127.0.0.1`, `[::1]`) are always permitted
/// implicitly. Pass multiple times to add several entries. Comma-separated
/// values are also accepted via the `SENSING_ALLOWED_HOSTS` env var.
#[arg(long = "allowed-host", value_name = "HOST")]
allowed_hosts: Vec<String>,
/// Disable Host-header validation entirely. Use only when the server sits
/// behind a reverse proxy that already canonicalises `Host` (e.g. nginx
/// `proxy_set_header Host`) — bare deployments stay vulnerable to DNS
/// rebinding without it.
#[arg(long)]
disable_host_validation: bool,
/// Data source: auto, wifi, esp32, simulate
#[arg(long, default_value = "auto")]
source: String,
@@ -4969,11 +4984,39 @@ async fn main() {
);
}
// DNS-rebinding defense: validate the `Host` header against an allowlist
// before any handler runs. Default is loopback-only (`localhost`,
// `127.0.0.1`, `[::1]`, each with or without a port). Operators extend
// the set via `--allowed-host` flags or the `SENSING_ALLOWED_HOSTS` env
// var; `--disable-host-validation` opts out entirely for reverse-proxy
// setups that already canonicalise `Host`.
let host_allowlist = if args.disable_host_validation {
warn!(
"Host-header validation DISABLED — server is reachable via any Host. \
Only use this behind a reverse proxy that pins Host."
);
wifi_densepose_sensing_server::host_validation::HostAllowlist::disabled()
} else {
let allowlist =
wifi_densepose_sensing_server::host_validation::HostAllowlist::from_cli_and_env(
args.allowed_hosts.iter().cloned(),
);
info!(
"Host-header validation ON ({} entries; loopback names always included)",
allowlist.entries_for_test().len()
);
allowlist
};
// WebSocket server on dedicated port (8765)
let ws_state = state.clone();
let ws_app = Router::new()
.route("/ws/sensing", get(ws_sensing_handler))
.route("/health", get(health))
.layer(axum::middleware::from_fn_with_state(
host_allowlist.clone(),
wifi_densepose_sensing_server::host_validation::require_allowed_host,
))
.with_state(ws_state);
let ws_addr = SocketAddr::from((bind_ip, args.ws_port));
@@ -5066,6 +5109,14 @@ async fn main() {
bearer_auth_state.clone(),
wifi_densepose_sensing_server::bearer_auth::require_bearer,
))
// DNS-rebinding defense: applied last so it runs first on the request
// path (axum layers run outermost-in). Rejects requests whose `Host`
// header is not in the allowlist before any handler — including
// `/health` and `/ws/*` — observes the body.
.layer(axum::middleware::from_fn_with_state(
host_allowlist.clone(),
wifi_densepose_sensing_server::host_validation::require_allowed_host,
))
.with_state(state.clone());
let http_addr = SocketAddr::from((bind_ip, args.http_port));