Lines
95.05 %
Functions
63.41 %
Branches
100 %
//! Per-IP and per-account throttles for authentication attempts.
//!
//! The daemon keeps all this in process memory — the plan explicitly
//! does not require multi-host consistency. Pure state + an injected
//! clock so tests can drive the time axis deterministically.
use std::collections::HashMap;
use std::net::IpAddr;
use std::time::{Duration, Instant};
/// Configuration knobs applied to every `RateLimiter`.
#[derive(Debug, Clone, Copy)]
pub struct Config {
/// Maximum *password* authentication attempts an IP may make inside
/// one window before further attempts are refused.
pub max_attempts_per_ip: u32,
/// Maximum *publickey* authentication attempts an IP may make
/// inside one window. Looser than the password bucket because a
/// single ssh client legitimately offers several identities per
/// connection.
pub max_pubkey_attempts_per_ip: u32,
/// Width of the sliding window, measured from the first attempt.
pub window: Duration,
/// After this many consecutive *password* failures on one
/// account, the account enters lockout.
pub account_lockout_threshold: u32,
/// How long an account stays locked out once the threshold is
/// tripped.
pub account_lockout_duration: Duration,
}
impl Default for Config {
fn default() -> Self {
Self {
max_attempts_per_ip: 6,
max_pubkey_attempts_per_ip: 30,
window: Duration::from_mins(1),
account_lockout_threshold: 3,
account_lockout_duration: Duration::from_mins(15),
/// Abstraction over "what time is it now" so tests don't hang on
/// real clocks. The daemon uses `InstantClock`; tests use
/// `MockClock`.
pub trait Clock {
fn now(&self) -> Instant;
/// Default clock that delegates to `std::time::Instant::now`.
#[derive(Debug, Default, Clone, Copy)]
pub struct InstantClock;
impl Clock for InstantClock {
fn now(&self) -> Instant {
Instant::now()
/// Outcome of a rate-limit check, returned by [`RateLimiter::check_ip`]
/// and [`RateLimiter::check_account`].
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Decision {
/// Proceed with the authentication attempt.
Allow,
/// Refuse the attempt. The attacker should see a generic
/// failure — not "you're rate limited", which leaks information.
Deny,
#[derive(Debug, Default)]
struct IpState {
/// Timestamps of attempts that still fall inside the active
/// window. Prunded on every observation.
attempts: Vec<Instant>,
struct AccountState {
consecutive_failures: u32,
locked_until: Option<Instant>,
/// In-memory rate-limit + lockout tracker. Not `Send`/`Sync` on its
/// own — the daemon wraps it in a `tokio::sync::Mutex`.
pub struct RateLimiter<C: Clock> {
config: Config,
clock: C,
ip: HashMap<IpAddr, IpState>,
pubkey_ip: HashMap<IpAddr, IpState>,
account: HashMap<String, AccountState>,
impl<C: Clock> RateLimiter<C> {
#[must_use]
pub fn new(config: Config, clock: C) -> Self {
config,
clock,
ip: HashMap::new(),
pubkey_ip: HashMap::new(),
account: HashMap::new(),
/// Consider a fresh authentication attempt from `ip`. Records
/// the attempt regardless of the return value so the window
/// covers bursts that never reach the server's auth callbacks.
pub fn check_ip(&mut self, ip: IpAddr) -> Decision {
Self::tick_bucket(
&mut self.ip,
ip,
self.clock.now(),
self.config.window,
self.config.max_attempts_per_ip,
)
/// Same as [`check_ip`] but for the publickey bucket — separate
/// counter so a normal client offering multiple identities does
/// not trip the password limiter.
pub fn check_pubkey_ip(&mut self, ip: IpAddr) -> Decision {
&mut self.pubkey_ip,
self.config.max_pubkey_attempts_per_ip,
fn tick_bucket(
bucket: &mut HashMap<IpAddr, IpState>,
ip: IpAddr,
now: Instant,
window: Duration,
limit: u32,
) -> Decision {
let entry = bucket.entry(ip).or_default();
entry.attempts.retain(|t| now.duration_since(*t) < window);
let seen = u32::try_from(entry.attempts.len()).unwrap_or(u32::MAX);
if seen >= limit {
return Decision::Deny;
entry.attempts.push(now);
Decision::Allow
/// Should a password attempt against `account` be allowed right
/// now? Pair with [`record_password_failure`] / [`reset_account`]
/// once the auth backend has adjudicated the attempt.
pub fn check_account(&mut self, account: &str) -> Decision {
let now = self.clock.now();
let state = self.account.entry(account.to_string()).or_default();
if let Some(until) = state.locked_until
&& now < until
{
if state.locked_until.is_some() {
state.locked_until = None;
state.consecutive_failures = 0;
pub fn record_password_failure(&mut self, account: &str) {
state.consecutive_failures += 1;
if state.consecutive_failures >= self.config.account_lockout_threshold {
state.locked_until = Some(now + self.config.account_lockout_duration);
/// Reset the consecutive-failure counter for an account — call
/// this on a successful authentication.
pub fn reset_account(&mut self, account: &str) {
if let Some(state) = self.account.get_mut(account) {
#[cfg(test)]
mod tests {
use super::*;
use std::cell::Cell;
use std::net::Ipv4Addr;
/// Clock whose `now()` value the test drives by hand.
pub struct MockClock {
now: Cell<Instant>,
impl MockClock {
pub fn new() -> Self {
now: Cell::new(Instant::now()),
pub fn advance(&self, by: Duration) {
self.now.set(self.now.get() + by);
impl Clock for &MockClock {
self.now.get()
fn cfg() -> Config {
Config {
max_attempts_per_ip: 3,
max_pubkey_attempts_per_ip: 5,
window: Duration::from_secs(10),
account_lockout_threshold: 2,
account_lockout_duration: Duration::from_mins(1),
fn loopback() -> IpAddr {
IpAddr::V4(Ipv4Addr::LOCALHOST)
#[test]
fn ip_allows_up_to_the_limit_then_denies() {
let clock = MockClock::new();
let mut rl = RateLimiter::new(cfg(), &clock);
assert_eq!(rl.check_ip(loopback()), Decision::Allow);
assert_eq!(
rl.check_ip(loopback()),
Decision::Deny,
"fourth attempt in-window must be denied"
);
fn ip_window_expires_and_resets_allowance() {
for _ in 0..3 {
rl.check_ip(loopback());
assert_eq!(rl.check_ip(loopback()), Decision::Deny);
clock.advance(Duration::from_secs(11));
Decision::Allow,
"after window expiry the limiter must forget"
fn account_locks_after_threshold_failures() {
assert_eq!(rl.check_account("alice"), Decision::Allow);
rl.record_password_failure("alice");
rl.check_account("alice"),
"second failure trips the lockout"
fn account_unlocks_after_duration() {
rl.record_password_failure("bob");
assert_eq!(rl.check_account("bob"), Decision::Deny);
clock.advance(Duration::from_secs(61));
assert_eq!(rl.check_account("bob"), Decision::Allow);
fn reset_clears_accumulated_failures() {
rl.record_password_failure("carol");
rl.reset_account("carol");
rl.check_account("carol"),
"counter must be cleared by reset"
fn pubkey_bucket_is_independent_of_password_bucket() {
"password bucket exhausted"
rl.check_pubkey_ip(loopback()),
"pubkey bucket must not share state with the password bucket"
fn pubkey_bucket_enforces_its_own_limit() {
for _ in 0..5 {
assert_eq!(rl.check_pubkey_ip(loopback()), Decision::Allow);
"sixth pubkey attempt in-window must be denied"
fn different_ips_are_tracked_independently() {
let a: IpAddr = "10.0.0.1".parse().unwrap();
let b: IpAddr = "10.0.0.2".parse().unwrap();
rl.check_ip(a);
assert_eq!(rl.check_ip(a), Decision::Deny);
rl.check_ip(b),
"a different IP must not inherit another's denial"