Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
4 changes: 4 additions & 0 deletions src/config/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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(),
Expand Down
1 change: 1 addition & 0 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ mod config;
mod server;
mod auth;
mod keepass;
mod rate_limit;
mod session;

const CONFIG_FILE: &str = "config.yml";
Expand Down
128 changes: 128 additions & 0 deletions src/rate_limit.rs
Original file line number Diff line number Diff line change
@@ -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<Instant> {
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. "<ip>|<username>")
#[derive(Default)]
pub struct RateLimiter {
entries: Mutex<HashMap<String, Failures>>,
}

impl RateLimiter {
// remaining lockout, if the key is currently blocked
pub async fn check(&self, key: &str) -> Option<Duration> {
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));
}
}
23 changes: 22 additions & 1 deletion src/server/route/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -54,16 +55,35 @@ async fn authenticated(session: Session, config: Data<Config>, db_cache: Data<Db


#[post("/user_login")]
async fn user_login(session: Session, config: Data<Config>, params: web::Form<UserLogin>) -> impl Responder {
async fn user_login(request: HttpRequest, session: Session, config: Data<Config>, rate_limiter: Data<RateLimiter>, params: web::Form<UserLogin>) -> impl Responder {
if let Err(err) = check_user_session(&session, &params.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!(
{
Expand All @@ -73,6 +93,7 @@ async fn user_login(session: Session, config: Data<Config>, params: web::Form<Us
));
}
};
rate_limiter.success(&rate_key).await;

let csrf_token = match set_user_session(session, &user_info) {
Ok(v) => v,
Expand Down
3 changes: 3 additions & 0 deletions src/server/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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(
Expand Down
Loading