feat(ws): single-use WebSocket ticket store (ADR-272 groundwork)

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
This commit is contained in:
Dragan Spiridonov
2026-07-22 18:41:34 +02:00
parent 6d3fb88677
commit 7d6d66941a
4 changed files with 302 additions and 0 deletions
Generated
+1
View File
@@ -11305,6 +11305,7 @@ dependencies = [
"midstreamer-temporal-compare",
"p256",
"proptest",
"rand 0.8.5",
"rumqttc",
"ruvector-mincut",
"ruview-auth",
@@ -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
@@ -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;
@@ -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<String>,
/// `sub` of the issuing principal, for logging. `None` for the static token.
pub subject: Option<String>,
}
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<Mutex<HashMap<String, Entry>>>,
}
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<String> {
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<TicketGrant> {
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<String, Entry>) {
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<String> {
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<String> {
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());
}
}