diff --git a/.github/workflows/sensing-server-docker.yml b/.github/workflows/sensing-server-docker.yml index 02b0c766..536f9996 100644 --- a/.github/workflows/sensing-server-docker.yml +++ b/.github/workflows/sensing-server-docker.yml @@ -115,11 +115,12 @@ jobs: # RUN guard catches missing ones at build time, this re-checks the # pushed artifact post-hoc as belt-and-braces). # 2. /health is up. - # 3. /api/v1/info returns 200 with no auth (LAN-mode default). - # 4. With RUVIEW_API_TOKEN set, /api/v1/info returns 401 without a - # Bearer header, 200 with the correct one (the #443 auth middleware). + # 3. Secure-by-default (#864): with NO token env, the entrypoint + # generates one, so /api/v1/info returns 401 without a bearer. + # 4. Explicit LAN opt-out (RUVIEW_ALLOW_UNAUTHENTICATED=1) restores the + # old unauthenticated 200 + serves the UI. # --------------------------------------------------------------------- - - name: Smoke-test image assets + LAN-mode HTTP + - name: Smoke-test image assets + secure-by-default HTTP run: | set -euo pipefail IMAGE="ghcr.io/ruvnet/wifi-densepose:sha-${GITHUB_SHA::7}" @@ -128,17 +129,33 @@ jobs: 'ls /app/ui/observatory.html /app/ui/pose-fusion.html /app/ui/index.html /app/ui/viz.html >/dev/null' docker run --rm "$IMAGE" sh -c 'ls -d /app/ui/observatory /app/ui/pose-fusion >/dev/null' - docker run -d --name sm -p 3000:3000 -e CSI_SOURCE=simulated "$IMAGE" - # Wait up to 30 s for /health. + # (a) Secure-by-default: no token env ⇒ entrypoint auto-generates one + # ⇒ /api/v1/info is gated (401) even though we passed no token. + docker run -d --name secdef -p 3000:3000 -e CSI_SOURCE=simulated "$IMAGE" for _ in $(seq 1 30); do if curl -fsS http://127.0.0.1:3000/health >/dev/null 2>&1; then break; fi sleep 1 done - curl -fsS http://127.0.0.1:3000/health + curl -fsS http://127.0.0.1:3000/health >/dev/null + code=$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:3000/api/v1/info) + test "$code" = "401" || { echo "secure-by-default broken: expected 401, got $code (#864)"; exit 1; } + # The auto-generated token is printed to the logs for operators. + docker logs secdef 2>&1 | grep -q "generated one for you" || \ + { echo "expected generated-token banner in logs (#864)"; exit 1; } + docker stop secdef + + # (b) Explicit opt-out for trusted LAN ⇒ unauthenticated 200 + UI. + docker run -d --name lan -p 3000:3000 -e CSI_SOURCE=simulated \ + -e RUVIEW_ALLOW_UNAUTHENTICATED=1 "$IMAGE" + for _ in $(seq 1 30); do + if curl -fsS http://127.0.0.1:3000/health >/dev/null 2>&1; then break; fi + sleep 1 + done + curl -fsS http://127.0.0.1:3000/health >/dev/null curl -fsS http://127.0.0.1:3000/api/v1/info >/dev/null curl -fsS http://127.0.0.1:3000/ui/observatory.html >/dev/null curl -fsS http://127.0.0.1:3000/ui/pose-fusion.html >/dev/null - docker stop sm + docker stop lan - name: Smoke-test the bearer-token auth path run: | diff --git a/docker/Dockerfile.rust b/docker/Dockerfile.rust index 8418ac03..6baaf373 100644 --- a/docker/Dockerfile.rust +++ b/docker/Dockerfile.rust @@ -60,9 +60,12 @@ RUN set -e; \ test -x /app/homecore-server || { echo "FATAL: /app/homecore-server is not executable"; exit 1; }; \ echo "image assets OK" -# Optional bearer-token auth on /api/v1/*: leave unset for LAN-mode (default), -# set to enforce `Authorization: Bearer ` (see bearer_auth module, #443). -# docker run -e RUVIEW_API_TOKEN=$(openssl rand -hex 32) ... +# Bearer-token auth on /api/v1/* and /ws/* (#443, #864). Secure-by-default: +# when left unset the entrypoint GENERATES a random token at startup and prints +# it to the logs, so the sensing API + WebSocket stream are never anonymous out +# of the box. Pin a known token, or opt into the open LAN posture explicitly: +# docker run -e RUVIEW_API_TOKEN=$(openssl rand -hex 32) ... # pin a token +# docker run -e RUVIEW_ALLOW_UNAUTHENTICATED=1 ... # trusted LAN only ENV RUVIEW_API_TOKEN= # HTTP API diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index b511141d..cae61a47 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -23,6 +23,15 @@ services: - "5005:5005/udp" environment: - RUST_LOG=info + # Bearer-token auth (#864). Secure-by-default: if RUVIEW_API_TOKEN is + # unset the container generates a random token at startup — retrieve it + # with `docker compose logs sensing-server`. Pin a known token by exporting + # RUVIEW_API_TOKEN in your shell / .env, or run open on a trusted, isolated + # LAN with RUVIEW_ALLOW_UNAUTHENTICATED=1. + # REST: Authorization: Bearer + # WS: ws://:3001/ws/sensing?token= + - RUVIEW_API_TOKEN=${RUVIEW_API_TOKEN:-} + - RUVIEW_ALLOW_UNAUTHENTICATED=${RUVIEW_ALLOW_UNAUTHENTICATED:-} # CSI_SOURCE controls the data source for the sensing server. # Options: auto (default) — probe for ESP32 UDP then fall back to simulation # esp32 — receive real CSI frames from an ESP32 on UDP port 5005 diff --git a/docker/docker-entrypoint.sh b/docker/docker-entrypoint.sh index e0851137..688e73ff 100755 --- a/docker/docker-entrypoint.sh +++ b/docker/docker-entrypoint.sh @@ -38,6 +38,52 @@ case "${1:-}" in ;; esac +# ── #864: secure-by-default API auth for the sensing server ────────────────── +# +# The sensing server publishes a live RF-sensing REST API and WebSocket stream. +# Historically the Docker image shipped with RUVIEW_API_TOKEN empty, which makes +# bearer auth a no-op and exposes `/api/v1/*` and `/ws/sensing` to anyone who can +# reach the published ports. We now fail closed: if no token is supplied we +# generate a strong random one and print it, so the stream is never anonymous by +# default. Operators on a trusted, isolated LAN can opt back into the open +# posture explicitly with RUVIEW_ALLOW_UNAUTHENTICATED=1. +generate_token() { + if command -v openssl >/dev/null 2>&1; then + openssl rand -hex 32 + elif [ -r /proc/sys/kernel/random/uuid ]; then + # Two UUIDs (dashes stripped) → 64 hex chars of kernel randomness. + printf '%s%s' \ + "$(cat /proc/sys/kernel/random/uuid)" \ + "$(cat /proc/sys/kernel/random/uuid)" | tr -d '-' + else + head -c 32 /dev/urandom | od -An -tx1 | tr -d ' \n' + fi +} + +if [ -z "${RUVIEW_API_TOKEN:-}" ]; then + case "${RUVIEW_ALLOW_UNAUTHENTICATED:-}" in + 1|true|TRUE|yes|YES) + echo "WARNING: RUVIEW_ALLOW_UNAUTHENTICATED is set — the sensing API and" >&2 + echo " /ws/sensing stream will run UNAUTHENTICATED. Only do this on a" >&2 + echo " trusted, isolated network (issue #864)." >&2 + ;; + *) + RUVIEW_API_TOKEN="$(generate_token)" + export RUVIEW_API_TOKEN + echo "============================================================" >&2 + echo " RuView: no RUVIEW_API_TOKEN supplied — generated one for you:" >&2 + echo " RUVIEW_API_TOKEN=${RUVIEW_API_TOKEN}" >&2 + echo "" >&2 + echo " REST: Authorization: Bearer " >&2 + echo " WS: ws://:3001/ws/sensing?token=" >&2 + echo "" >&2 + echo " Pin your own with -e RUVIEW_API_TOKEN=..., or run open on a" >&2 + echo " trusted LAN with -e RUVIEW_ALLOW_UNAUTHENTICATED=1 (issue #864)." >&2 + echo "============================================================" >&2 + ;; + esac +fi + # If the first argument looks like a flag (starts with -), prepend the # server binary so users can just pass flags: # docker run --source esp32 --tick-ms 500 diff --git a/firmware/esp32-csi-node/main/csi_collector.c b/firmware/esp32-csi-node/main/csi_collector.c index 56ae21f7..c9a6d7b4 100644 --- a/firmware/esp32-csi-node/main/csi_collector.c +++ b/firmware/esp32-csi-node/main/csi_collector.c @@ -104,6 +104,23 @@ static uint8_t s_hop_index = 0; /** Handle for the periodic hop timer. NULL when timer is not running. */ static esp_timer_handle_t s_hop_timer = NULL; +/** Handle for the periodic probe-request injection timer (RuView#866). + * NULL when not running. */ +static esp_timer_handle_t s_probe_timer = NULL; + +/* Probe-request injection cadence (RuView#866). The MGMT-only promiscuous + * filter (RuView#396) only surfaces management frames, so on a network with no + * nearby beaconing APs — or one saturated with DATA traffic that the filter + * drops — the CSI callback can starve (3 callbacks in 70 s was observed in + * #866). Injecting a broadcast probe request elicits probe *responses* (which + * ARE management frames) from every AP in range, giving a controlled, traffic- + * independent CSI rate without re-enabling the DATA-frame interrupt storm that + * MGMT-only exists to avoid. 100 ms ⇒ ~10 Hz, matching the 20 Hz edge sample + * rate once ambient beacons are added. Override at build time via Kconfig. */ +#ifndef CONFIG_CSI_PROBE_INJECT_INTERVAL_MS +#define CONFIG_CSI_PROBE_INJECT_INTERVAL_MS 100 +#endif + /** * Serialize CSI data into ADR-018 binary frame format. * @@ -464,19 +481,37 @@ void csi_collector_init(void) ESP_ERROR_CHECK(esp_wifi_set_promiscuous(true)); ESP_ERROR_CHECK(esp_wifi_set_promiscuous_rx_cb(wifi_promiscuous_cb)); - /* MGMT-only promiscuous filter + active probe injection (RuView#396). + /* Promiscuous CSI filter (RuView#396 / RuView#866). * - * DATA frames cause 100-500+ WiFi HW interrupts/sec which crashes Core 0 - * in wDev_ProcessFiq (SPI flash cache race in ESP-IDF WiFi blob). - * MGMT-only gives ~10 Hz (beacons). Probe request injection at 10 Hz - * adds ~10 Hz probe responses from APs → ~20 Hz total, matching the - * edge processing designed sample rate of 20 Hz. */ + * History: DATA frames once crashed Core 0 in wDev_ProcessFiq (SPI-flash + * cache race in the WiFi blob) under a 100-500+ interrupt/sec storm, so the + * filter was pinned to MGMT-only. But MGMT-only starves on real networks: + * the associated AP's beacons do not reliably generate CSI on the C6, and + * broadcast probe injection (below) transmits fine yet elicits almost no + * capturable responses — #866 measured ~3 CSI callbacks in 70 s with 0 pps + * yield. Two mitigations for the original crash are now in place and active + * (confirmed in the boot log): WiFi RX/TX IRAM optimisations keep the ISR + * out of cacheable flash, and wifi_csi_callback() applies a 50 Hz early + * rate gate (CSI_MIN_PROCESS_INTERVAL_US) that caps ISR work regardless of + * arrival rate. With those guards we re-admit DATA frames so ambient/own + * traffic produces a dense, traffic-driven CSI stream. Operators who hit + * instability can fall back to MGMT-only via Kconfig. + * + * Probe injection (csi_collector_start_probe_timer) is retained: it keeps a + * ~10 Hz floor of management-frame CSI when the link is otherwise idle. */ +#ifdef CONFIG_CSI_PROMISC_MGMT_ONLY + uint32_t filter_mask = WIFI_PROMIS_FILTER_MASK_MGMT; + const char *filter_desc = "MGMT-only (Kconfig override)"; +#else + uint32_t filter_mask = WIFI_PROMIS_FILTER_MASK_MGMT | WIFI_PROMIS_FILTER_MASK_DATA; + const char *filter_desc = "MGMT+DATA (50 Hz-gated, RuView#866)"; +#endif wifi_promiscuous_filter_t filt = { - .filter_mask = WIFI_PROMIS_FILTER_MASK_MGMT, + .filter_mask = filter_mask, }; ESP_ERROR_CHECK(esp_wifi_set_promiscuous_filter(&filt)); - ESP_LOGI(TAG, "Promiscuous mode enabled (MGMT-only, RuView#396)"); + ESP_LOGI(TAG, "Promiscuous mode enabled (%s)", filter_desc); #if CONFIG_SOC_WIFI_HE_SUPPORT /* Wi-Fi 6 targets (e.g. ESP32-C6): wifi_csi_config_t is wifi_csi_acquire_config_t @@ -526,6 +561,12 @@ void csi_collector_init(void) ESP_LOGI(TAG, "CSI collection initialized (node_id=%u, channel=%u)", (unsigned)s_node_id, (unsigned)csi_channel); + + /* RuView#866: start active probe injection so CSI keeps flowing even when + * the MGMT-only filter would otherwise starve under heavy DATA traffic or + * a beacon-sparse environment. Safe to call here — WiFi is started and the + * CSI rx callback is registered above. */ + csi_collector_start_probe_timer(); } /* Accessor for other modules that need the authoritative runtime node_id. */ @@ -730,3 +771,103 @@ esp_err_t csi_inject_ndp_frame(void) return err; } + +/* ---- RuView#866: active probe-request injection for traffic-independent CSI ---- */ + +esp_err_t csi_inject_probe_request(void) +{ + /* + * Broadcast 802.11 probe request (wildcard SSID). Every AP in range answers + * with a probe response — a *management* frame that passes the MGMT-only + * promiscuous filter (RuView#396) and fires the CSI callback. This gives a + * controlled CSI rate that does not depend on ambient beacon/data traffic. + * + * Layout: 24-byte MAC header + tagged params (wildcard SSID + basic rates). + * FC(2) Dur(2) A1/DA(6) A2/SA(6) A3/BSSID(6) SeqCtl(2) | SSID tag | Rates tag + */ + uint8_t frame[] = { + 0x40, 0x00, /* FC: Type=Mgmt(0), Subtype=ProbeReq(4) */ + 0x00, 0x00, /* Duration */ + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, /* A1 DA: broadcast */ + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, /* A2 SA: own MAC (filled below) */ + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, /* A3 BSSID: broadcast */ + 0x00, 0x00, /* Sequence control (HW overwrites) */ + 0x00, 0x00, /* Tag: SSID, len 0 (wildcard) */ + 0x01, 0x04, 0x02, 0x04, 0x0b, 0x16, /* Tag: Supported Rates 1/2/5.5/11 Mbps */ + }; + + /* The Wi-Fi driver requires A2 (source) to be this interface's own MAC for + * a self-originated management frame, otherwise esp_wifi_80211_tx rejects + * it with ESP_ERR_INVALID_ARG. */ + uint8_t mac[6]; + if (esp_wifi_get_mac(WIFI_IF_STA, mac) == ESP_OK) { + memcpy(&frame[10], mac, 6); + } + + /* en_sys_seq=true: let the MAC assign the sequence number. */ + esp_err_t err = esp_wifi_80211_tx(WIFI_IF_STA, frame, sizeof(frame), true); + + /* Observability (RuView#866): track TX outcome so the per-second yield can + * be correlated with whether injection is actually reaching the air. The + * first few results are logged verbatim; thereafter a periodic summary. */ + static uint32_t s_probe_ok = 0; + static uint32_t s_probe_fail = 0; + if (err == ESP_OK) { + s_probe_ok++; + if (s_probe_ok <= 3 || (s_probe_ok % 100) == 0) { + ESP_LOGI(TAG, "probe inject ok #%lu (fail=%lu)", + (unsigned long)s_probe_ok, (unsigned long)s_probe_fail); + } + } else { + s_probe_fail++; + if (s_probe_fail <= 3 || (s_probe_fail % 50) == 0) { + ESP_LOGW(TAG, "probe inject failed: %s (fail #%lu, ok=%lu)", + esp_err_to_name(err), (unsigned long)s_probe_fail, + (unsigned long)s_probe_ok); + } + } + return err; +} + +/** Timer callback: inject one probe request every CONFIG_CSI_PROBE_INJECT_INTERVAL_MS. */ +static void probe_timer_cb(void *arg) +{ + (void)arg; + csi_inject_probe_request(); +} + +void csi_collector_start_probe_timer(void) +{ + if (CONFIG_CSI_PROBE_INJECT_INTERVAL_MS == 0) { + ESP_LOGI(TAG, "Probe injection disabled (interval=0)"); + return; + } + if (s_probe_timer != NULL) { + ESP_LOGW(TAG, "Probe timer already running"); + return; + } + + esp_timer_create_args_t timer_args = { + .callback = probe_timer_cb, + .arg = NULL, + .name = "csi_probe", + }; + esp_err_t err = esp_timer_create(&timer_args, &s_probe_timer); + if (err != ESP_OK) { + ESP_LOGE(TAG, "Failed to create probe timer: %s", esp_err_to_name(err)); + return; + } + + uint64_t period_us = (uint64_t)CONFIG_CSI_PROBE_INJECT_INTERVAL_MS * 1000; + err = esp_timer_start_periodic(s_probe_timer, period_us); + if (err != ESP_OK) { + ESP_LOGE(TAG, "Failed to start probe timer: %s", esp_err_to_name(err)); + esp_timer_delete(s_probe_timer); + s_probe_timer = NULL; + return; + } + + ESP_LOGI(TAG, "Probe injection started: %d ms (~%d Hz) to keep CSI alive under MGMT-only filter", + CONFIG_CSI_PROBE_INJECT_INTERVAL_MS, + 1000 / CONFIG_CSI_PROBE_INJECT_INTERVAL_MS); +} diff --git a/firmware/esp32-csi-node/main/csi_collector.h b/firmware/esp32-csi-node/main/csi_collector.h index 92f43105..42f2a572 100644 --- a/firmware/esp32-csi-node/main/csi_collector.h +++ b/firmware/esp32-csi-node/main/csi_collector.h @@ -117,6 +117,27 @@ void csi_collector_enable_data_capture(void); */ esp_err_t csi_inject_ndp_frame(void); +/** + * Inject a broadcast 802.11 probe request (wildcard SSID) (RuView#866). + * + * Nearby APs answer with probe responses — management frames that pass the + * MGMT-only promiscuous filter and fire the CSI rx callback. This produces a + * controlled CSI rate independent of ambient beacon/data traffic, without + * re-enabling the DATA-frame interrupt storm that the MGMT-only filter avoids. + * + * @return ESP_OK on success, or an error code from esp_wifi_80211_tx(). + */ +esp_err_t csi_inject_probe_request(void); + +/** + * Start the periodic probe-request injection timer (RuView#866). + * + * Fires every CONFIG_CSI_PROBE_INJECT_INTERVAL_MS (default 100 ms ⇒ ~10 Hz), + * calling csi_inject_probe_request(). Called automatically from + * csi_collector_init(); no-op if already running or if the interval is 0. + */ +void csi_collector_start_probe_timer(void); + /** * Get the recent CSI callback rate (per second). * diff --git a/v2/crates/wifi-densepose-sensing-server/src/bearer_auth.rs b/v2/crates/wifi-densepose-sensing-server/src/bearer_auth.rs index c7acd168..4b7419a6 100644 --- a/v2/crates/wifi-densepose-sensing-server/src/bearer_auth.rs +++ b/v2/crates/wifi-densepose-sensing-server/src/bearer_auth.rs @@ -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=` 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 { + // 1. Authorization: Bearer — 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= 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 { + 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, + 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= to the ws URL, \ + or send Authorization: Bearer )\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); } } diff --git a/v2/crates/wifi-densepose-sensing-server/src/main.rs b/v2/crates/wifi-densepose-sensing-server/src/main.rs index eaebdbdf..ae72972b 100644 --- a/v2/crates/wifi-densepose-sensing-server/src/main.rs +++ b/v2/crates/wifi-densepose-sensing-server/src/main.rs @@ -6486,7 +6486,10 @@ async fn main() { // every `/api/v1/*` request must carry `Authorization: Bearer `. 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=." + ); if bind_ip.is_unspecified() { warn!( "API auth ON but bind-addr is {} — consider --bind-addr 127.0.0.1 for LAN-only deployments", @@ -6494,8 +6497,9 @@ async fn main() { ); } } else { - info!( - "API auth: OFF — /api/v1/* is unauthenticated. Set RUVIEW_API_TOKEN= to enforce bearer auth." + warn!( + "API auth: OFF — /api/v1/* and /ws/* sensing streams are UNAUTHENTICATED. \ + Set RUVIEW_API_TOKEN= to enforce bearer auth (the Docker image does this by default)." ); } @@ -6528,6 +6532,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, @@ -6640,12 +6651,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