diff --git a/config.yml b/config.yml index a613a43..7da0ba5 100644 --- a/config.yml +++ b/config.yml @@ -102,5 +102,10 @@ session_lifetime: '1 hour' # Redirecting backends might require lax here cookie_samesite: 'strict' +# Whether to trust Forwarded/X-Forwarded-For headers for the client ip +# (used by login rate limiting). Only enable when running behind a +# reverse proxy that sets these headers, they are spoofable otherwise. +trust_proxy_headers: false + listen: '::' port: 8080 diff --git a/src/config/config.rs b/src/config/config.rs index 34a3135..f75f59e 100644 --- a/src/config/config.rs +++ b/src/config/config.rs @@ -35,6 +35,9 @@ pub struct Config { pub session_lifetime: Duration, #[serde(with = "SameSiteDef")] pub cookie_samesite: cookie::SameSite, + // only enable behind a reverse proxy: forwarding headers are + // client-controlled and would let rate limiting be evaded + pub trust_proxy_headers: bool, pub search: Search, #[serde(alias = "LDAP", alias = "Ldap")] pub ldap: Ldap, @@ -63,6 +66,7 @@ impl Default for Config { // 1 hour session_lifetime: Duration::from_secs(60 * 60), cookie_samesite: cookie::SameSite::Strict, + trust_proxy_headers: false, search: Default::default(), ldap: Default::default(), oidc: Default::default(), diff --git a/src/main.rs b/src/main.rs index b23fe95..3477e32 100644 --- a/src/main.rs +++ b/src/main.rs @@ -9,6 +9,7 @@ mod config; mod server; mod auth; mod keepass; +mod rate_limit; mod session; const CONFIG_FILE: &str = "config.yml"; diff --git a/src/rate_limit.rs b/src/rate_limit.rs new file mode 100644 index 0000000..776c5c5 --- /dev/null +++ b/src/rate_limit.rs @@ -0,0 +1,128 @@ +use std::collections::HashMap; +use std::time::{Duration, Instant}; + +use tokio::sync::Mutex; + +// failures before lockouts start +const FREE_FAILURES: u32 = 5; +const BASE_DELAY: Duration = Duration::from_secs(2); +const MAX_DELAY: Duration = Duration::from_secs(15 * 60); + +struct Failures { + count: u32, + last_failure: Instant, +} + +impl Failures { + fn blocked_until(&self) -> Option { + if self.count <= FREE_FAILURES { + return None; + } + // exponential backoff: 2s, 4s, 8s, ... capped at 15 minutes + let exponent = (self.count - FREE_FAILURES - 1).min(30); + let delay = BASE_DELAY.saturating_mul(2u32.saturating_pow(exponent)).min(MAX_DELAY); + Some(self.last_failure + delay) + } + + fn expired(&self, now: Instant) -> bool { + now.saturating_duration_since(self.last_failure) > MAX_DELAY + } +} + +// in-memory login throttle with exponential backoff, +// keyed by caller-provided strings (e.g. "|") +#[derive(Default)] +pub struct RateLimiter { + entries: Mutex>, +} + +impl RateLimiter { + // remaining lockout, if the key is currently blocked + pub async fn check(&self, key: &str) -> Option { + let entries = self.entries.lock().await; + let blocked_until = entries.get(key)?.blocked_until()?; + let now = Instant::now(); + if now < blocked_until { + return Some(blocked_until - now); + } + None + } + + pub async fn failure(&self, key: &str) { + let mut entries = self.entries.lock().await; + + // drop stale entries so the map doesn't grow unboundedly + let now = Instant::now(); + entries.retain(|_, f| !f.expired(now)); + + let failures = entries.entry(key.to_string()).or_insert(Failures { + count: 0, + last_failure: now, + }); + failures.count += 1; + failures.last_failure = now; + } + + pub async fn success(&self, key: &str) { + self.entries.lock().await.remove(key); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn blocks_after_free_failures() { + let limiter = RateLimiter::default(); + let key = "1.2.3.4|alice"; + + for _ in 0..FREE_FAILURES { + limiter.failure(key).await; + assert!(limiter.check(key).await.is_none()); + } + + limiter.failure(key).await; + assert!(limiter.check(key).await.is_some()); + } + + #[tokio::test] + async fn success_resets() { + let limiter = RateLimiter::default(); + let key = "1.2.3.4|alice"; + + for _ in 0..FREE_FAILURES + 3 { + limiter.failure(key).await; + } + assert!(limiter.check(key).await.is_some()); + + limiter.success(key).await; + assert!(limiter.check(key).await.is_none()); + } + + #[tokio::test] + async fn keys_are_independent() { + let limiter = RateLimiter::default(); + + for _ in 0..FREE_FAILURES + 1 { + limiter.failure("1.2.3.4|alice").await; + } + assert!(limiter.check("1.2.3.4|alice").await.is_some()); + assert!(limiter.check("5.6.7.8|alice").await.is_none()); + assert!(limiter.check("1.2.3.4|bob").await.is_none()); + } + + #[test] + fn backoff_escalates_and_caps() { + let now = Instant::now(); + let delay = |count| Failures { count, last_failure: now } + .blocked_until() + .map(|until| until - now); + + assert_eq!(delay(FREE_FAILURES), None); + assert_eq!(delay(FREE_FAILURES + 1), Some(Duration::from_secs(2))); + assert_eq!(delay(FREE_FAILURES + 2), Some(Duration::from_secs(4))); + assert_eq!(delay(FREE_FAILURES + 3), Some(Duration::from_secs(8))); + assert_eq!(delay(FREE_FAILURES + 100), Some(MAX_DELAY)); + } +} diff --git a/src/server/route/auth.rs b/src/server/route/auth.rs index 37f3723..7779d2a 100644 --- a/src/server/route/auth.rs +++ b/src/server/route/auth.rs @@ -12,6 +12,7 @@ use crate::auth_backend::{AuthCache, SESSION_KEY_AUTH_STATE, UserInfo}; use crate::config::config::Config; use crate::keepass::db_cache::DbCache; use crate::keepass::keepass::KeePass; +use crate::rate_limit::RateLimiter; use crate::server::route::INDEX_FILE; use crate::server::route::util::{_close_db, check_user_session, db_is_open, revoke_key, set_user_session, store_key}; use crate::session::AuthSession; @@ -54,16 +55,35 @@ async fn authenticated(session: Session, config: Data, db_cache: Data, params: web::Form) -> impl Responder { +async fn user_login(request: HttpRequest, session: Session, config: Data, rate_limiter: Data, params: web::Form) -> impl Responder { if let Err(err) = check_user_session(&session, ¶ms.username) { return err; } + // forwarding headers are spoofable, only honor them when explicitly + // configured (i.e. behind a reverse proxy that sets them) + let client_ip = if config.trust_proxy_headers { + request.connection_info().realip_remote_addr().unwrap_or("unknown").to_string() + } else { + request.peer_addr().map(|addr| addr.ip().to_string()).unwrap_or_else(|| "unknown".to_string()) + }; + let rate_key = format!("{}|{}", client_ip, params.username.to_lowercase()); + if let Some(remaining) = rate_limiter.check(&rate_key).await { + info!("user login from '{}' ({}): rate limited for {}s", params.username, client_ip, remaining.as_secs()); + return HttpResponse::TooManyRequests().json(json!( + { + "success": false, + "message": "too many failed login attempts, try again later", + } + )); + } + let auth_backend = auth_backend::new(&config); // TODO: differentiate between real error and login failed let user_info = match auth_backend.login(params.username.as_str(), params.password.as_str()).await { Ok(user_info) => user_info, Err(err) => { + rate_limiter.failure(&rate_key).await; info!("user login from '{}': {}", params.username, err); return HttpResponse::Unauthorized().json(json!( { @@ -73,6 +93,7 @@ async fn user_login(session: Session, config: Data, params: web::Form v, diff --git a/src/server/server.rs b/src/server/server.rs index a03420c..e7b7ddb 100644 --- a/src/server/server.rs +++ b/src/server/server.rs @@ -8,6 +8,7 @@ use env_logger::Env; use crate::{auth, auth_backend}; use crate::config::config::Config; use crate::keepass::db_cache::DbCache; +use crate::rate_limit::RateLimiter; use crate::server::route::setup_routes; pub struct Server; @@ -22,11 +23,13 @@ impl Server { let config_data = web::Data::new(config); let auth_cache = web::Data::new(auth_backend::new(&config_data).init().await?); let db_cache = web::Data::new(DbCache::default()); + let rate_limiter = web::Data::new(RateLimiter::default()); HttpServer::new(move || { App::new() .app_data(db_cache.clone()) .app_data(auth_cache.clone()) + .app_data(rate_limiter.clone()) .app_data(config_data.clone()) .wrap(auth::CheckAuth) .wrap(