// ───────────────────────────────────────────────────────────────────────────── // A07:2025 AUTHENTICATION FAILURES (2021: A07 Identification and Authentication Failures) // ───────────────────────────────────────────────────────────────────────────── // WHAT : Failures in confirming identity and managing the resulting // session — attackers log in as someone else or stay logged in. // HAPPENS : credential stuffing (breached user:pass lists replayed); brute // force (no lockout/rate limit); weak password policy; session // fixation (attacker sets the session id before victim logs in); // no expiry / no rotation; MFA bypass (skip step, response tamper, // OTP brute force); reset-token flaws (predictable, non-expiring, // reusable, leaked via Referer); predictable session ids; token in // localStorage (XSS-readable); user enumeration via error text / // timing; "remember me" forever. // SPOT : login with no limiter; session id unchanged across login; // Math.random / Date.now in token code; cookie without HttpOnly/ // Secure/SameSite; reset token stored in plaintext; different error // for "no such user" vs "bad password"; JWT without exp. // FIX : rate limit + lockout with backoff (per account AND per IP); // breached-password check (HIBP k-anonymity API); length-first // password policy (NIST 800-63B: 8+ min, 64+ max, no composition // rules, no forced rotation); MFA (TOTP/WebAuthn/passkeys); rotate // session id on login & privilege change; absolute + idle expiry; // cookie flags HttpOnly Secure SameSite; CSPRNG tokens, hashed at // rest, single-use, short TTL; constant-time compare; generic errors. // SAY : CWE-287 Improper Authentication, CWE-384 Session Fixation, // CWE-307 Improper Restriction of Excessive Auth Attempts, CWE-521 // Weak Password Requirements, CWE-613 Insufficient Session // Expiration, CWE-640 Weak Password Recovery. Pitch: "Rate-limit, // MFA, rotate the session on login, HttpOnly cookies, short-lived // single-use tokens compared in constant time." // ───────────────────────────────────────────────────────────────────────────── 'use strict'; const assert = require('node:assert/strict'); const crypto = require('node:crypto'); // ── 1. Brute force / credential stuffing: lockout with exponential backoff // VULNERABLE: // app.post('/login', async (req, res) => { if (await check(req.body)) ... }); // unlimited attempts // FIXED: class LoginGuard { constructor({ maxFree = 5, baseMs = 1000, capMs = 15 * 60_000 } = {}) { this.maxFree = maxFree; this.baseMs = baseMs; this.capMs = capMs; this.state = new Map(); } check(key, now) { const s = this.state.get(key); return !s || now >= s.lockedUntil; } fail(key, now) { const s = this.state.get(key) || { fails: 0, lockedUntil: 0 }; s.fails += 1; if (s.fails > this.maxFree) s.lockedUntil = now + Math.min(this.capMs, this.baseMs * 2 ** (s.fails - this.maxFree - 1)); this.state.set(key, s); } success(key) { this.state.delete(key); } } const guard = new LoginGuard(); let now = 1_000_000; for (let i = 0; i < 5; i++) { assert.equal(guard.check('acct:alice', now), true); guard.fail('acct:alice', now); } guard.fail('acct:alice', now); // 6th fail => locked 1s assert.equal(guard.check('acct:alice', now), false); assert.equal(guard.check('acct:alice', now + 1000), true); guard.fail('acct:alice', now + 1000); // 7th => 2s assert.equal(guard.check('acct:alice', now + 2000), false); assert.equal(guard.check('acct:alice', now + 3000), true); guard.success('acct:alice'); assert.equal(guard.check('acct:alice', now), true); // Key on BOTH account and IP; also check HIBP range API: GET /range/{sha1[0..5]} -> compare suffixes locally. // ── 2. Password policy (NIST 800-63B shape: length, breach list, no silly composition rules) const breached = new Set(['password', '123456', 'qwerty', 'letmein']); // stand-in for HIBP function passwordOk(pw) { return typeof pw === 'string' && pw.length >= 12 && pw.length <= 128 && !breached.has(pw.toLowerCase()); } assert.equal(passwordOk('correct horse battery staple'), true); assert.equal(passwordOk('short'), false); assert.equal(passwordOk('Password'), false); // ── 3. Session store: rotation on login, idle + absolute expiry, CSPRNG ids // VULNERABLE: // req.session.user = user; // same session id before and after login => fixation // cookie: 'sid=' + Date.now() // predictable; no expiry; no flags // FIXED: class SessionStore { constructor({ idleMs = 30 * 60_000, absoluteMs = 12 * 3_600_000 } = {}) { this.idleMs = idleMs; this.absoluteMs = absoluteMs; this.sessions = new Map(); } newId() { return crypto.randomBytes(32).toString('base64url'); } create(data, now) { const id = this.newId(); this.sessions.set(id, { ...data, createdAt: now, lastSeen: now }); return id; } get(id, now) { const s = this.sessions.get(id); if (!s) return null; if (now - s.lastSeen > this.idleMs || now - s.createdAt > this.absoluteMs) { this.sessions.delete(id); return null; } s.lastSeen = now; return s; } login(oldId, user, now) { // rotate: destroy pre-auth session, mint a new id const prev = this.sessions.get(oldId); this.sessions.delete(oldId); return this.create({ ...(prev ? { cart: prev.cart } : {}), userId: user.id }, now); } destroy(id) { this.sessions.delete(id); } } const store = new SessionStore(); let t = 5_000_000; const anonId = store.create({ cart: ['sku1'] }, t); const authId = store.login(anonId, { id: 'u1' }, t); assert.notEqual(authId, anonId); // id rotated on login assert.equal(store.get(anonId, t), null); // old id dead => fixation defeated assert.equal(store.get(authId, t).userId, 'u1'); assert.deepEqual(store.get(authId, t).cart, ['sku1']); // carried state over assert.equal(Buffer.from(authId, 'base64url').length, 32); // 256-bit random id assert.equal(store.get(authId, t + 31 * 60_000), null); // idle expiry const longId = store.create({ userId: 'u2' }, t); for (let i = 1; i <= 13; i++) store.get(longId, t + i * 3_600_000 - 60_000); // keeps touching it assert.equal(store.get(longId, t + 13 * 3_600_000), null); // absolute expiry wins anyway // ── 4. Cookie attributes (as an object; localStorage is XSS-readable, cookies with HttpOnly are not) const sessionCookie = Object.freeze({ name: '__Host-sid', // __Host- prefix: Secure, no Domain, Path=/ httpOnly: true, // JS cannot read it secure: true, // HTTPS only sameSite: 'Lax', // 'Strict' if no cross-site top-level nav needed; CSRF mitigation path: '/', maxAge: 30 * 60, // seconds; match idle timeout }); assert.equal(sessionCookie.httpOnly && sessionCookie.secure, true); assert.ok(['Lax', 'Strict'].includes(sessionCookie.sameSite)); // ── 5. Password reset token: random, hashed at rest, single-use, short TTL, constant-time compare // VULNERABLE: token = user.id + Date.now(); stored plaintext; never expires; reusable; emailed in a GET link w/o Referrer-Policy. // FIXED: const resetTokens = new Map(); // sha256(token) -> { userId, exp, used } function issueReset(userId, now, ttlMs = 15 * 60_000) { const token = crypto.randomBytes(32).toString('base64url'); resetTokens.set(crypto.createHash('sha256').update(token).digest('hex'), { userId, exp: now + ttlMs, used: false }); return token; // only the user ever sees the raw token } function consumeReset(token, now) { const h = crypto.createHash('sha256').update(String(token)).digest('hex'); for (const [k, rec] of resetTokens) { if (k.length === h.length && crypto.timingSafeEqual(Buffer.from(k), Buffer.from(h))) { if (rec.used || now > rec.exp) return null; rec.used = true; return rec.userId; } } return null; } const rt = issueReset('u1', t); assert.equal(consumeReset(rt, t + 60_000), 'u1'); assert.equal(consumeReset(rt, t + 61_000), null); // single-use const rt2 = issueReset('u1', t); assert.equal(consumeReset(rt2, t + 16 * 60_000), null); // expired assert.equal(consumeReset('guess', t), null); // Generic responses: "If that account exists, we sent an email." Same timing for both branches. console.log('ok: owasp/a07-authentication-failures.js');