fix(auth): scope lockout key to (account, IP) to prevent DOS by login flood

Closes #323.

LoginLockoutService cached failed-attempt counters keyed only on
the username, so any caller that could reach the auth endpoint and
guess (or enumerate) a username could lock that account out for the
entire lockout window — the rate limiter happily lets each IP make
its share of bad-password attempts before clamping, which is enough
to trip the per-account threshold in seconds. The reporter
demonstrated a complete DOS by spoofing X-Forwarded-For with
OXICLOUD_TRUST_PROXY_HEADERS=true.

Fix: change the lockout cache key from `username` to `username|ip`.
A flood from one IP locks that IP out of that account, but a
legitimate user coming from a different IP is unaffected.

Changes:
- LoginLockoutService::{check, record_failure, record_success} take
  client_ip as a second argument; cache key is built via Self::key
  (`format!("{username}|{ip}")`).
- middleware/rate_limit.rs: factor out extract_client_ip_from_parts
  (HeaderMap + Option<&SocketAddr>) so handlers that don't take a
  full Request<B> can still derive the same client identifier
  extract_client_ip uses. extract_client_ip now delegates to it.
- auth_handler.rs login: derive client_ip from headers (the only
  signal available without ConnectInfo) and pass it through to all
  three lockout calls.
- nextcloud/basic_auth_middleware.rs: do the same with the full
  Request via extract_client_ip.

Tests:
- Updated existing 4 unit tests to thread an IP arg.
- New does_not_lock_out_other_ips_for_same_account: lock from IP1,
  assert IP2 still allowed (the #323 regression).
- New success_resets_only_the_acting_ip: a successful login from
  IP2 must NOT clear an attacker's lockout from IP1.

Verification:
- `cargo build` ✅
- `cargo test login_lockout` → 6 passed (4 existing thread an IP
  arg without behaviour change, 2 new pin the per-IP scoping).

Signed-off-by: SAY-5 <say.apm35@gmail.com>
This commit is contained in:
SAY-5
2026-04-27 12:22:59 -07:00
committed by Sai Asish Y
parent 087cb44add
commit 9dfb29bdda
4 changed files with 139 additions and 42 deletions
@@ -55,12 +55,28 @@ impl LoginLockoutService {
}
}
/// Check whether the account is currently locked.
/// Build the cache key from the (lowercased) username and the client IP.
///
/// The IP is part of the key so that an attacker flooding bad passwords
/// from one address cannot lock a legitimate user out of the same account
/// from a different address (issue #323). When the caller cannot resolve
/// a real IP — e.g. `OXICLOUD_TRUST_PROXY_HEADERS=false` and the peer
/// address isn't available — `client_ip` should be a non-empty constant
/// like `"unknown"`; in that pathological case we fall back to
/// account-scoped lockout, which is no worse than the previous
/// behaviour.
fn key(username: &str, client_ip: &str) -> String {
// `|` is not valid in either a username or an IP literal so it makes
// the username/ip boundary unambiguous.
format!("{}|{}", username.to_lowercase(), client_ip)
}
/// Check whether the (account, IP) pair is currently locked.
///
/// Returns `Ok(())` if the user may attempt login, or
/// `Err(remaining_secs)` with the *approximate* remaining lockout time.
pub fn check(&self, username: &str) -> Result<(), u64> {
if let Some(rec) = self.cache.get(&username.to_lowercase())
pub fn check(&self, username: &str, client_ip: &str) -> Result<(), u64> {
if let Some(rec) = self.cache.get(&Self::key(username, client_ip))
&& rec.count >= self.max_failures
{
// The entry exists and is over the threshold. Because moka
@@ -71,8 +87,8 @@ impl LoginLockoutService {
}
/// Record a failed login attempt. Returns the new failure count.
pub fn record_failure(&self, username: &str) -> u32 {
let key = username.to_lowercase();
pub fn record_failure(&self, username: &str, client_ip: &str) -> u32 {
let key = Self::key(username, client_ip);
let new_count = self.cache.get(&key).map(|r| r.count + 1).unwrap_or(1);
self.cache
.insert(key.clone(), FailureRecord { count: new_count });
@@ -80,18 +96,21 @@ impl LoginLockoutService {
if new_count >= self.max_failures {
tracing::warn!(
username = %username,
client_ip = %client_ip,
attempts = new_count,
lockout_secs = self.lockout_secs,
"Account temporarily locked after {} consecutive failed login attempts",
"Account temporarily locked after {} consecutive failed login attempts from this IP",
new_count,
);
}
new_count
}
/// Record a successful login — resets the failure counter.
pub fn record_success(&self, username: &str) {
self.cache.invalidate(&username.to_lowercase());
/// Record a successful login — resets the failure counter for this
/// (account, IP) pair so the user isn't penalised for stray earlier
/// failures from the same address.
pub fn record_success(&self, username: &str, client_ip: &str) {
self.cache.invalidate(&Self::key(username, client_ip));
}
/// Maximum failures before lockout (used to inform callers / error messages).
@@ -109,42 +128,88 @@ impl LoginLockoutService {
mod tests {
use super::*;
const IP1: &str = "1.1.1.1";
const IP2: &str = "2.2.2.2";
#[test]
fn allows_login_under_threshold() {
let svc = LoginLockoutService::new(3, 60, 100);
assert!(svc.check("alice").is_ok());
svc.record_failure("alice");
svc.record_failure("alice");
assert!(svc.check("alice", IP1).is_ok());
svc.record_failure("alice", IP1);
svc.record_failure("alice", IP1);
// 2 failures — still under threshold
assert!(svc.check("alice").is_ok());
assert!(svc.check("alice", IP1).is_ok());
}
#[test]
fn locks_after_threshold() {
let svc = LoginLockoutService::new(3, 60, 100);
svc.record_failure("bob");
svc.record_failure("bob");
svc.record_failure("bob");
assert!(svc.check("bob").is_err());
svc.record_failure("bob", IP1);
svc.record_failure("bob", IP1);
svc.record_failure("bob", IP1);
assert!(svc.check("bob", IP1).is_err());
}
#[test]
fn resets_on_success() {
let svc = LoginLockoutService::new(3, 60, 100);
svc.record_failure("carol");
svc.record_failure("carol");
svc.record_success("carol");
svc.record_failure("carol", IP1);
svc.record_failure("carol", IP1);
svc.record_success("carol", IP1);
// Counter reset — should be allowed again
assert!(svc.check("carol").is_ok());
svc.record_failure("carol"); // starts over at 1
assert!(svc.check("carol").is_ok());
assert!(svc.check("carol", IP1).is_ok());
svc.record_failure("carol", IP1); // starts over at 1
assert!(svc.check("carol", IP1).is_ok());
}
#[test]
fn case_insensitive() {
let svc = LoginLockoutService::new(2, 60, 100);
svc.record_failure("Dave");
svc.record_failure("dave");
assert!(svc.check("DAVE").is_err());
svc.record_failure("Dave", IP1);
svc.record_failure("dave", IP1);
assert!(svc.check("DAVE", IP1).is_err());
}
/// Regression test for #323: flooding bad passwords from one IP must
/// NOT lock the account out for legitimate users coming from a
/// different IP.
#[test]
fn does_not_lock_out_other_ips_for_same_account() {
let svc = LoginLockoutService::new(3, 60, 100);
// Attacker hammers the account from IP1 until it locks for that IP.
for _ in 0..3 {
svc.record_failure("admin", IP1);
}
assert!(
svc.check("admin", IP1).is_err(),
"attacker IP must be locked"
);
// A legitimate user coming from IP2 must still be allowed to try.
assert!(
svc.check("admin", IP2).is_ok(),
"second IP must not inherit the lockout — that's the #323 DOS"
);
}
/// A successful login on one IP must clear *that* IP's counter only —
/// it should NOT silently absolve a separate, ongoing brute-force from
/// a different IP against the same account.
#[test]
fn success_resets_only_the_acting_ip() {
let svc = LoginLockoutService::new(3, 60, 100);
for _ in 0..3 {
svc.record_failure("admin", IP1);
}
// Genuine login from IP2 succeeds; should reset IP2 counter (which
// is already 0 here) but leave IP1's lockout intact.
svc.record_success("admin", IP2);
assert!(
svc.check("admin", IP1).is_err(),
"IP1 must remain locked after IP2's success"
);
}
}