mirror of
https://github.com/ruvnet/RuView
synced 2026-08-10 20:31:42 +00:00
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:
@@ -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));
|
||||
|
||||
Reference in New Issue
Block a user