From 41d2d7ef77c26819d1faede7a0547f2727fa8464 Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Sat, 5 Sep 2026 09:47:27 +0200 Subject: [PATCH] feat: slow down repeated failed logins MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The login endpoint answered guesses as fast as they arrived, against a single static password. Wrong attempts now back off — 1s, 2s, 4s, up to 30s — and a correct one clears the count. It is a single counter rather than a table keyed by address: there is only one account, so an attacker gains nothing by rotating source IPs, and there is no per-client map to grow. The cap is there so that someone hammering the login delays the owner by half a minute rather than locking them out. Wrong credentials now answer 401 rather than 400. The server also warns at startup if DIARY_PASSWORD is still the value from .env.example. --- src/config.rs | 7 ++++ src/error.rs | 6 +++ src/main.rs | 2 + src/routes/session.rs | 10 ++++- src/state.rs | 3 +- src/throttle.rs | 84 ++++++++++++++++++++++++++++++++++++++++ web/src/lib/Login.svelte | 10 ++++- 7 files changed, 119 insertions(+), 3 deletions(-) create mode 100644 src/throttle.rs diff --git a/src/config.rs b/src/config.rs index c0441ea..fd21e97 100644 --- a/src/config.rs +++ b/src/config.rs @@ -32,6 +32,13 @@ impl Config { anyhow::bail!("DIARY_SECRET must be at least 16 characters"); } + if password == "change-me" { + tracing::warn!( + "DIARY_PASSWORD is still the example value from .env.example — \ + anyone who can reach this server can read the diary" + ); + } + let bind = var("DIARY_BIND") .unwrap_or_else(|| "127.0.0.1:4242".to_string()) .parse() diff --git a/src/error.rs b/src/error.rs index 1bf4045..196165c 100644 --- a/src/error.rs +++ b/src/error.rs @@ -10,6 +10,8 @@ pub enum AppError { Unauthorized, NotFound, BadRequest(String), + /// The caller must wait this many seconds before trying again. + TooManyRequests(i64), Internal(anyhow::Error), } @@ -21,6 +23,10 @@ impl IntoResponse for AppError { AppError::Unauthorized => (StatusCode::UNAUTHORIZED, "unauthorized".to_string()), AppError::NotFound => (StatusCode::NOT_FOUND, "not found".to_string()), AppError::BadRequest(m) => (StatusCode::BAD_REQUEST, m), + AppError::TooManyRequests(wait) => ( + StatusCode::TOO_MANY_REQUESTS, + format!("too many attempts — wait {wait}s"), + ), AppError::Internal(e) => { tracing::error!("internal error: {e:#}"); ( diff --git a/src/main.rs b/src/main.rs index 1a10300..97b5f10 100644 --- a/src/main.rs +++ b/src/main.rs @@ -4,6 +4,7 @@ mod error; mod routes; mod state; mod static_files; +mod throttle; use std::sync::Arc; @@ -69,6 +70,7 @@ async fn main() -> Result<()> { let state = AppState { db, config: Arc::new(config), + login_throttle: Arc::default(), }; let app = Router::new() diff --git a/src/routes/session.rs b/src/routes/session.rs index 594a846..54b3fbb 100644 --- a/src/routes/session.rs +++ b/src/routes/session.rs @@ -23,9 +23,17 @@ pub async fn login( State(state): State, Json(body): Json, ) -> AppResult { + state.login_throttle.check().map_err(|wait| { + tracing::warn!("login refused, {wait}s of backoff remaining"); + AppError::TooManyRequests(wait) + })?; + if !auth::credentials_match(&state.config, &body.username, &body.password) { - return Err(AppError::BadRequest("invalid credentials".into())); + state.login_throttle.record_failure(); + return Err(AppError::Unauthorized); } + + state.login_throttle.record_success(); let token = auth::issue_token(&state.config); Ok(( StatusCode::OK, diff --git a/src/state.rs b/src/state.rs index 4da1f74..6abde7f 100644 --- a/src/state.rs +++ b/src/state.rs @@ -2,10 +2,11 @@ use std::sync::Arc; use sqlx::SqlitePool; -use crate::config::Config; +use crate::{config::Config, throttle::LoginThrottle}; #[derive(Clone)] pub struct AppState { pub db: SqlitePool, pub config: Arc, + pub login_throttle: Arc, } diff --git a/src/throttle.rs b/src/throttle.rs new file mode 100644 index 0000000..ed595a7 --- /dev/null +++ b/src/throttle.rs @@ -0,0 +1,84 @@ +use std::sync::Mutex; + +/// Failed logins get progressively slower to answer. +/// +/// The diary has exactly one account, so this is one counter rather than a map +/// keyed by address: an attacker cannot step around it by changing source IP, +/// and there is no per-client table to grow. The cost of that choice is that +/// someone hammering the login can slow the owner's own attempt down — which is +/// why the wait is capped at `MAX_DELAY` seconds instead of locking the account. +#[derive(Debug, Default)] +pub struct LoginThrottle { + state: Mutex, +} + +#[derive(Debug, Default)] +struct State { + failures: u32, + next_allowed: i64, +} + +/// Wrong passwords happen; the first few are answered at full speed. +const FREE_ATTEMPTS: u32 = 3; +const MAX_DELAY: i64 = 30; + +/// 1s, 2s, 4s, 8s… up to `MAX_DELAY`, starting once the free attempts are used. +fn delay_after(failures: u32) -> i64 { + match failures.checked_sub(FREE_ATTEMPTS) { + None | Some(0) => 0, + Some(over) => 1i64.checked_shl(over - 1).unwrap_or(MAX_DELAY).min(MAX_DELAY), + } +} + +impl LoginThrottle { + /// `Err(seconds)` when this attempt is too soon after the last failure. + pub fn check(&self) -> Result<(), i64> { + let state = self.state.lock().unwrap_or_else(|e| e.into_inner()); + match state.next_allowed - crate::now() { + wait if wait > 0 => Err(wait), + _ => Ok(()), + } + } + + pub fn record_failure(&self) { + let mut state = self.state.lock().unwrap_or_else(|e| e.into_inner()); + state.failures = state.failures.saturating_add(1); + state.next_allowed = crate::now() + delay_after(state.failures); + } + + pub fn record_success(&self) { + *self.state.lock().unwrap_or_else(|e| e.into_inner()) = State::default(); + } +} + +#[cfg(test)] +mod tests { + use super::{delay_after, LoginThrottle, MAX_DELAY}; + + #[test] + fn the_first_few_mistakes_are_free() { + assert_eq!(delay_after(0), 0); + assert_eq!(delay_after(3), 0); + } + + #[test] + fn then_the_wait_doubles_up_to_the_cap() { + assert_eq!(delay_after(4), 1); + assert_eq!(delay_after(5), 2); + assert_eq!(delay_after(6), 4); + assert_eq!(delay_after(9), 30); + // Never overflows, however long the attack runs. + assert_eq!(delay_after(u32::MAX), MAX_DELAY); + } + + #[test] + fn a_correct_password_clears_the_debt() { + let throttle = LoginThrottle::default(); + for _ in 0..10 { + throttle.record_failure(); + } + assert!(throttle.check().is_err()); + throttle.record_success(); + assert!(throttle.check().is_ok()); + } +} diff --git a/web/src/lib/Login.svelte b/web/src/lib/Login.svelte index 0e8fb57..1e8c616 100644 --- a/web/src/lib/Login.svelte +++ b/web/src/lib/Login.svelte @@ -1,4 +1,5 @@