Skip to content

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

Open
SAY-5 wants to merge 1 commit intoAtalayaLabs:mainfrom
SAY-5:fix/per-ip-account-lockout-323
Open

fix(auth): scope lockout key to (account, IP) to prevent DOS by login flood (#323)#326
SAY-5 wants to merge 1 commit intoAtalayaLabs:mainfrom
SAY-5:fix/per-ip-account-lockout-323

Conversation

@SAY-5
Copy link
Copy Markdown

@SAY-5 SAY-5 commented Apr 27, 2026

Closes #323.

What

Change the LoginLockoutService cache key from username to
username|ip. A flood of bad passwords from one IP locks that IP
out of that account; legitimate users coming from a different IP
are unaffected.

Why

The cache previously keyed on username only, so anyone who could
reach /api/auth/login and guess (or enumerate) a username could
lock that account out for the full lockout window. The reporter
demonstrated a complete DOS using X-Forwarded-For spoofing with
OXICLOUD_TRUST_PROXY_HEADERS=true:

$ curl -H "X-Forwarded-For: 1.1.1.1" \
       -d '{"password":"wrong","username":"admin2"}' \
       /api/auth/login
{"status":"429 Too Many Requests","error":"Account temporarily
 locked due to too many failed attempts. Try again in 900 seconds.",
 ...}

Every later request from any other IP for admin2 was rejected for
the next 15 minutes — full DOS for that account. The IP-based rate
limiter doesn't help: it caps requests per IP, but the per-account
counter is incremented on every IP's first few attempts before the
limiter clamps each one.

Changes

  • LoginLockoutService::{check, record_failure, record_success}
    now take client_ip as a second argument; cache key is built via
    Self::key(username, client_ip)
    format!("{username_lower}|{client_ip}").
  • middleware/rate_limit.rs: factor extract_client_ip into
    extract_client_ip_from_parts(&HeaderMap, Option<&SocketAddr>)
    so handlers that consume the body via Json<…> (and therefore
    can't take a full Request<B>) can still derive the same client
    identifier. extract_client_ip now delegates to it.
  • auth_handler.rs login: derive client_ip from the request
    headers and pass it to check / record_failure /
    record_success.
  • nextcloud/basic_auth_middleware.rs: same wiring through
    extract_client_ip(&request), which already had Request
    access.

Tests

  • All 4 pre-existing unit tests in login_lockout_service.rs
    threaded with an IP1 constant — behaviour unchanged.
  • does_not_lock_out_other_ips_for_same_account (regression):
    attacker from IP1 locks the account for IP1, but check
    for IP2 against the same account stays Ok.
  • success_resets_only_the_acting_ip: a successful login from
    IP2 does NOT clear an in-progress brute-force from IP1.

Verification

  • cargo build
  • cargo test login_lockout → 6 passed.

Notes / non-goals

  • Reviving the original behaviour (lock the account globally) for
    ops who explicitly want it is out of scope here. If desired, that
    could be added later as an opt-in env flag — but the default must
    be safe against the issue's repro, hence per-IP scoping.
  • The fallback IP value when no proxy header is trusted and no TCP
    peer is available is the literal "unknown" (same string the
    rate limiter uses), which degrades gracefully to per-account
    scoping for that one bucket — no worse than today.

… flood

Closes AtalayaLabs#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 AtalayaLabs#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>
@SAY-5 SAY-5 requested a review from DioCrafts as a code owner April 27, 2026 19:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[BUG/SECURITY] possible to DOS an account: a login flood will lock an account

1 participant