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:
@@ -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());
|
||||
|
||||
Reference in New Issue
Block a user