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
+14 -4
View File
@@ -143,11 +143,22 @@ pub fn client_ip<B>(req: &Request<B>, include_port: bool) -> String {
.get::<ConnectInfo<SocketAddr>>()
.map(|ci| ci.0);
client_ip_from_parts(req.headers(), peer, include_port)
}
/// Same as [`client_ip`], but operates on already-extracted parts (headers
/// plus an optional TCP peer). Handlers that don't take a full `Request<B>`,
/// e.g. those that consume the body via `Json<…>`, can still derive a stable
/// client identifier with this entry point.
pub fn client_ip_from_parts(
headers: &axum::http::HeaderMap,
peer: Option<SocketAddr>,
include_port: bool,
) -> String {
if let Some(peer_addr) = peer {
if is_trusted_proxy(peer_addr.ip()) {
// Try X-Forwarded-For first (leftmost = original client)
if let Some(xff) = req
.headers()
if let Some(xff) = headers
.get("x-forwarded-for")
.and_then(|v| v.to_str().ok())
&& let Some(ip) = xff
@@ -160,8 +171,7 @@ pub fn client_ip<B>(req: &Request<B>, include_port: bool) -> String {
}
// Then X-Real-Ip
if let Some(xri) = req
.headers()
if let Some(xri) = headers
.get("x-real-ip")
.and_then(|v| v.to_str().ok())
.map(str::trim)