fix(audit-2026-06-11/H-B1): login rate limit + Argon2 concurrency cap
The login handler had no admission control before `spawn_blocking(verify_password)`. On Pi-class hardware Argon2id is ~50- 100 ms per attempt, so a few dozen concurrent anonymous POSTs to /auth/login park every blocking worker, wedging the entire admin API and any other path that uses `spawn_blocking`. Adds two cheap, in-process guards modeled on EmailRateLimiter: * `LoginRateLimiter` — two sliding-window token buckets, one per `(remote_ip, username)` (burst 5/60s) and one per `username` (burst 10/15min). Per-(ip, user) defeats a single attacker pounding one account; per-user defeats credential-stuffing distributed across many IPs. Username is case-folded so case variants share a bucket. Maps GC lazily when they cross a soft size cap. * Per-process `tokio::sync::Semaphore` — caps concurrent Argon2 verifies during login. Default 2 permits (Pi-class), overridable via `PICLOUD_LOGIN_ARGON2_PARALLELISM`. Acquired AFTER the bucket check so attackers can't queue. Limit check fires before the DB credentials lookup, so even an unknown username doesn't cost a query. Denied attempts return 429 + Retry-After. Real client IP comes from the first X-Forwarded-For entry (Caddy is the trusted single hop); falls back to "unknown" so the per-user bucket still gates. 4 unit tests cover bucket burst exhaustion, per-user crossing IPs, case folding, and per-user independence. Audit ref: security_audit/08_dos_resource.md (H-2 / H-B1). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -16,7 +16,7 @@
|
||||
|
||||
use axum::body::Body;
|
||||
use axum::extract::{Extension, Request, State};
|
||||
use axum::http::{header, StatusCode};
|
||||
use axum::http::{header, HeaderMap, HeaderValue, StatusCode};
|
||||
use axum::middleware::from_fn_with_state;
|
||||
use axum::response::{IntoResponse, Json, Response};
|
||||
use axum::routing::{get, post};
|
||||
@@ -30,6 +30,7 @@ use picloud_shared::Principal;
|
||||
|
||||
use crate::auth::{generate_session_token, hash_token, verify_password};
|
||||
use crate::auth_middleware::{require_authenticated, AuthState};
|
||||
use crate::login_rate_limit::LoginRateOutcome;
|
||||
|
||||
pub fn auth_router(state: AuthState) -> Router {
|
||||
// /login + /logout are unguarded (login is how you get in; logout
|
||||
@@ -75,7 +76,23 @@ pub struct AdminUserDto {
|
||||
// Handlers
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
async fn login(State(state): State<AuthState>, Json(input): Json<LoginRequest>) -> Response {
|
||||
async fn login(
|
||||
State(state): State<AuthState>,
|
||||
headers: HeaderMap,
|
||||
Json(input): Json<LoginRequest>,
|
||||
) -> Response {
|
||||
// Audit 2026-06-11 H-B1 — rate limit BEFORE any DB read or
|
||||
// Argon2 verify so an attacker can't even queue. Keying is
|
||||
// (X-Forwarded-For first hop, username) + (username), so a single
|
||||
// hot user and a credential-stuffing flurry both saturate.
|
||||
let remote_ip = extract_remote_ip(&headers);
|
||||
if let LoginRateOutcome::Denied { retry_after } = state
|
||||
.login_rate_limiter
|
||||
.try_consume(&remote_ip, &input.username)
|
||||
{
|
||||
return rate_limited(retry_after);
|
||||
}
|
||||
|
||||
// Always perform a verify, even on missing/inactive users, to flatten
|
||||
// timing and prevent username enumeration. The dummy hash is the
|
||||
// shared `TIMING_FLAT_DUMMY_HASH` constant from `auth.rs` so the
|
||||
@@ -99,6 +116,18 @@ async fn login(State(state): State<AuthState>, Json(input): Json<LoginRequest>)
|
||||
None => (crate::auth::TIMING_FLAT_DUMMY_HASH.to_string(), None, false),
|
||||
};
|
||||
|
||||
// Audit 2026-06-11 H-B1 — global Argon2 concurrency cap. Acquired
|
||||
// AFTER the bucket check so attackers can't queue; held only across
|
||||
// the verify, not the surrounding DB I/O. acquire() awaits, but the
|
||||
// bucket already gated so the queue is bounded.
|
||||
let permit = match state.argon2_login_semaphore.clone().acquire_owned().await {
|
||||
Ok(p) => p,
|
||||
Err(_) => {
|
||||
// Semaphore is closed (process shutting down). Fail closed.
|
||||
return internal_error();
|
||||
}
|
||||
};
|
||||
|
||||
// F-P-002: Argon2id verify off the async worker.
|
||||
let password = input.password.clone();
|
||||
let password_ok =
|
||||
@@ -106,9 +135,11 @@ async fn login(State(state): State<AuthState>, Json(input): Json<LoginRequest>)
|
||||
Ok(b) => b,
|
||||
Err(err) => {
|
||||
tracing::error!(?err, "verify_password spawn_blocking join failed");
|
||||
drop(permit);
|
||||
return internal_error();
|
||||
}
|
||||
};
|
||||
drop(permit);
|
||||
if !password_ok || user_id.is_none() || !is_active {
|
||||
return invalid_credentials();
|
||||
}
|
||||
@@ -212,6 +243,44 @@ fn extract_token_for_logout(req: &Request<Body>) -> Option<String> {
|
||||
Some(token.to_string())
|
||||
}
|
||||
|
||||
/// Pull the originating client IP from the request. Caddy is the only
|
||||
/// trusted proxy in front of picloud; X-Forwarded-For carries the
|
||||
/// client. We take the first entry (closest to the original client).
|
||||
/// Returns `"unknown"` if the header is absent/malformed so the
|
||||
/// per-user bucket still gates without an IP qualifier.
|
||||
fn extract_remote_ip(headers: &HeaderMap) -> String {
|
||||
if let Some(xff) = headers.get("x-forwarded-for") {
|
||||
if let Ok(s) = xff.to_str() {
|
||||
if let Some(first) = s.split(',').next() {
|
||||
let trimmed = first.trim();
|
||||
if !trimmed.is_empty() {
|
||||
return trimmed.to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"unknown".to_string()
|
||||
}
|
||||
|
||||
fn rate_limited(retry_after: std::time::Duration) -> Response {
|
||||
let mut secs = retry_after.as_secs();
|
||||
if secs == 0 {
|
||||
secs = 1;
|
||||
}
|
||||
let mut hdrs = HeaderMap::new();
|
||||
if let Ok(v) = HeaderValue::from_str(&secs.to_string()) {
|
||||
hdrs.insert("retry-after", v);
|
||||
}
|
||||
(
|
||||
StatusCode::TOO_MANY_REQUESTS,
|
||||
hdrs,
|
||||
Json(json!({
|
||||
"error": "too many login attempts; try again later",
|
||||
})),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
fn invalid_credentials() -> Response {
|
||||
(
|
||||
StatusCode::UNAUTHORIZED,
|
||||
|
||||
Reference in New Issue
Block a user