mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-11 16:09:39 +00:00
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:
co-authored by
Claude Opus 4.6
parent
970e859a41
commit
06bbe7b94b
Generated
+1
@@ -16564,6 +16564,7 @@ dependencies = [
|
||||
"crc",
|
||||
"cron",
|
||||
"croner",
|
||||
"dashmap 6.1.0",
|
||||
"datafusion",
|
||||
"equivalent",
|
||||
"futures",
|
||||
|
||||
@@ -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()))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user