Files
EventSnap/backend/src/services/sse_tickets.rs
fabi ec7c7f18ca fix(auth): stop one guest on the venue NAT from locking everyone else out
Every guest at the venue shares one public IP, so an IP-keyed limiter throttles the whole
party as a single client. Three separate limits got that wrong, and the host — whose only
credential is a 4-digit PIN — was the one who could not absorb it.

/recover carried a cross-name failure budget checked BEFORE the account lookup, so it
refused a CORRECT PIN. Thirty POSTs with invented names spent the shared budget for fifteen
minutes and ~2 requests/minute sustained it indefinitely, denying PIN recovery to everyone
including a host locked out of their own event. The budget is now carried as a flag: a
correct PIN authenticates regardless, while wrong ones answer 429 instead of 401. Guessing
stays bounded where it always really was — the per-(IP,name) ceiling and the per-account
3-strike lockout, neither of which an attacker on any IP can evade.

join_ip went from 60/min to 300. A 100-guest wedding does not trickle in; it arrives when
the QR code goes up, all from one address, and guests 61-100 were turned away on the one
screen with no auto-retry. This limit only bounds raw volume — the per-name bucket is the
anti-spam control and BCRYPT_PERMITS is the CPU bound — so it can sit well above the peak.

Download tickets are now bound to ONE archive via `TicketKind::Download(ExportKind)`. Both
download routes share an authenticator, so a bare ticket opened either; combined with the
resume budget that made a single mint worth 40 transfers of a multi-GB keepsake while the
per-day limiter, charged only at mint, never moved. `kind` is consequently required at
/export/ticket; every shipped client already sends it.

The per-session ticket cap is now per-kind. A 6-hour download ticket is always the oldest
entry for its session, so ordinary SSE churn evicted it first — and /export opens its own
SSE connection on the session that just minted it. A couple of wifi flaps mid-transfer
killed the ticket, 401'd the resume, and cost the guest another of three daily downloads.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 22:43:29 +02:00

567 lines
24 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use rand::Rng;
/// Short-lived single-use tickets that let `EventSource` clients open the SSE
/// stream without putting the JWT in the URL (where it would leak via access
/// logs / referer / browser history).
///
/// Flow: client `POST /api/v1/stream/ticket` with `Authorization: Bearer <jwt>`,
/// server returns an opaque ticket, client passes it as `?ticket=...` on the
/// stream open. Tickets are consumed on use and expire after `TTL`.
const TTL: Duration = Duration::from_secs(30);
/// Lifetime of a `Download` ticket, and it is deliberately far longer than [`TTL`].
///
/// A keepsake is up to ~1.4 GB over hotel or cellular wifi, so the download itself outlives a 30 s
/// window many times over — and a resumed transfer arrives minutes or hours after the ticket was
/// minted. A `Download` ticket is therefore a short-lived capability for ONE archive rather than a
/// single-shot nonce: [`SseTicketStore::redeem_download`] does not remove it, so a client may
/// resume with `Range` as many times as the transfer needs.
///
/// The abuse this does NOT open: the ticket is bound to a session (revoked with it), only mints at
/// `/export/ticket` where the 3/day limit is charged, and grants nothing but this event's own
/// keepsake — which every authenticated guest is entitled to download anyway. What it buys is that
/// one dropped connection no longer costs a guest a third of their daily allowance, at the
/// emotional payoff of the product.
const DOWNLOAD_TTL: Duration = Duration::from_secs(6 * 60 * 60);
/// The lifetime that applies to a given kind.
fn ttl_for(kind: TicketKind) -> Duration {
match kind {
TicketKind::Download(_) => DOWNLOAD_TTL,
TicketKind::Sse => TTL,
}
}
/// Ceiling on outstanding tickets across the whole process.
///
/// Not really about the bytes (~120 each) — about `issue` having had no bound of any kind.
/// Sized well above a real event: ~1000 concurrent clients each holding one live 30 s ticket.
const MAX_TICKETS: usize = 4096;
/// Live tickets one session may hold. Above 1 because two tabs sharing a token open their
/// EventSources concurrently; 4 absorbs that without letting a reconnect loop accumulate.
const MAX_TICKETS_PER_SESSION: usize = 4;
/// How many times one download ticket may be redeemed.
///
/// Making the ticket non-consuming is what lets a dropped transfer resume without spending another
/// of the guest's three daily downloads — but unbounded it also meant a single mint was an
/// unlimited download key for six hours, at BOTH archive endpoints, with the per-day limiter
/// (charged only at mint) never moving. On a 40 GB box serving ~1.4 GB archives that is the one
/// resource an ordinary guest could exhaust without doing anything obviously wrong.
///
/// 20 is far more than a resumed transfer needs (a browser retries a handful of times, not dozens)
/// and turns "unbounded until the ticket expires" into a bounded multiple. It does not make the
/// daily limit exact — that would mean charging per redemption, which would bill a client that
/// restarts from byte 0 instead of sending a `Range`, i.e. re-break the thing this exists to fix.
const MAX_DOWNLOAD_REDEMPTIONS: u32 = 20;
/// What a ticket may be redeemed for.
///
/// The store began life serving only SSE and stayed untyped when the export download started
/// reusing it, which silently made the two interchangeable. That is not a theoretical mixing
/// concern: `POST /stream/ticket` is rate-limited at 60/min per user and charges nothing, while
/// `POST /export/ticket` charges one of three PER-DAY downloads. An untyped ticket let any guest
/// mint at the cheap endpoint and redeem at the expensive one, so the daily export limit was
/// bypassable ~60×/minute — each redemption streaming the whole multi-GB keepsake, `no-store`,
/// off the same filesystem Postgres writes WAL to.
///
/// `consume` therefore requires the kind to MATCH. A ticket is only ever valid for the thing it
/// was minted for.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum TicketKind {
/// Opens the SSE stream (`GET /stream`). Cheap, high volume.
Sse,
/// Downloads ONE export archive. Expensive, rate-limited per day.
///
/// The archive is part of the ticket, not incidental to it. A bare `Download` ticket was
/// accepted by BOTH `/export/zip` and `/export/html` — they share one authenticator — so with
/// the redemption budget that makes a ticket resumable, a single mint authorised 20 transfers
/// spread across both archives. Three mints a day therefore bought 60 full downloads of a
/// ~1.4 GB keepsake, while the per-day limiter (charged only at mint) never moved.
Download(ExportKind),
}
/// Which archive a [`TicketKind::Download`] is good for.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum ExportKind {
/// `Gallery.<event>.<n>.zip` — the original media.
Zip,
/// `Memories.<event>.<n>.zip` — the offline HTML viewer.
Html,
}
#[derive(Clone)]
pub struct SseTicketStore {
inner: Arc<Mutex<HashMap<String, Entry>>>,
}
#[derive(Clone)]
struct Entry {
token_hash: String,
issued_at: Instant,
kind: TicketKind,
/// Times this ticket has been redeemed. Only meaningful for `Download` — see
/// [`MAX_DOWNLOAD_REDEMPTIONS`].
redemptions: u32,
}
impl SseTicketStore {
pub fn new() -> Self {
Self {
inner: Arc::new(Mutex::new(HashMap::new())),
}
}
/// Drop every outstanding ticket. Used by the e2e TRUNCATE endpoint: tickets are bound to a
/// session token hash, and TRUNCATE deletes the sessions out from under them, so anything left
/// here is a dangling reference to a user that no longer exists.
pub fn clear(&self) {
self.inner.lock().unwrap().clear();
}
/// Mint a new ticket bound to the caller's session (identified by token hash).
///
/// `None` when the store is at capacity — the caller should answer 503, not evict.
///
/// Three bounds, because `issue` had none: no size cap, no per-caller cap, and no rate
/// limit on the endpoint, while `prune` ran only hourly against a 30-second TTL. So any
/// authenticated session could loop the endpoint and grow the map for an hour.
pub fn issue(&self, token_hash: String, kind: TicketKind) -> Option<String> {
let ticket = random_ticket();
let mut map = self.inner.lock().unwrap();
// Prune on issue rather than only hourly. This alone changes the bound from "tickets
// minted since the last maintenance tick" to "tickets live at once", which is what the
// 30 s TTL was always meant to express.
map.retain(|_, e| e.issued_at.elapsed() <= ttl_for(e.kind));
// Cap the caller's own outstanding tickets, evicting their oldest. NOT one-per-session:
// two tabs sharing a token open their EventSources concurrently, and having tab B
// invalidate tab A's unconsumed ticket looks exactly like a flaky SSE connection.
//
// SCOPED TO THE SAME KIND, which matters now that `Download` tickets live 6 h instead of
// being consumed on first use. A long-lived download ticket is ALWAYS the oldest entry for
// its session, so a kind-blind cap made it the first thing ordinary SSE churn threw away —
// and `/export` itself opens an SSE connection on the very session that just minted it.
// A couple of reconnects during the download (a wifi flap is enough; each attempt that
// returns early abandons an unconsumed ticket) evicted the ticket out from under a running
// transfer, so the next `Range` resume 401'd and the guest had to spend another of their
// three daily downloads. Two flaps and they were locked out of their own keepsake for a day.
//
// Per-kind, an SSE reconnect storm can still only evict SSE tickets, which is what the cap
// was written for; the guest's in-flight keepsake is no longer collateral.
let mut mine: Vec<(String, Instant)> = map
.iter()
.filter(|(_, e)| e.token_hash == token_hash && e.kind == kind)
.map(|(k, e)| (k.clone(), e.issued_at))
.collect();
if mine.len() >= MAX_TICKETS_PER_SESSION {
mine.sort_by_key(|(_, issued)| *issued);
for (key, _) in mine.iter().take(mine.len() - MAX_TICKETS_PER_SESSION + 1) {
map.remove(key);
}
}
// At capacity, REFUSE — never evict a stranger's ticket. Evicting would let one
// misbehaving client deny SSE to the whole venue, which is worse than failing the
// request that hit the ceiling.
if map.len() >= MAX_TICKETS {
tracing::warn!(
outstanding = map.len(),
"SSE ticket store at capacity; refusing to mint"
);
return None;
}
map.insert(
ticket.clone(),
Entry {
token_hash,
issued_at: Instant::now(),
kind,
redemptions: 0,
},
);
Some(ticket)
}
/// Consume a ticket minted for `kind`. Returns `Some(token_hash)` if the ticket exists, is
/// not expired, and was minted for this purpose. Single-use: the ticket is removed regardless
/// of whether it was still fresh, so a replay can't slip through after expiry.
///
/// A ticket of the WRONG kind is also removed. It was a valid ticket the caller legitimately
/// held, so this is not punitive — but leaving it would let a redemption loop probe the store
/// without ever spending anything, and the client has no legitimate reason to present a
/// ticket at the wrong endpoint.
/// Redeem a `Download` ticket WITHOUT consuming it.
///
/// Downloads must be resumable — see [`DOWNLOAD_TTL`]. A browser resumes by re-issuing the same
/// GET with a `Range` header, so a single-use ticket made `Accept-Ranges` a lie: the retry
/// authenticated against a ticket that the interrupted attempt had already spent, 401'd, and
/// the guest had to mint a new one, spending another of their three daily downloads. Two
/// dropped connections and they were locked out of their own keepsake for ~24 hours.
///
/// Still bound to a live session: the caller re-checks the session on every request, so
/// revoking a session (logout, "sign out everywhere", a host PIN reset) kills the download too.
pub fn redeem_download(&self, ticket: &str, want: ExportKind) -> Option<String> {
let mut map = self.inner.lock().unwrap();
let entry = map.get_mut(ticket)?;
// The archive must match the one this ticket was minted for. Both download routes share
// this authenticator, so without the payload check a ZIP ticket opened the HTML archive
// too and the redemption budget was spent across both.
if entry.kind != TicketKind::Download(want) {
tracing::warn!(
found = ?entry.kind,
?want,
"ticket presented for the wrong archive (or wrong kind); rejected"
);
return None;
}
if entry.issued_at.elapsed() > DOWNLOAD_TTL {
return None;
}
if entry.redemptions >= MAX_DOWNLOAD_REDEMPTIONS {
tracing::warn!(
redemptions = entry.redemptions,
"download ticket exceeded its redemption budget; refusing"
);
return None;
}
entry.redemptions += 1;
Some(entry.token_hash.clone())
}
pub fn consume(&self, ticket: &str, kind: TicketKind) -> Option<String> {
let mut map = self.inner.lock().unwrap();
let entry = map.remove(ticket)?;
if entry.issued_at.elapsed() > ttl_for(entry.kind) {
return None;
}
if entry.kind != kind {
tracing::warn!(
expected = ?kind,
found = ?entry.kind,
"ticket presented at the wrong endpoint; rejected"
);
return None;
}
Some(entry.token_hash)
}
/// Drop expired entries — called from the background maintenance task so a
/// long-running process doesn't accumulate stale tickets.
pub fn prune(&self) {
let mut map = self.inner.lock().unwrap();
map.retain(|_, e| e.issued_at.elapsed() <= ttl_for(e.kind));
}
}
fn random_ticket() -> String {
// 192 bits of randomness, base32-ish hex. Plenty of entropy and URL-safe.
let mut rng = rand::rng();
let mut bytes = [0u8; 24];
rng.fill(&mut bytes);
bytes.iter().map(|b| format!("{b:02x}")).collect()
}
#[cfg(test)]
mod tests {
use super::*;
/// `issue` now returns `Option`; in every test below the store is far from capacity, so an
/// `expect` here documents that refusing is exceptional rather than routine.
fn issue(store: &SseTicketStore, hash: &str) -> String {
store
.issue(hash.into(), TicketKind::Sse)
.expect("store has capacity")
}
/// The store is shared by two endpoints with wildly different costs: `/stream/ticket` is
/// 60/min per user and free, `/export/ticket` charges one of three PER-DAY downloads. While
/// entries were untyped, a ticket minted at the cheap endpoint opened the expensive one — so
/// the daily export limit could be bypassed ~60×/minute, each redemption streaming the whole
/// multi-GB keepsake off the disk Postgres writes WAL to.
///
/// Asserted in BOTH directions so this cannot be "fixed" by a check that only guards one.
#[test]
fn a_ticket_is_only_valid_for_the_purpose_it_was_minted_for() {
let store = SseTicketStore::new();
let sse = store.issue("h".into(), TicketKind::Sse).unwrap();
assert_eq!(
store.consume(&sse, TicketKind::Download(ExportKind::Zip)),
None,
"an SSE ticket must not open the export download"
);
let dl = store
.issue("h".into(), TicketKind::Download(ExportKind::Zip))
.unwrap();
assert_eq!(
store.consume(&dl, TicketKind::Sse),
None,
"a download ticket must not open the SSE stream"
);
// And the matching cases still work, so the guard is not simply rejecting everything.
let sse = store.issue("h".into(), TicketKind::Sse).unwrap();
assert_eq!(store.consume(&sse, TicketKind::Sse).as_deref(), Some("h"));
let dl = store
.issue("h".into(), TicketKind::Download(ExportKind::Zip))
.unwrap();
assert_eq!(
store
.consume(&dl, TicketKind::Download(ExportKind::Zip))
.as_deref(),
Some("h")
);
}
#[test]
fn issue_then_consume_returns_the_hash_exactly_once() {
let store = SseTicketStore::new();
let ticket = issue(&store, "hash-1");
assert_eq!(
store.consume(&ticket, TicketKind::Sse).as_deref(),
Some("hash-1")
);
// Single-use: a replay of the same ticket is rejected.
assert_eq!(
store.consume(&ticket, TicketKind::Sse),
None,
"a consumed ticket must not be reusable"
);
}
#[test]
fn unknown_ticket_consumes_to_none() {
let store = SseTicketStore::new();
assert_eq!(store.consume("never-issued", TicketKind::Sse), None);
}
#[test]
fn issued_tickets_are_unique_and_hex() {
let store = SseTicketStore::new();
let a = issue(&store, "h");
let b = issue(&store, "h");
assert_ne!(a, b, "each ticket must be unique");
assert_eq!(a.len(), 48, "24 random bytes → 48 hex chars");
assert!(a.chars().all(|c| c.is_ascii_hexdigit()));
}
#[test]
fn fresh_ticket_survives_prune() {
let store = SseTicketStore::new();
let ticket = issue(&store, "h");
store.prune(); // not expired → kept
assert_eq!(
store.consume(&ticket, TicketKind::Sse).as_deref(),
Some("h")
);
}
/// Build an entry that is already past the TTL.
fn insert_stale(store: &SseTicketStore, key: &str, token_hash: &str) {
store.inner.lock().unwrap().insert(
key.to_string(),
Entry {
kind: TicketKind::Sse,
token_hash: token_hash.into(),
issued_at: Instant::now()
.checked_sub(TTL + Duration::from_secs(1))
.expect("host uptime should exceed the ticket TTL"),
redemptions: 0,
},
);
}
#[test]
fn expired_ticket_consumes_to_none() {
let store = SseTicketStore::new();
insert_stale(&store, "stale-ticket", "h");
assert_eq!(
store.consume("stale-ticket", TicketKind::Sse),
None,
"an expired ticket must not authenticate"
);
}
/// The TTL is 30 s but `prune` only ran hourly, so the map was really bounded by "tickets
/// minted in the last hour" — which is unbounded for a client in a loop.
#[test]
fn issuing_prunes_expired_entries() {
let store = SseTicketStore::new();
insert_stale(&store, "stale-a", "someone-else");
insert_stale(&store, "stale-b", "someone-else");
issue(&store, "h");
assert_eq!(
store.inner.lock().unwrap().len(),
1,
"issue must reclaim expired slots, not merely add to them"
);
}
/// Two tabs sharing a token is normal, so the per-session cap must be above 1 — but a
/// reconnect loop must not accumulate. The caller's OWN oldest is what gets evicted.
#[test]
fn a_session_is_capped_and_evicts_only_its_own_oldest() {
let store = SseTicketStore::new();
let stranger = issue(&store, "other-session");
let mut mine: Vec<String> = Vec::new();
for _ in 0..MAX_TICKETS_PER_SESSION + 2 {
mine.push(issue(&store, "mine"));
}
let live = mine
.iter()
.filter(|t| store.inner.lock().unwrap().contains_key(*t))
.count();
assert_eq!(live, MAX_TICKETS_PER_SESSION, "one session, bounded");
assert!(
store
.inner
.lock()
.unwrap()
.contains_key(&mine[mine.len() - 1]),
"the newest ticket is the one the caller is about to use"
);
assert_eq!(
store.consume(&stranger, TicketKind::Sse).as_deref(),
Some("other-session"),
"another session's ticket must survive — evicting it would let one client deny \
SSE to the venue"
);
}
/// At capacity the store REFUSES rather than evicting a stranger. Refusing fails the one
/// request that hit the ceiling; evicting would break an unrelated client's live stream.
#[test]
fn at_capacity_the_store_refuses_instead_of_evicting() {
let store = SseTicketStore::new();
{
let mut map = store.inner.lock().unwrap();
for i in 0..MAX_TICKETS {
map.insert(
format!("filler-{i}"),
Entry {
kind: TicketKind::Sse,
token_hash: format!("session-{i}"),
issued_at: Instant::now(),
redemptions: 0,
},
);
}
}
assert_eq!(
store.issue("newcomer".into(), TicketKind::Sse),
None,
"a full store must refuse, so the caller can answer 503"
);
assert!(
store.inner.lock().unwrap().contains_key("filler-0"),
"no existing ticket may be sacrificed to make room"
);
}
/// A download ticket is deliberately non-consuming so a dropped 1.4 GB transfer can resume
/// without spending one of the guest's three daily downloads. Unbounded, though, that made a
/// single mint an unlimited download key for six hours while the per-day limiter — charged
/// only at mint — never moved. This pins the bound without breaking resumption.
#[test]
fn a_download_ticket_resumes_freely_but_not_forever() {
let store = SseTicketStore::new();
let ticket = store
.issue("session-a".into(), TicketKind::Download(ExportKind::Zip))
.expect("fresh store should issue");
// Every redemption inside the budget returns the session, so a resumed transfer works.
for i in 0..MAX_DOWNLOAD_REDEMPTIONS {
assert_eq!(
store.redeem_download(&ticket, ExportKind::Zip).as_deref(),
Some("session-a"),
"redemption {i} should still be honoured"
);
}
// Past it the ticket is spent: the guest re-mints (and is charged) rather than holding
// an open-ended key.
assert_eq!(store.redeem_download(&ticket, ExportKind::Zip), None);
}
#[test]
fn a_download_ticket_opens_only_the_archive_it_was_minted_for() {
// Both download routes share one authenticator, so without the archive in the ticket a
// single mint was good for BOTH. Combined with the resume budget that made one mint worth
// 2 x MAX_DOWNLOAD_REDEMPTIONS transfers of a multi-GB keepsake, while the per-day limit —
// charged only at mint — never moved.
let store = SseTicketStore::new();
let zip = store
.issue("s".into(), TicketKind::Download(ExportKind::Zip))
.unwrap();
assert_eq!(
store.redeem_download(&zip, ExportKind::Html),
None,
"a ZIP ticket must not open the HTML archive"
);
assert_eq!(
store.redeem_download(&zip, ExportKind::Zip).as_deref(),
Some("s"),
"...and the refusal above must be about the archive, not a spent ticket"
);
let html = store
.issue("s".into(), TicketKind::Download(ExportKind::Html))
.unwrap();
assert_eq!(store.redeem_download(&html, ExportKind::Zip), None);
assert_eq!(
store.redeem_download(&html, ExportKind::Html).as_deref(),
Some("s")
);
}
#[test]
fn sse_churn_cannot_evict_a_running_download() {
// A `Download` ticket lives 6 h, so it is ALWAYS the oldest entry for its session — and a
// kind-blind per-session cap therefore threw it away first. `/export` opens its own SSE
// connection on the same session, so a couple of reconnects during the transfer evicted
// the ticket out from under it: the next `Range` resume 401'd and the guest spent another
// of their three daily downloads. Two wifi flaps and they lost their keepsake for a day.
let store = SseTicketStore::new();
let download = store
.issue("one-session".into(), TicketKind::Download(ExportKind::Zip))
.unwrap();
// Far more SSE churn than the per-session cap, all on the same session.
for _ in 0..(MAX_TICKETS_PER_SESSION * 3) {
store.issue("one-session".into(), TicketKind::Sse).unwrap();
}
assert_eq!(
store.redeem_download(&download, ExportKind::Zip).as_deref(),
Some("one-session"),
"an in-flight keepsake download must survive an SSE reconnect storm"
);
}
/// The kind split is what stops a free SSE ticket from redeeming a rate-limited download.
#[test]
fn an_sse_ticket_is_never_redeemable_as_a_download() {
let store = SseTicketStore::new();
let sse = store
.issue("session-b".into(), TicketKind::Sse)
.expect("fresh store should issue");
assert_eq!(store.redeem_download(&sse, ExportKind::Zip), None);
// And it is still usable for what it IS, so the rejection above is about kind, not
// the ticket having been quietly spent.
assert_eq!(
store.consume(&sse, TicketKind::Sse).as_deref(),
Some("session-b")
);
}
}