mirror of
https://github.com/ruvnet/RuView
synced 2026-07-22 17:23:19 +00:00
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:
Generated
+1
@@ -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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user