crates/monetize-site-buy/src/limit.rs
//! **The buy doors' budgets per client** — Gunnar's console's two-tier token bucket
//! (gunnar-ui-server `security::RateLimit`, `Buckets`, and the three budgets its `Webauthn`
//! holds), moved as it is, so every site that sells keeps a paying customer's page working
//! while a guesser is refused.
//!
//! # Why one bucket per address would refuse the page it is protecting
//!
//! The welcome page polls **every two seconds** — thirty requests a minute for ONE honest
//! visitor, every one of them about the same session. A flat "30 a minute per address" is not
//! a limit on abuse but a coin flip on whether a paying customer's page keeps working, and
//! behind a proxy that puts every visitor in one bucket it is worse than that. So there are
//! two tiers:
//!
//! * **per `(address, key)`**, `per_key` a minute: what an honest visitor spends, generous
//! because polling is the design. The key is the session, the name or the order asked about.
//! * **per address, charged ONLY for a key that address has not asked about before**,
//! `new_keys` a minute: the one that bites a guesser, because every guess is a new key — and
//! it costs an honest visitor exactly one token, once.
//!
//! The maps are capped, so an attacker's own traffic cannot grow them without bound, and both
//! tiers are decided under one lock.
use std::collections::HashMap;
use std::sync::Mutex;
use std::time::{Duration, Instant};
/// One key's tokens, and when they were last counted.
#[derive(Debug)]
struct Bucket {
tokens: f64,
seen: Instant,
}
/// **Token buckets by key, capped.** A bucket idle for a minute is full again — the same as no
/// bucket — so those go first; if the map is still crowded the least-recently-seen go, an
/// eighth of the cap at a time.
#[derive(Debug)]
struct Buckets {
rate: f64,
cap: usize,
held: HashMap<String, Bucket>,
}
impl Buckets {
fn new(per_minute: u32, cap: usize) -> Buckets {
Buckets { rate: f64::from(per_minute), cap, held: HashMap::new() }
}
/// Spend one token from `key`'s bucket, refilling it for the time passed.
fn take(&mut self, key: &str, now: Instant) -> bool {
if self.held.len() >= self.cap && !self.held.contains_key(key) {
self.make_room(now);
}
let rate = self.rate;
let bucket = self.held.entry(key.to_owned()).or_insert(Bucket { tokens: rate, seen: now });
let elapsed = now.saturating_duration_since(bucket.seen).as_secs_f64();
bucket.tokens = (bucket.tokens + elapsed * rate / 60.0).min(rate);
bucket.seen = now;
if bucket.tokens < 1.0 {
return false;
}
bucket.tokens -= 1.0;
true
}
fn make_room(&mut self, now: Instant) {
let full_again = Duration::from_secs(60);
self.held.retain(|_, b| now.saturating_duration_since(b.seen) < full_again);
let keep = self.cap - self.cap / 8;
if self.held.len() > keep {
let mut seen: Vec<Instant> = self.held.values().map(|b| b.seen).collect();
let cut = seen.len() - keep;
let (_, oldest_kept, _) = seen.select_nth_unstable(cut);
let oldest_kept = *oldest_kept;
self.held.retain(|_, b| b.seen >= oldest_kept);
let mut ties = self.held.len().saturating_sub(keep);
self.held.retain(|_, b| {
let drop = ties > 0 && b.seen == oldest_kept;
ties -= usize::from(drop);
!drop
});
}
}
}
#[derive(Debug)]
struct Tiers {
pairs: Buckets,
ips: Buckets,
}
/// **A two-tier token bucket** (see the module).
#[derive(Debug)]
pub struct RateLimit {
held: Mutex<Tiers>,
}
impl RateLimit {
/// `per_key` requests a minute for one (address, key), and `new_keys` distinct keys a minute
/// from one address.
pub fn new(per_key: u32, new_keys: u32) -> RateLimit {
RateLimit { held: Mutex::new(Tiers { pairs: Buckets::new(per_key, 4096), ips: Buckets::new(new_keys, 4096) }) }
}
/// May this address ask about this key right now?
pub fn allow(&self, ip: &str, key: &str) -> bool {
self.allow_at(ip, key, Instant::now())
}
/// [`Self::allow`] at `now` (a test's stopped clock).
pub fn allow_at(&self, ip: &str, key: &str, now: Instant) -> bool {
// `\u{1f}` (unit separator) cannot appear in either half, so no pair is spelled two ways.
let pair = format!("{ip}\u{1f}{key}");
let mut t = self.held.lock().unwrap_or_else(|p| p.into_inner());
let fresh = !t.pairs.held.contains_key(&pair);
if fresh && !t.ips.take(ip, now) {
return false;
}
t.pairs.take(&pair, now)
}
}
/// **The three budgets Gunnar's console keeps for the buy doors**, by the relay's door names:
/// `signup` (the welcome page's polling, sign-in-again, rename: 120 a minute per session, 30
/// new sessions), `signup_name` (the name check and the sign-up start: 10 a minute per name,
/// 30 new names) and `private` (the private-instance doors: 60 a minute per key, 30 new keys).
#[derive(Debug)]
pub struct Budgets {
signup: RateLimit,
signup_name: RateLimit,
private: RateLimit,
}
impl Default for Budgets {
fn default() -> Budgets {
Budgets { signup: RateLimit::new(120, 30), signup_name: RateLimit::new(10, 30), private: RateLimit::new(60, 30) }
}
}
impl Budgets {
/// [`crate::BuySite::allowed`], as Gunnar's console answers it.
pub fn allow(&self, door: &str, ip: &str, key: &str) -> bool {
self.allow_at(door, ip, key, Instant::now())
}
/// [`Self::allow`] at `now`.
pub fn allow_at(&self, door: &str, ip: &str, key: &str, now: Instant) -> bool {
match door {
"signup" => self.signup.allow_at(ip, key, now),
"signup_name" => self.signup_name.allow_at(ip, key, now),
_ => self.private.allow_at(ip, key, now),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
/// ★ **The welcome page's own polling is never refused**: one visitor asking about one
/// session every two seconds for minutes, and a few sign-in-again presses beside it.
#[test]
fn a_paying_customers_page_keeps_working() {
let b = Budgets::default();
let t0 = Instant::now();
for i in 0..300u64 {
assert!(b.allow_at("signup", "203.0.113.9", "cs_test_abcdefghij", t0 + Duration::from_secs(2 * i)), "poll {i} refused");
}
}
/// ★ **A guesser is refused**: every guess is a new session from the same address.
#[test]
fn a_guesser_is_refused_after_thirty_new_keys_a_minute() {
let b = Budgets::default();
let t0 = Instant::now();
let let_in = (0..100).filter(|i| b.allow_at("signup", "203.0.113.9", &format!("cs_test_guess{i:04}"), t0)).count();
assert_eq!(let_in, 30);
assert!(b.allow_at("signup", "198.51.100.7", "cs_test_other", t0), "another address has its own budget");
let names = (0..50).filter(|_| b.allow_at("signup_name", "203.0.113.9", "x14", t0)).count();
assert_eq!(names, 10, "ten asks a minute about one name");
}
}