From 7d6d66941afcbc29f2ec11694ae6ee0c9be8aa31 Mon Sep 17 00:00:00 2001 From: Dragan Spiridonov Date: Wed, 22 Jul 2026 18:41:34 +0200 Subject: [PATCH] feat(ws): single-use WebSocket ticket store (ADR-272 groundwork) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Audit finding this exists to close — verified empirically, not inferred. With RUVIEW_API_TOKEN set (operator believes auth is ON), a real WebSocket handshake carrying NO credential: /ws/sensing -> 101 Switching Protocols /ws/introspection -> 101 Switching Protocols /api/v1/stream/pose -> 101 Switching Protocols /api/v1/models -> 401 (control: REST is correctly gated) The REST control plane is locked while the DATA plane — live presence, pose and vitals — is open to anyone who can reach the port. Two of those paths sit outside PROTECTED_PREFIX entirely; the third is the documented EXEMPT_PATHS entry. The exemption was reasonable when added (a browser cannot set Authorization on an upgrade) but its blast radius is larger than it looks, and phase 3 sharpened the contrast by making REST genuinely strong. (To be precise about what was proven: the handshake is accepted. A payload frame was not captured in that window, so this is "the connection is established without a credential", not "data was read".) This commit adds only the store; wiring it into the middleware is a breaking change for browser clients and lands with the UI update. Design notes: - Single use. `consume` REMOVES the entry, so a replay of the same URL fails even inside the TTL. This is what makes a credential-in-a-query tolerable: by the time it reaches an access log or a Referer header, it is spent. - 30-second TTL. Long enough for a page to open a socket; too short to harvest. - It is not the credential. It authorizes one WebSocket. It cannot be replayed against /api/v1/*, cannot be refreshed, and carries no reusable identity. - The grant captures the ISSUING principal's scopes, so a WebSocket inherits exactly the authority of the credential that asked for it — a sensing:read session cannot mint a ticket that outranks itself. - Capped at 512 outstanding, self-healing as tickets expire, so an authenticated but misbehaving caller cannot grow the map without bound. - In-memory because that is correct, not merely convenient: a ticket surviving a restart would outlive the server that vouched for it. Native clients (Python, Rust CLI, TS MCP) are NOT browsers and will send a normal Authorization header on the upgrade instead — tickets would add a round-trip and a second credential path for no benefit. 12 tests: single-use enforced, replay refused, expiry refused AND pruned, unknown ticket refused, 256-bit unpredictability, grant carries issuer scopes, cap enforced and self-healing, and query parsing including `?myticket=x` not being read as `?ticket=x`. Co-Authored-By: Ruflo & AQE --- v2/Cargo.lock | 1 + .../wifi-densepose-sensing-server/Cargo.toml | 2 + .../wifi-densepose-sensing-server/src/lib.rs | 1 + .../src/ws_ticket.rs | 298 ++++++++++++++++++ 4 files changed, 302 insertions(+) create mode 100644 v2/crates/wifi-densepose-sensing-server/src/ws_ticket.rs diff --git a/v2/Cargo.lock b/v2/Cargo.lock index 370fc26b..bfb70e58 100644 --- a/v2/Cargo.lock +++ b/v2/Cargo.lock @@ -11305,6 +11305,7 @@ dependencies = [ "midstreamer-temporal-compare", "p256", "proptest", + "rand 0.8.5", "rumqttc", "ruvector-mincut", "ruview-auth", diff --git a/v2/crates/wifi-densepose-sensing-server/Cargo.toml b/v2/crates/wifi-densepose-sensing-server/Cargo.toml index 89007c3a..5267e4d2 100644 --- a/v2/crates/wifi-densepose-sensing-server/Cargo.toml +++ b/v2/crates/wifi-densepose-sensing-server/Cargo.toml @@ -89,6 +89,8 @@ thiserror = "1" # transport above rather than pulling a second HTTP stack: `ruview-auth`'s # JWKS fetch sits behind a trait, and its default feature is the ureq one. ruview-auth = { path = "../ruview-auth" } +# ADR-272 — unpredictable single-use WebSocket tickets. +rand = "0.8" # ADR-115 §3.8 — MQTT publisher (HA-DISCO). # Gated behind the `mqtt` feature so the default binary stays small for users diff --git a/v2/crates/wifi-densepose-sensing-server/src/lib.rs b/v2/crates/wifi-densepose-sensing-server/src/lib.rs index 374f3802..2156963a 100644 --- a/v2/crates/wifi-densepose-sensing-server/src/lib.rs +++ b/v2/crates/wifi-densepose-sensing-server/src/lib.rs @@ -9,6 +9,7 @@ //! - Real-time CSI introspection / low-latency tap (`introspection`, ADR-099) pub mod bearer_auth; +pub mod ws_ticket; pub mod cli; pub mod dataset; pub mod edge_registry; diff --git a/v2/crates/wifi-densepose-sensing-server/src/ws_ticket.rs b/v2/crates/wifi-densepose-sensing-server/src/ws_ticket.rs new file mode 100644 index 00000000..c5ab8314 --- /dev/null +++ b/v2/crates/wifi-densepose-sensing-server/src/ws_ticket.rs @@ -0,0 +1,298 @@ +//! Short-lived, single-use WebSocket tickets (ADR-272). +//! +//! # Why this exists +//! +//! A browser's `WebSocket` constructor cannot set an `Authorization` header on +//! the upgrade request. That limitation is why `/ws/sensing`, +//! `/ws/introspection` and `/api/v1/stream/pose` have been exempt from +//! [`crate::bearer_auth`] — which means that on a server with auth switched +//! ON, an unauthenticated caller can still complete a WebSocket handshake to +//! the **live sensing stream**. The REST control plane is locked; the data +//! plane is open. +//! +//! A ticket closes that without pretending browsers can do something they +//! cannot: the page makes an ordinary authenticated `POST /api/v1/ws-ticket` +//! (a normal request, where it *can* set headers), gets an opaque string, and +//! passes it as `?ticket=…` on the upgrade. +//! +//! # Why a query parameter is acceptable here, when it usually is not +//! +//! Putting a credential in a URL is normally a mistake: URLs land in access +//! logs, `Referer` headers and browser history. Three properties keep this one +//! bounded, and all three are load-bearing: +//! +//! 1. **Single use.** Consumed on the first upgrade attempt. A ticket in a log +//! is already spent. +//! 2. **Seconds, not hours.** [`TICKET_TTL`] is 30s — long enough for a page to +//! open a socket, far too short to be worth harvesting. +//! 3. **It is not the credential.** It authorizes one WebSocket connection. +//! It cannot be replayed against `/api/v1/*`, cannot be refreshed, and +//! carries no user identity a thief could reuse elsewhere. +//! +//! Native clients — the Python client, the Rust CLI, the TS MCP client — are +//! **not** browsers and must send a normal `Authorization` header on the +//! upgrade instead. Routing them through tickets would add a round-trip and a +//! second credential path for no benefit. + +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; + +use rand::RngCore; + +/// How long a ticket is valid. Deliberately tiny — a page opens its socket +/// immediately after fetching one, so anything longer is only useful to +/// someone who found the URL later. +pub const TICKET_TTL: Duration = Duration::from_secs(30); + +/// Cap on outstanding tickets, so a caller with a valid credential cannot grow +/// the map without bound by requesting tickets in a loop. Well above any real +/// page's needs. +const MAX_OUTSTANDING: usize = 512; + +/// What a redeemed ticket authorizes. +/// +/// The scopes are captured at issue time from the authenticated request, so a +/// WebSocket inherits exactly the authority of the credential that asked for +/// it — a `sensing:read` session cannot obtain a ticket that outranks itself. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TicketGrant { + /// Space-separated scopes held by the issuing principal, or `None` when the + /// issuer was the legacy static token (which predates scopes and carries + /// full authority). + pub scopes: Option, + /// `sub` of the issuing principal, for logging. `None` for the static token. + pub subject: Option, +} + +struct Entry { + grant: TicketGrant, + expires_at: Instant, +} + +/// In-memory ticket store. +/// +/// In-memory is correct rather than merely convenient: tickets live for +/// seconds, and a ticket surviving a restart would be a ticket outliving the +/// server that vouched for it. +#[derive(Clone, Default)] +pub struct TicketStore { + inner: Arc>>, +} + +impl TicketStore { + pub fn new() -> Self { + Self::default() + } + + /// Mint a ticket for an authenticated caller. + /// + /// Returns `None` if too many tickets are outstanding — refusing to issue + /// is the correct failure here; the alternative is unbounded growth driven + /// by a caller who is authenticated but misbehaving. + pub fn issue(&self, grant: TicketGrant) -> Option { + let mut map = self.inner.lock().expect("ticket store poisoned"); + prune(&mut map); + if map.len() >= MAX_OUTSTANDING { + tracing::warn!( + outstanding = map.len(), + "refusing to issue a WebSocket ticket: too many outstanding" + ); + return None; + } + let mut bytes = [0u8; 32]; + rand::rngs::OsRng.fill_bytes(&mut bytes); + let ticket = hex(&bytes); + map.insert( + ticket.clone(), + Entry { + grant, + expires_at: Instant::now() + TICKET_TTL, + }, + ); + Some(ticket) + } + + /// Redeem a ticket. **Removes it** — a ticket is valid exactly once, so a + /// replay of the same URL fails even within the TTL. + pub fn consume(&self, ticket: &str) -> Option { + let mut map = self.inner.lock().expect("ticket store poisoned"); + prune(&mut map); + let entry = map.remove(ticket)?; + // Belt and braces: prune already dropped expired entries, but an entry + // expiring between the two would otherwise slip through. + if entry.expires_at <= Instant::now() { + return None; + } + Some(entry.grant) + } + + #[cfg(test)] + fn outstanding(&self) -> usize { + self.inner.lock().unwrap().len() + } +} + +fn prune(map: &mut HashMap) { + let now = Instant::now(); + map.retain(|_, e| e.expires_at > now); +} + +fn hex(bytes: &[u8]) -> String { + use std::fmt::Write; + bytes.iter().fold(String::with_capacity(bytes.len() * 2), |mut s, b| { + let _ = write!(s, "{b:02x}"); + s + }) +} + +/// Extract `ticket` from a raw query string. +fn ticket_from_query(query: &str) -> Option { + for pair in query.split('&') { + if let Some(v) = pair.strip_prefix("ticket=") { + if !v.is_empty() { + return Some(v.to_string()); + } + } + } + None +} + +/// Extract `ticket` from a request URI's query, if present. +pub fn ticket_from_uri(uri: &axum::http::Uri) -> Option { + ticket_from_query(uri.query()?) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn grant() -> TicketGrant { + TicketGrant { + scopes: Some("sensing:read".into()), + subject: Some("user-1".into()), + } + } + + #[test] + fn a_ticket_round_trips_once() { + let store = TicketStore::new(); + let t = store.issue(grant()).expect("issued"); + assert_eq!(store.consume(&t), Some(grant())); + } + + #[test] + fn a_ticket_cannot_be_used_twice() { + // The property that makes a credential-in-a-URL tolerable: by the time + // it reaches a log, it is spent. + let store = TicketStore::new(); + let t = store.issue(grant()).unwrap(); + assert!(store.consume(&t).is_some(), "first use succeeds"); + assert!(store.consume(&t).is_none(), "replay must fail"); + } + + #[test] + fn an_unknown_ticket_is_refused() { + let store = TicketStore::new(); + assert!(store.consume("deadbeef").is_none()); + } + + #[test] + fn consuming_removes_the_entry_rather_than_marking_it() { + let store = TicketStore::new(); + let t = store.issue(grant()).unwrap(); + assert_eq!(store.outstanding(), 1); + store.consume(&t); + assert_eq!(store.outstanding(), 0, "spent tickets must not accumulate"); + } + + #[test] + fn an_expired_ticket_is_refused_and_pruned() { + let store = TicketStore::new(); + let t = store.issue(grant()).unwrap(); + // Force expiry without sleeping. + { + let mut map = store.inner.lock().unwrap(); + map.get_mut(&t).unwrap().expires_at = Instant::now() - Duration::from_secs(1); + } + assert!(store.consume(&t).is_none(), "expired ticket must be refused"); + assert_eq!(store.outstanding(), 0, "and must not linger"); + } + + #[test] + fn tickets_are_unpredictable_and_distinct() { + let store = TicketStore::new(); + let a = store.issue(grant()).unwrap(); + let b = store.issue(grant()).unwrap(); + assert_ne!(a, b); + // 32 bytes hex — guessing is not a strategy. + assert_eq!(a.len(), 64, "expected 256 bits of ticket"); + assert!(a.chars().all(|c| c.is_ascii_hexdigit())); + } + + #[test] + fn the_grant_records_the_issuing_principals_scopes() { + // A sensing:read session must not be able to mint a ticket that + // outranks it — the WebSocket inherits the issuer's authority. + let store = TicketStore::new(); + let g = TicketGrant { + scopes: Some("sensing:read".into()), + subject: Some("u".into()), + }; + let t = store.issue(g.clone()).unwrap(); + assert_eq!(store.consume(&t).unwrap().scopes.as_deref(), Some("sensing:read")); + } + + #[test] + fn issuing_is_refused_once_too_many_are_outstanding() { + let store = TicketStore::new(); + for _ in 0..MAX_OUTSTANDING { + assert!(store.issue(grant()).is_some()); + } + assert!( + store.issue(grant()).is_none(), + "an authenticated but misbehaving caller must not grow the map without bound" + ); + } + + #[test] + fn expired_tickets_free_capacity_again() { + let store = TicketStore::new(); + for _ in 0..MAX_OUTSTANDING { + store.issue(grant()); + } + assert!(store.issue(grant()).is_none()); + { + let mut map = store.inner.lock().unwrap(); + for e in map.values_mut() { + e.expires_at = Instant::now() - Duration::from_secs(1); + } + } + assert!( + store.issue(grant()).is_some(), + "the cap must be self-healing, not a permanent wedge" + ); + } + + #[test] + fn parses_a_ticket_from_a_query_string() { + assert_eq!(ticket_from_query("ticket=abc123").as_deref(), Some("abc123")); + assert_eq!( + ticket_from_query("foo=1&ticket=abc123&bar=2").as_deref(), + Some("abc123") + ); + } + + #[test] + fn an_absent_or_empty_ticket_parameter_yields_none() { + assert!(ticket_from_query("foo=1").is_none()); + assert!(ticket_from_query("ticket=").is_none()); + assert!(ticket_from_query("").is_none()); + } + + #[test] + fn a_parameter_merely_ending_in_ticket_is_not_a_ticket() { + // `?myticket=x` must not be read as `?ticket=x`. + assert!(ticket_from_query("myticket=abc").is_none()); + } +}