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 <ruv@ruv.net>
This commit is contained in:
Mathew Beaneandclaude-flow committed 2026-10-02 22:26:26 -04:00
1 parent 03a897938f
commit fabb0c8233
4 files changed
+172

No files matched your search

@@ -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=<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,
@@ -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<HttpBindDecision, String> {
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=<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));
}
}
@@ -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;
@@ -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