From 06bbe7b94bfb846bd73aaf6abdc83e4c14e70adc Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Sun, 29 Mar 2026 13:16:11 +0000 Subject: [PATCH] fix: add per-IP and per-account brute force protection on login endpoint (#8601) Co-authored-by: Claude Opus 4.6 (1M context) --- backend/Cargo.lock | 1 + backend/windmill-api-users/src/users.rs | 8 +- backend/windmill-common/Cargo.toml | 1 + backend/windmill-common/src/lib.rs | 1 + .../windmill-common/src/login_rate_limit.rs | 102 ++++++++++++++++++ 5 files changed, 112 insertions(+), 1 deletion(-) create mode 100644 backend/windmill-common/src/login_rate_limit.rs diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 230c822b31..45d100732d 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -16564,6 +16564,7 @@ dependencies = [ "crc", "cron", "croner", + "dashmap 6.1.0", "datafusion", "equivalent", "futures", diff --git a/backend/windmill-api-users/src/users.rs b/backend/windmill-api-users/src/users.rs index 4bb61183ce..57eb0cfd85 100644 --- a/backend/windmill-api-users/src/users.rs +++ b/backend/windmill-api-users/src/users.rs @@ -1746,6 +1746,7 @@ async fn set_login_type( #[allow(unreachable_code, unused_variables)] async fn login( + headers: axum::http::HeaderMap, cookies: Cookies, Extension(db): Extension, Extension(argon2): Extension>>, @@ -1756,8 +1757,11 @@ async fn login( return Ok("no_auth".to_string()); } - let mut tx = db.begin().await?; let email = email.to_lowercase(); + let client_ip = windmill_common::login_rate_limit::extract_client_ip(&headers); + windmill_common::login_rate_limit::check_login_rate_limit(&client_ip, &email)?; + + let mut tx = db.begin().await?; let audit_author = AuditAuthor { email: email.clone(), username: email.clone(), @@ -1789,6 +1793,7 @@ async fn login( None, ) .await?; + windmill_common::login_rate_limit::record_login_failure(&client_ip, &email); Err(Error::BadRequest("Invalid login".to_string())) } else { let token = create_session_token(&email, super_admin, &mut tx, cookies).await?; @@ -1825,6 +1830,7 @@ async fn login( None, ) .await?; + windmill_common::login_rate_limit::record_login_failure(&client_ip, &email); Err(Error::BadRequest("Invalid login".to_string())) } } diff --git a/backend/windmill-common/Cargo.toml b/backend/windmill-common/Cargo.toml index d593787def..fcf69c13ee 100644 --- a/backend/windmill-common/Cargo.toml +++ b/backend/windmill-common/Cargo.toml @@ -117,6 +117,7 @@ pin-project-lite.workspace = true futures.workspace = true tempfile.workspace = true globset.workspace = true +dashmap.workspace = true opentelemetry-semantic-conventions = { workspace = true, optional = true } opentelemetry-otlp = { workspace = true, optional = true } diff --git a/backend/windmill-common/src/lib.rs b/backend/windmill-common/src/lib.rs index deb1c38a03..98fb7bdebf 100644 --- a/backend/windmill-common/src/lib.rs +++ b/backend/windmill-common/src/lib.rs @@ -69,6 +69,7 @@ pub mod git_sync_ee; pub mod git_sync_oss; pub mod jobs; pub mod jwt; +pub mod login_rate_limit; pub mod more_serde; pub mod oauth2; #[cfg(all(feature = "enterprise", feature = "openidconnect", feature = "private"))] diff --git a/backend/windmill-common/src/login_rate_limit.rs b/backend/windmill-common/src/login_rate_limit.rs new file mode 100644 index 0000000000..c444ce6b19 --- /dev/null +++ b/backend/windmill-common/src/login_rate_limit.rs @@ -0,0 +1,102 @@ +use chrono::Utc; +use dashmap::DashMap; +use hyper::StatusCode; +use std::sync::LazyLock; + +use crate::error::{Error, Result}; + +const DEFAULT_PER_IP_LIMIT: i32 = 10; +const DEFAULT_PER_ACCOUNT_LIMIT: i32 = 5; + +struct RateLimitEntry { + count: i32, + minute_bucket: i64, +} + +static IP_RATE_LIMIT: LazyLock> = LazyLock::new(DashMap::new); +static ACCOUNT_RATE_LIMIT: LazyLock> = LazyLock::new(DashMap::new); + +static PER_IP_LIMIT: LazyLock = LazyLock::new(|| { + std::env::var("LOGIN_RATE_LIMIT_PER_IP") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(DEFAULT_PER_IP_LIMIT) +}); + +static PER_ACCOUNT_LIMIT: LazyLock = LazyLock::new(|| { + std::env::var("LOGIN_RATE_LIMIT_PER_ACCOUNT") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(DEFAULT_PER_ACCOUNT_LIMIT) +}); + +pub fn extract_client_ip(headers: &axum::http::HeaderMap) -> String { + if let Some(real_ip) = headers.get("x-real-ip") { + if let Ok(ip) = real_ip.to_str() { + let trimmed = ip.trim(); + if !trimmed.is_empty() { + return trimmed.to_string(); + } + } + } + + if let Some(forwarded_for) = headers.get("x-forwarded-for") { + if let Ok(ips) = forwarded_for.to_str() { + if let Some(first_ip) = ips.split(',').next() { + let trimmed = first_ip.trim(); + if !trimmed.is_empty() { + return trimmed.to_string(); + } + } + } + } + + "unknown".to_string() +} + +fn check_rate_limit(map: &DashMap, key: &str, limit: i32) -> Result<()> { + let current_minute = Utc::now().timestamp() / 60; + + let entry = map + .entry(key.to_string()) + .or_insert(RateLimitEntry { count: 0, minute_bucket: current_minute }); + + if entry.minute_bucket != current_minute { + return Ok(()); + } + + if entry.count >= limit { + return Err(Error::Generic( + StatusCode::TOO_MANY_REQUESTS, + "Too many login attempts. Please try again later.".to_string(), + )); + } + + Ok(()) +} + +fn record_failure(map: &DashMap, key: &str) { + let current_minute = Utc::now().timestamp() / 60; + + let mut entry = map + .entry(key.to_string()) + .or_insert(RateLimitEntry { count: 0, minute_bucket: current_minute }); + + if entry.minute_bucket != current_minute { + entry.count = 1; + entry.minute_bucket = current_minute; + } else { + entry.count += 1; + } +} + +pub fn check_login_rate_limit(ip: &str, email: &str) -> Result<()> { + check_rate_limit(&IP_RATE_LIMIT, ip, *PER_IP_LIMIT)?; + check_rate_limit(&ACCOUNT_RATE_LIMIT, email, *PER_ACCOUNT_LIMIT)?; + Ok(()) +} + +pub fn record_login_failure(ip: &str, email: &str) { + record_failure(&IP_RATE_LIMIT, ip); + record_failure(&ACCOUNT_RATE_LIMIT, email); +}