Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/monetize

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");
    }
}