fix(security,firmware): secure-by-default Docker auth (#864) + CSI yield recovery (#866)

#864 — Docker no longer exposes the sensing API/stream unauthenticated:
- Add `require_ws_token` middleware gating `/ws/*` (sensing + introspection)
  with the API token via `?token=` (browser) or `Authorization: Bearer`
  (programmatic). Previously /ws/sensing was ungated even with a token set.
- docker-entrypoint.sh now fails closed: auto-generates a strong
  RUVIEW_API_TOKEN when none is supplied and prints it; explicit
  RUVIEW_ALLOW_UNAUTHENTICATED=1 restores the open LAN posture.
- compose/Dockerfile wire the env vars; startup logs + CI smoke test updated
  to assert secure-by-default (401 with no token) and the opt-out path.
- 7 new bearer_auth unit tests (15 total pass).

#866 — CSI callbacks were starving (~3 in 70s, 0pps) under the MGMT-only
promiscuous filter:
- The documented "10 Hz probe injection" never existed — implement it for
  real (csi_inject_probe_request + 10 Hz timer). Validated on ESP32-C6 (COM9):
  probe TX succeeds at 10 Hz, but management-frame CSI stays sparse.
- Re-admit DATA frames (MGMT+DATA) now that the original wDev_ProcessFiq
  SPI-cache crash is mitigated by WiFi RX/TX IRAM opts + the existing 50 Hz
  rate gate. Kconfig CSI_PROMISC_MGMT_ONLY falls back if needed.
- Hardware-validated on COM9: yield 0 -> ~9pps avg (peak 19), presence/motion
  sensing restored, 0 panics over 35s.

Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
ruv
2026-05-30 11:37:07 -04:00
parent 9ad550d95f
commit f02b431b59
8 changed files with 425 additions and 24 deletions
@@ -34,6 +34,10 @@ pub const API_TOKEN_ENV: &str = "RUVIEW_API_TOKEN";
/// Path prefix the middleware protects when auth is enabled.
pub const PROTECTED_PREFIX: &str = "/api/v1/";
/// Path prefix for the WebSocket sensing/introspection topics that
/// [`require_ws_token`] protects when auth is enabled (#864).
pub const WS_PREFIX: &str = "/ws/";
/// Cheap, cloneable handle to the configured token (or `None`).
#[derive(Debug, Clone, Default)]
pub struct AuthState {
@@ -115,6 +119,71 @@ pub async fn require_bearer(
}
}
/// Extract a bearer token from a WebSocket-upgrade request. Browsers cannot set
/// arbitrary headers on a WS handshake, so the token is accepted via the
/// `?token=<t>` query parameter in addition to the `Authorization: Bearer`
/// header that programmatic clients (wscat, curl) can send.
///
/// No percent-decoding is applied: generated tokens are URL-safe (hex from
/// `openssl rand` / UUID concatenation). Operators who pin a custom token
/// should keep it URL-safe.
fn ws_supplied_token(request: &Request) -> Option<String> {
// 1. Authorization: Bearer <token> — for programmatic clients.
if let Some(t) = request
.headers()
.get(AUTHORIZATION)
.and_then(|v| v.to_str().ok())
.and_then(|s| s.strip_prefix("Bearer "))
{
return Some(t.to_string());
}
// 2. ?token=<token> query parameter — the only option browsers have on a
// WebSocket handshake.
request.uri().query().and_then(token_from_query)
}
/// Find the `token` value in a `&`-separated `key=value` query string.
fn token_from_query(query: &str) -> Option<String> {
query.split('&').find_map(|pair| {
let mut it = pair.splitn(2, '=');
match (it.next(), it.next()) {
(Some("token"), Some(v)) => Some(v.to_string()),
_ => None,
}
})
}
/// Axum middleware: enforces a valid token on `/ws/*` upgrade requests when
/// [`AuthState::is_enabled`] returns `true` (#864). Mirrors [`require_bearer`]
/// but reads the token from `?token=` (browser-friendly) or `Authorization`.
/// When auth is disabled the middleware is a no-op, preserving the LAN-only
/// default for non-Docker local runs.
pub async fn require_ws_token(
State(auth): State<AuthState>,
request: Request,
next: Next,
) -> Response {
let Some(expected) = auth.token.clone() else {
return next.run(request).await;
};
if !request.uri().path().starts_with(WS_PREFIX) {
return next.run(request).await;
}
let ok = ws_supplied_token(&request)
.map(|s| ct_eq(s.as_bytes(), expected.as_bytes()))
.unwrap_or(false);
if ok {
next.run(request).await
} else {
(
StatusCode::UNAUTHORIZED,
"missing or invalid token (append ?token=<RUVIEW_API_TOKEN> to the ws URL, \
or send Authorization: Bearer <token>)\n",
)
.into_response()
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -256,5 +325,82 @@ mod tests {
// These are documented in the issue body and the README; keep them locked.
assert_eq!(API_TOKEN_ENV, "RUVIEW_API_TOKEN");
assert_eq!(PROTECTED_PREFIX, "/api/v1/");
assert_eq!(WS_PREFIX, "/ws/");
}
// ── #864: WebSocket token enforcement ────────────────────────────────────
fn ws_router(auth: AuthState) -> Router {
Router::new()
.route("/ws/sensing", get(|| async { "stream" }))
.route("/ws/introspection", get(|| async { "stream" }))
.route("/health", get(|| async { "ok" }))
.layer(axum::middleware::from_fn_with_state(auth, require_ws_token))
}
#[test]
fn token_from_query_parses_first_match() {
assert_eq!(token_from_query("token=abc").as_deref(), Some("abc"));
assert_eq!(token_from_query("a=1&token=abc&b=2").as_deref(), Some("abc"));
assert_eq!(token_from_query("a=1&b=2").as_deref(), None);
assert_eq!(token_from_query("").as_deref(), None);
// bare key with no value is not a token
assert_eq!(token_from_query("token").as_deref(), None);
}
#[tokio::test]
async fn ws_unprotected_when_token_unset() {
let r = ws_router(AuthState::default());
assert_eq!(
status(r, "GET", "/ws/sensing", None).await,
StatusCode::OK
);
}
#[tokio::test]
async fn ws_blocks_without_token() {
let r = ws_router(AuthState::from_token("s3cr3t"));
assert_eq!(
status(r.clone(), "GET", "/ws/sensing", None).await,
StatusCode::UNAUTHORIZED
);
assert_eq!(
status(r, "GET", "/ws/introspection", None).await,
StatusCode::UNAUTHORIZED
);
}
#[tokio::test]
async fn ws_allows_with_query_token() {
let r = ws_router(AuthState::from_token("s3cr3t"));
assert_eq!(
status(r, "GET", "/ws/sensing?token=s3cr3t", None).await,
StatusCode::OK
);
}
#[tokio::test]
async fn ws_allows_with_bearer_header() {
let r = ws_router(AuthState::from_token("s3cr3t"));
assert_eq!(
status(r, "GET", "/ws/sensing", Some("s3cr3t")).await,
StatusCode::OK
);
}
#[tokio::test]
async fn ws_blocks_with_wrong_query_token() {
let r = ws_router(AuthState::from_token("s3cr3t"));
assert_eq!(
status(r, "GET", "/ws/sensing?token=nope", None).await,
StatusCode::UNAUTHORIZED
);
}
#[tokio::test]
async fn ws_middleware_never_gates_non_ws_paths() {
// /health rides on the same router (dedicated WS port) and must stay open.
let r = ws_router(AuthState::from_token("s3cr3t"));
assert_eq!(status(r, "GET", "/health", None).await, StatusCode::OK);
}
}
@@ -6103,7 +6103,10 @@ async fn main() {
// every `/api/v1/*` request must carry `Authorization: Bearer <token>`.
let bearer_auth_state = wifi_densepose_sensing_server::bearer_auth::AuthState::from_env();
if bearer_auth_state.is_enabled() {
info!("API auth: bearer-token enforcement ON for /api/v1/* (RUVIEW_API_TOKEN set)");
info!(
"API auth: bearer-token enforcement ON for /api/v1/* and /ws/* (RUVIEW_API_TOKEN set). \
WebSocket clients pass it as ?token=<token>."
);
if bind_ip.is_unspecified() {
warn!(
"API auth ON but bind-addr is {} — consider --bind-addr 127.0.0.1 for LAN-only deployments",
@@ -6111,8 +6114,9 @@ async fn main() {
);
}
} else {
info!(
"API auth: OFF — /api/v1/* is unauthenticated. Set RUVIEW_API_TOKEN=<token> to enforce bearer auth."
warn!(
"API auth: OFF — /api/v1/* and /ws/* sensing streams are UNAUTHENTICATED. \
Set RUVIEW_API_TOKEN=<token> to enforce bearer auth (the Docker image does this by default)."
);
}
@@ -6145,6 +6149,13 @@ async fn main() {
let ws_app = Router::new()
.route("/ws/sensing", get(ws_sensing_handler))
.route("/health", get(health))
// #864: gate the live sensing stream with the API token when set. Reads
// `?token=` (browser-friendly) or `Authorization: Bearer`. No-op when
// RUVIEW_API_TOKEN is unset (LAN-mode default for local non-Docker runs).
.layer(axum::middleware::from_fn_with_state(
bearer_auth_state.clone(),
wifi_densepose_sensing_server::bearer_auth::require_ws_token,
))
.layer(axum::middleware::from_fn_with_state(
host_allowlist.clone(),
wifi_densepose_sensing_server::host_validation::require_allowed_host,
@@ -6257,12 +6268,19 @@ async fn main() {
))
// Opt-in bearer-token auth on `/api/v1/*` (#443). When `RUVIEW_API_TOKEN`
// is unset/empty the middleware is a no-op — the default stays
// LAN-mode-friendly. `/health*`, `/ws/sensing`, and `/ui/*` are never
// gated (orchestrator probes + local browsers).
// LAN-mode-friendly. `/health*` and `/ui/*` are never gated
// (orchestrator probes + local browsers loading the static UI).
.layer(axum::middleware::from_fn_with_state(
bearer_auth_state.clone(),
wifi_densepose_sensing_server::bearer_auth::require_bearer,
))
// #864: gate the live `/ws/*` sensing + introspection streams with the
// same token. Browsers pass it as `?token=`; programmatic clients use
// `Authorization: Bearer`. No-op when RUVIEW_API_TOKEN is unset.
.layer(axum::middleware::from_fn_with_state(
bearer_auth_state.clone(),
wifi_densepose_sensing_server::bearer_auth::require_ws_token,
))
// 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