fix: add per-IP and per-account brute force protection on login endpoint (#8601)

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ruben Fiszel
2026-03-29 13:16:11 +00:00
committed by GitHub
co-authored by Claude Opus 4.6
parent 970e859a41
commit 06bbe7b94b
5 changed files with 112 additions and 1 deletions
+1
View File
@@ -16564,6 +16564,7 @@ dependencies = [
"crc",
"cron",
"croner",
"dashmap 6.1.0",
"datafusion",
"equivalent",
"futures",
+7 -1
View File
@@ -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<DB>,
Extension(argon2): Extension<Arc<Argon2<'_>>>,
@@ -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()))
}
}
+1
View File
@@ -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 }
+1
View File
@@ -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"))]
@@ -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<DashMap<String, RateLimitEntry>> = LazyLock::new(DashMap::new);
static ACCOUNT_RATE_LIMIT: LazyLock<DashMap<String, RateLimitEntry>> = LazyLock::new(DashMap::new);
static PER_IP_LIMIT: LazyLock<i32> = 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<i32> = 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<String, RateLimitEntry>, 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<String, RateLimitEntry>, 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);
}