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

@@ -488,12 +488,23 @@ pub async fn build_app(
authz: authz.clone(),
};
// Audit 2026-06-11 H-B1 — login DoS defenses. PICLOUD_LOGIN_ARGON2_PARALLELISM
// overrides the per-process Argon2-during-login concurrency cap.
let argon2_login_parallelism = std::env::var("PICLOUD_LOGIN_ARGON2_PARALLELISM")
.ok()
.and_then(|s| s.parse::<usize>().ok())
.filter(|n| *n > 0)
.unwrap_or(2);
let auth_state = AuthState {
users: auth.users.clone(),
sessions: auth.sessions.clone(),
keys: auth.keys.clone(),
ttl: auth.ttl,
principal_cache: Arc::new(picloud_manager_core::auth_middleware::PrincipalCache::new()),
login_rate_limiter: Arc::new(
picloud_manager_core::login_rate_limit::LoginRateLimiter::new(),
),
argon2_login_semaphore: Arc::new(tokio::sync::Semaphore::new(argon2_login_parallelism)),
};
let admins_state = AdminsState {
users: auth.users.clone(),