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>
This commit is contained in:
fabi
2026-08-11 22:43:29 +02:00
parent 963f6449a1
commit ec7c7f18ca
5 changed files with 906 additions and 83 deletions

View File

@@ -7,7 +7,23 @@ use std::time::{Duration, Instant};
/// of recent requests and rejects new ones once the window is full.
#[derive(Clone)]
pub struct RateLimiter {
windows: Arc<Mutex<HashMap<String, Vec<Instant>>>>,
windows: Arc<Mutex<HashMap<String, Bucket>>>,
}
/// One key's recent hits, plus the window they were recorded under.
///
/// The `window` field is what makes pruning correct. `prune` used a single fixed 24 h ceiling for
/// every key, on the reasoning that 24 h is the longest window in use (export downloads) — but that
/// meant a `join:{ip}:{name}` key whose 60-SECOND window expired 23 hours ago was still retained.
/// Minting one costs a single 409 and no bcrypt, at 60/min per IP across three endpoints: roughly
/// 172,800 keys/day/IP, about 31 MB/day/IP inside a 1 GB container. The limiter became the
/// memory-exhaustion primitive it exists to prevent.
///
/// Storing the window per key makes the sweep drop each bucket as soon as ITS OWN window has
/// elapsed, which is also what the hot path already does on every check.
struct Bucket {
hits: Vec<Instant>,
window: Duration,
}
impl RateLimiter {
@@ -34,7 +50,14 @@ impl RateLimiter {
let now = Instant::now();
let key = key.into();
let mut map = self.windows.lock().unwrap();
let timestamps = map.entry(key).or_default();
let bucket = map.entry(key).or_insert_with(|| Bucket {
hits: Vec::new(),
window,
});
// A key's window can change under it when an admin edits the limit at runtime. Track the
// current one so `prune` expires the bucket on the window actually in force.
bucket.window = window;
let timestamps = &mut bucket.hits;
timestamps.retain(|&t| now.duration_since(t) < window);
if timestamps.len() < max {
timestamps.push(now);
@@ -58,6 +81,37 @@ impl RateLimiter {
}
}
/// Is `key` already at or above `max`, WITHOUT recording a hit?
///
/// Needed by limiters whose budget is spent by an outcome rather than by the request — the
/// per-IP failed-PIN ceiling charges only on a wrong PIN, so the gate at the top of the handler
/// has to be able to ask "is this IP shut out?" without itself consuming the budget it guards.
/// Using `check_with_retry` for that would charge every *successful* recovery too, and a venue
/// full of guests legitimately recovering their own devices would lock itself out.
///
/// Returns `Err(retry_after_secs)` when exhausted, mirroring `check_with_retry` so callers can
/// build the same 429.
pub fn peek(&self, key: &str, max: usize, window: Duration) -> Result<(), u64> {
let now = Instant::now();
let mut map = self.windows.lock().unwrap();
let Some(bucket) = map.get_mut(key) else {
return Ok(());
};
bucket
.hits
.retain(|&t| now.duration_since(t) < bucket.window);
if bucket.hits.len() < max {
return Ok(());
}
let Some(&oldest) = bucket.hits.first() else {
return Ok(());
};
Err(window
.saturating_sub(now.duration_since(oldest))
.as_secs()
.max(1))
}
/// Wipe every tracked window. Used by the test-mode truncate route so a previous
/// test's accumulated counters don't bleed into the next test's rate-limit checks.
pub fn clear(&self) {
@@ -68,17 +122,23 @@ impl RateLimiter {
/// background task (see [`crate::services::maintenance`]) so that long-lived
/// processes don't accumulate one HashMap entry per IP that ever connected.
///
/// Uses a conservative 24h ceiling — anything older than that is gone regardless
/// of which endpoint's window it was tracked under (the longest window today is
/// 24h for export downloads). If we ever add longer windows, raise this constant.
/// Expires each bucket against ITS OWN window (see [`Bucket`]), not one global ceiling. The
/// previous fixed 24 h ceiling retained per-minute keys for a full day — ~172,800 keys/day/IP
/// at 60/min across three endpoints, each mintable with a single 409 and no bcrypt.
///
/// Holds the one global mutex for the length of the sweep, and that mutex is on the hot path of
/// upload, feed, join, recover, social and export — so the retain does the cheap thing per
/// bucket and nothing else. Correct pruning also keeps the map small enough that this stays
/// cheap, which the old ceiling actively undermined.
pub fn prune(&self) {
let now = Instant::now();
let ceiling = Duration::from_secs(24 * 60 * 60);
let mut map = self.windows.lock().unwrap();
let before = map.len();
map.retain(|_, ts| {
ts.retain(|&t| now.duration_since(t) < ceiling);
!ts.is_empty()
map.retain(|_, bucket| {
bucket
.hits
.retain(|&t| now.duration_since(t) < bucket.window);
!bucket.hits.is_empty()
});
let dropped = before.saturating_sub(map.len());
if dropped > 0 {
@@ -230,15 +290,20 @@ mod tests {
fn prune_drops_keys_whose_windows_have_fully_expired() {
let rl = RateLimiter::new();
// A key whose only timestamp is older than the 24h ceiling. We can't sleep for a day,
// so backdate the Instant directly.
// A key whose only timestamp is older than its own window. We can't sleep, so backdate
// the Instant directly. A ONE-MINUTE window here on purpose: the old prune applied a flat
// 24 h ceiling to every key, so this bucket — expired for over an hour of wall time —
// survived the sweep. That is the leak (H3), and pinning it needs a short-window key.
let ancient = Instant::now()
.checked_sub(Duration::from_secs(25 * 60 * 60))
.expect("backdating an Instant by 25h");
rl.windows
.lock()
.unwrap()
.insert("stale".to_string(), vec![ancient]);
.checked_sub(Duration::from_secs(90 * 60))
.expect("backdating an Instant by 90 minutes");
rl.windows.lock().unwrap().insert(
"stale".to_string(),
Bucket {
hits: vec![ancient],
window: MIN,
},
);
// ...alongside a key that is still inside its window.
assert!(rl.check_with_retry("live", 5, MIN).is_ok());

View File

@@ -13,6 +13,29 @@ use rand::Rng;
/// 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.
@@ -23,6 +46,20 @@ const MAX_TICKETS: usize = 4096;
/// 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
@@ -39,8 +76,23 @@ const MAX_TICKETS_PER_SESSION: usize = 4;
pub enum TicketKind {
/// Opens the SSE stream (`GET /stream`). Cheap, high volume.
Sse,
/// Downloads an export archive (`GET /export/{zip,html}`). Expensive, rate-limited per day.
Download,
/// 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)]
@@ -53,6 +105,9 @@ 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 {
@@ -83,14 +138,26 @@ impl SseTicketStore {
// 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);
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)
.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 {
@@ -117,6 +184,7 @@ impl SseTicketStore {
token_hash,
issued_at: Instant::now(),
kind,
redemptions: 0,
},
);
Some(ticket)
@@ -130,10 +198,48 @@ impl SseTicketStore {
/// 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 {
if entry.issued_at.elapsed() > ttl_for(entry.kind) {
return None;
}
if entry.kind != kind {
@@ -151,7 +257,7 @@ impl SseTicketStore {
/// 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);
map.retain(|_, e| e.issued_at.elapsed() <= ttl_for(e.kind));
}
}
@@ -188,12 +294,14 @@ mod tests {
let sse = store.issue("h".into(), TicketKind::Sse).unwrap();
assert_eq!(
store.consume(&sse, TicketKind::Download),
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).unwrap();
let dl = store
.issue("h".into(), TicketKind::Download(ExportKind::Zip))
.unwrap();
assert_eq!(
store.consume(&dl, TicketKind::Sse),
None,
@@ -203,9 +311,13 @@ mod tests {
// 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).unwrap();
let dl = store
.issue("h".into(), TicketKind::Download(ExportKind::Zip))
.unwrap();
assert_eq!(
store.consume(&dl, TicketKind::Download).as_deref(),
store
.consume(&dl, TicketKind::Download(ExportKind::Zip))
.as_deref(),
Some("h")
);
}
@@ -214,7 +326,10 @@ mod tests {
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"));
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),
@@ -244,7 +359,10 @@ mod tests {
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"));
assert_eq!(
store.consume(&ticket, TicketKind::Sse).as_deref(),
Some("h")
);
}
/// Build an entry that is already past the TTL.
@@ -257,6 +375,7 @@ mod tests {
issued_at: Instant::now()
.checked_sub(TTL + Duration::from_secs(1))
.expect("host uptime should exceed the ticket TTL"),
redemptions: 0,
},
);
}
@@ -305,7 +424,11 @@ mod tests {
.count();
assert_eq!(live, MAX_TICKETS_PER_SESSION, "one session, bounded");
assert!(
store.inner.lock().unwrap().contains_key(&mine[mine.len() - 1]),
store
.inner
.lock()
.unwrap()
.contains_key(&mine[mine.len() - 1]),
"the newest ticket is the one the caller is about to use"
);
assert_eq!(
@@ -330,6 +453,7 @@ mod tests {
kind: TicketKind::Sse,
token_hash: format!("session-{i}"),
issued_at: Instant::now(),
redemptions: 0,
},
);
}
@@ -344,4 +468,99 @@ mod tests {
"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")
);
}
}