From fabb0c8233f675b8840aac03d4fb9212523901ad Mon Sep 17 00:00:00 2001 From: Mathew Beane Date: Fri, 2 Oct 2026 22:26:26 -0400 Subject: [PATCH] fix(sensing-server): refuse a routable HTTP bind when API auth is off The UDP receiver already refuses a routable bind without a source allowlist (ADR-296), and the Docker entrypoint refuses a routable bind without RUVIEW_API_TOKEN (#864). Running the binary directly with --bind-addr 0.0.0.0 and no auth only logged that auth was off. The server now exits at startup when the HTTP/WebSocket bind is not loopback and API auth (RUVIEW_API_TOKEN or RUVIEW_OAUTH_ISSUER) is off, unless RUVIEW_ALLOW_UNAUTHENTICATED is set, in which case it starts and logs a warning. Loopback binds are unaffected. The conditions match the entrypoint's, so a container that starts today still starts. Co-Authored-By: claude-flow --- .../wifi-densepose-sensing-server/SECURITY.md | 18 +++ .../src/http_bind.rs | 125 ++++++++++++++++++ .../wifi-densepose-sensing-server/src/lib.rs | 2 + .../wifi-densepose-sensing-server/src/main.rs | 27 ++++ 4 files changed, 172 insertions(+) create mode 100644 v2/crates/wifi-densepose-sensing-server/src/http_bind.rs diff --git a/v2/crates/wifi-densepose-sensing-server/SECURITY.md b/v2/crates/wifi-densepose-sensing-server/SECURITY.md index f5c42775b..00cd2a0ee 100644 --- a/v2/crates/wifi-densepose-sensing-server/SECURITY.md +++ b/v2/crates/wifi-densepose-sensing-server/SECURITY.md @@ -1,5 +1,23 @@ # Security notes — wifi-densepose-sensing-server +## HTTP and WebSocket API bind + +`--bind-addr` (env `SENSING_BIND_ADDR`) defaults to `127.0.0.1`. The server +refuses to start when the bind is routable (`0.0.0.0`, `::` or a LAN IP) and +API auth is off, because `/api/v1/*` and `/ws/sensing` would then be readable +by anyone who can reach the host. Pick one: + +- **Enable auth.** Set `RUVIEW_API_TOKEN=` (or `RUVIEW_OAUTH_ISSUER`). +- **Stay on loopback.** Use `--bind-addr 127.0.0.1` and put a reverse proxy in + front for remote access. +- **Opt in explicitly.** Set `RUVIEW_ALLOW_UNAUTHENTICATED=1` (`true`, `yes` + and `on` also work). The server starts and logs a warning. Only do this on an + isolated, trusted network. + +This is the same rule the Docker entrypoint already applies (#864), so a +container that starts today keeps starting. The refusal exits with status 1; +the entrypoint's own check exits with 64 before the binary runs. + ## UDP CSI data plane (ADR-296) The sensing server ingests CSI/radar frames over UDP from ESP32, MediaTek, diff --git a/v2/crates/wifi-densepose-sensing-server/src/http_bind.rs b/v2/crates/wifi-densepose-sensing-server/src/http_bind.rs new file mode 100644 index 000000000..bdd3e3d11 --- /dev/null +++ b/v2/crates/wifi-densepose-sensing-server/src/http_bind.rs @@ -0,0 +1,125 @@ +//! HTTP/WebSocket bind check: refuse a routable bind with API auth off. +//! +//! Same rule the Docker entrypoint applies (#864) and the UDP receiver applies +//! to its own bind (ADR-296, [`crate::udp_bind::decide_udp_bind`]): loopback is +//! always fine, a routable bind needs either API auth (`RUVIEW_API_TOKEN` or +//! `RUVIEW_OAUTH_ISSUER`) or the explicit `RUVIEW_ALLOW_UNAUTHENTICATED` opt-in. + +use std::net::IpAddr; + +/// Explicit opt-in to serve the API unauthenticated on a routable address. +/// Same variable the Docker entrypoint reads. +pub const ALLOW_UNAUTHENTICATED_ENV: &str = "RUVIEW_ALLOW_UNAUTHENTICATED"; + +/// Outcome of [`decide_http_bind`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum HttpBindDecision { + /// Bind is loopback-only (`127.0.0.0/8` / `::1`); not reachable off-host. + Loopback, + /// Bind is routable and API auth is on. + RoutableAuthenticated, + /// Bind is routable with API auth off, accepted via + /// `RUVIEW_ALLOW_UNAUTHENTICATED`. + RoutableUnauthenticated, +} + +/// Whether `RUVIEW_ALLOW_UNAUTHENTICATED` holds an opt-in value. Accepts `1`, +/// `true`, `yes` and `on` in any case; anything else, including unset, is off. +pub fn allow_unauthenticated_opt_in(value: Option<&str>) -> bool { + value.is_some_and(|v| { + matches!( + v.trim().to_ascii_lowercase().as_str(), + "1" | "true" | "yes" | "on" + ) + }) +} + +/// Decide whether the HTTP/WebSocket listeners may bind to `bind`. +/// +/// Pure and socket-free so it can be tested without binding. +pub fn decide_http_bind( + bind: IpAddr, + auth_enabled: bool, + allow_unauthenticated: bool, +) -> Result { + if bind.is_loopback() { + return Ok(HttpBindDecision::Loopback); + } + if auth_enabled { + return Ok(HttpBindDecision::RoutableAuthenticated); + } + if allow_unauthenticated { + return Ok(HttpBindDecision::RoutableUnauthenticated); + } + Err(format!( + "Refusing to serve the API on routable address {bind} with API auth off: \ + /api/v1/* and /ws/sensing would be readable by anyone who can reach this \ + host. Set RUVIEW_API_TOKEN= (or RUVIEW_OAUTH_ISSUER) to enable \ + auth, use --bind-addr 127.0.0.1, or set {ALLOW_UNAUTHENTICATED_ENV}=1 on \ + a trusted network only." + )) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::net::{Ipv4Addr, Ipv6Addr}; + + fn v4(a: u8, b: u8, c: u8, d: u8) -> IpAddr { + IpAddr::V4(Ipv4Addr::new(a, b, c, d)) + } + + #[test] + fn loopback_starts_without_auth() { + for ip in [ + IpAddr::V4(Ipv4Addr::LOCALHOST), + v4(127, 0, 1, 1), + IpAddr::V6(Ipv6Addr::LOCALHOST), + ] { + assert_eq!( + decide_http_bind(ip, false, false), + Ok(HttpBindDecision::Loopback) + ); + } + } + + #[test] + fn routable_without_auth_is_refused() { + for ip in [ + IpAddr::V4(Ipv4Addr::UNSPECIFIED), + IpAddr::V6(Ipv6Addr::UNSPECIFIED), + v4(192, 168, 1, 10), + ] { + let err = decide_http_bind(ip, false, false).unwrap_err(); + assert!(err.contains("RUVIEW_API_TOKEN"), "{err}"); + assert!(err.contains(ALLOW_UNAUTHENTICATED_ENV), "{err}"); + } + } + + #[test] + fn routable_with_auth_starts() { + assert_eq!( + decide_http_bind(v4(0, 0, 0, 0), true, false), + Ok(HttpBindDecision::RoutableAuthenticated) + ); + } + + #[test] + fn routable_with_opt_in_starts_unauthenticated() { + assert_eq!( + decide_http_bind(v4(0, 0, 0, 0), false, true), + Ok(HttpBindDecision::RoutableUnauthenticated) + ); + } + + #[test] + fn opt_in_accepts_entrypoint_spellings_only() { + for v in ["1", "true", "TRUE", "yes", "On", " 1 "] { + assert!(allow_unauthenticated_opt_in(Some(v)), "{v:?}"); + } + for v in ["", "0", "false", "no", "off", "2", "y"] { + assert!(!allow_unauthenticated_opt_in(Some(v)), "{v:?}"); + } + assert!(!allow_unauthenticated_opt_in(None)); + } +} diff --git a/v2/crates/wifi-densepose-sensing-server/src/lib.rs b/v2/crates/wifi-densepose-sensing-server/src/lib.rs index 0cf0214f1..589c39f90 100644 --- a/v2/crates/wifi-densepose-sensing-server/src/lib.rs +++ b/v2/crates/wifi-densepose-sensing-server/src/lib.rs @@ -18,6 +18,8 @@ pub mod discovery; pub mod edge_registry; pub mod error_response; pub mod host_validation; +/// #864/ADR-296: refuse a routable HTTP bind with API auth off. +pub mod http_bind; /// ADR-297: per-node vs. fused room inference, with deterministic fusion. pub mod inference; pub mod introspection; diff --git a/v2/crates/wifi-densepose-sensing-server/src/main.rs b/v2/crates/wifi-densepose-sensing-server/src/main.rs index 180ae6f53..86376ce6d 100644 --- a/v2/crates/wifi-densepose-sensing-server/src/main.rs +++ b/v2/crates/wifi-densepose-sensing-server/src/main.rs @@ -12386,6 +12386,33 @@ async fn main() { ); } + // #864/ADR-296: a routable bind with auth off is refused, the same rule + // the Docker entrypoint and the UDP receiver apply. Loopback is unaffected. + { + use wifi_densepose_sensing_server::http_bind; + let allow_unauthenticated = http_bind::allow_unauthenticated_opt_in( + std::env::var(http_bind::ALLOW_UNAUTHENTICATED_ENV) + .ok() + .as_deref(), + ); + match http_bind::decide_http_bind( + bind_ip, + bearer_auth_state.is_enabled(), + allow_unauthenticated, + ) { + Ok(http_bind::HttpBindDecision::RoutableUnauthenticated) => warn!( + "API auth OFF on routable bind {bind_ip} ({} set): /api/v1/* and \ + /ws/sensing are readable by anyone who can reach this host", + http_bind::ALLOW_UNAUTHENTICATED_ENV + ), + Ok(_) => {} + Err(e) => { + error!("{e}"); + std::process::exit(1); + } + } + } + // 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