mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-13 22:03:13 -06:00
The only throttle on POST /api/auth/login was the per-IP limiter in server.js. That bounds one noisy source and nothing else: it does not bound a distributed attempt, and it is only as accurate as a deployment's proxy configuration. Nothing counted failures against the account actually being attacked, and nothing cleared such a count on success because no such count existed. lib/login-lockout.js mirrors lib/totp-lockout.js and lib/pair-lockout.js so there is one lockout idiom here rather than three. 10 failed passwords lock an account for 15 minutes. Keyed on user.id, never on the submitted email: the email is attacker-supplied and unbounded, so keying on it would let anyone grow the Map without limit — the same class of bug fixed elsewhere in this campaign. A user id only exists for a real account, so the key space is bounded by the user table and needs no eviction sweep, exactly like totp-lockout. A locked account returns the SAME 401 and body as a wrong password. A distinct 429 would tell an attacker "this account exists and is under attack", turning login into an account-existence oracle; the test asserts the locked response is byte-identical to both the wrong-password and unknown-account responses. The trade is that a locked-out legitimate user sees the generic message, so the trip is recorded in activity_log (auth:login_locked) for the operator instead. The counter is cleared as soon as the password verifies — before the TOTP and email-verification branches, which return early and never reach issueSession, so a reset placed there would never fire for those accounts. SSO paths do not share this code and are unaffected. Frontend needs no change: login.js renders any non-ok body's `error` string verbatim, and the body is unchanged. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
46 lines
2.1 KiB
JavaScript
46 lines
2.1 KiB
JavaScript
'use strict';
|
|
|
|
// Per-ACCOUNT brute-force lockout for POST /api/auth/login. Same shape as
|
|
// lib/totp-lockout.js (#100) and lib/pair-lockout.js (#87) — deliberately, so there is one
|
|
// recognisable lockout idiom in this codebase rather than three.
|
|
//
|
|
// WHY per-account and not just per-IP: the existing login throttle in server.js is keyed on
|
|
// the client IP, which is the right control for a single noisy source but bounds nothing
|
|
// against a distributed one — and IP attribution is only as good as the deployment's proxy
|
|
// configuration. A counter tied to the account being attacked is independent of where the
|
|
// attempts come from.
|
|
//
|
|
// KEYED ON user.id, never on the submitted email. The email is attacker-supplied and
|
|
// unbounded, so keying on it would let anyone grow this Map without limit — the same
|
|
// mistake this campaign fixed elsewhere. A user id only exists for a real account, so the
|
|
// key space is bounded by the user table and needs no eviction sweep (matching
|
|
// totp-lockout, which is bounded the same way).
|
|
//
|
|
// In-memory; resets on restart. That is a deliberate trade, not an oversight: a restart
|
|
// forgiving an in-progress lockout is preferable to persisting one, and the per-IP limiter
|
|
// still applies across restarts.
|
|
|
|
const MAX_FAILS = 10; // failed passwords before the account is locked
|
|
const LOCKOUT_MS = 15 * 60 * 1000; // how long it is then locked
|
|
|
|
const failures = new Map(); // user.id -> { count, lockedUntil }
|
|
|
|
function isLocked(key, now = Date.now()) {
|
|
const rec = failures.get(key);
|
|
return !!(rec && rec.lockedUntil > now);
|
|
}
|
|
|
|
function recordFailure(key, now = Date.now()) {
|
|
const rec = failures.get(key) || { count: 0, lockedUntil: 0 };
|
|
rec.count += 1;
|
|
if (rec.count >= MAX_FAILS) { rec.lockedUntil = now + LOCKOUT_MS; rec.count = 0; }
|
|
failures.set(key, rec);
|
|
return rec;
|
|
}
|
|
|
|
// A correct password clears the key — including for accounts that then go on to a TOTP or
|
|
// email-verification step, since the password itself has been proven at that point.
|
|
function reset(key) { failures.delete(key); }
|
|
|
|
module.exports = { isLocked, recordFailure, reset, MAX_FAILS, LOCKOUT_MS };
|