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:
MechaCat02
2026-06-11 20:42:51 +02:00
parent bd64a25c97
commit 07ffc0b568
5 changed files with 354 additions and 2 deletions

View File

@@ -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,