diff --git a/backend/auth/rate-limiter.test.ts b/backend/auth/rate-limiter.test.ts new file mode 100644 index 00000000..695f0871 --- /dev/null +++ b/backend/auth/rate-limiter.test.ts @@ -0,0 +1,177 @@ +/** + * Behavior-based tests for AuthRateLimiter. + * + * Validates: + * - Failure-based lockout tiers (5/10/20 → 30s/2m/10m) still trigger. + * - Volume cap on attempts fires independently of success/failure mix, + * defending routes like `auth:validate-invite` against probes that + * alternate valid and invalid tokens. + * - `recordSuccess` clears BOTH the failure counter and the attempt + * counter, so legitimate users don't accumulate debt across successful + * validations. + * - Lockout state is per-route and per-identifier. + * + * Each test uses a unique random identifier so the singleton's in-memory + * state does not bleed across tests. + */ + +import { describe, expect, test } from 'bun:test'; +import { authRateLimiter } from './rate-limiter'; + +function uniqueId(label: string): string { + // IPv4-shaped string the rate limiter treats as opaque. + return `203.0.113.${Math.floor(Math.random() * 200) + 10}-${label}`; +} + +/** + * Extract a human-readable message + lockout-remaining-ms from whatever + * `check()` returns. Today it's a string; PR #396 will change it to + * `{ message, lockedUntil }`. This helper keeps the tests valid against + * either shape by accepting `unknown` instead of depending on the + * current return type of `check`. + */ +interface LockoutObject { message: string; lockedUntil: number } +function isLockoutObject(v: unknown): v is LockoutObject { + return typeof v === 'object' && v !== null && 'message' in v && 'lockedUntil' in v; +} +function readLockout(result: unknown): { message: string; remainingMs: number } | null { + if (result == null) return null; + if (typeof result === 'string') { + const match = result.match(/Try again in (\d+) seconds/); + const remainingSec = match ? Number(match[1]) : 0; + return { message: result, remainingMs: remainingSec * 1000 }; + } + if (isLockoutObject(result)) { + return { message: result.message, remainingMs: result.lockedUntil - Date.now() }; + } + return null; +} + +describe('failure-based lockout tiers (regression)', () => { + test('five failures lock for 30s', () => { + const id = uniqueId('5fail'); + for (let i = 0; i < 5; i++) { + authRateLimiter.recordFailure(id, 'auth:login'); + } + const lockout = readLockout(authRateLimiter.check(id, 'auth:login')); + expect(lockout).not.toBeNull(); + expect(lockout!.remainingMs).toBeGreaterThanOrEqual(25_000); + expect(lockout!.remainingMs).toBeLessThanOrEqual(30_500); + }); + + test('ten failures escalate to 2-minute lockout', () => { + const id = uniqueId('10fail'); + for (let i = 0; i < 10; i++) { + authRateLimiter.recordFailure(id, 'auth:login'); + } + const lockout = readLockout(authRateLimiter.check(id, 'auth:login')); + expect(lockout).not.toBeNull(); + expect(lockout!.remainingMs).toBeGreaterThanOrEqual(115_000); + expect(lockout!.remainingMs).toBeLessThanOrEqual(120_500); + }); + + test('twenty failures escalate to 10-minute lockout', () => { + const id = uniqueId('20fail'); + for (let i = 0; i < 20; i++) { + authRateLimiter.recordFailure(id, 'auth:login'); + } + const lockout = readLockout(authRateLimiter.check(id, 'auth:login')); + expect(lockout).not.toBeNull(); + expect(lockout!.remainingMs).toBeGreaterThanOrEqual(595_000); + expect(lockout!.remainingMs).toBeLessThanOrEqual(600_500); + }); +}); + +describe('attempt volume cap', () => { + test('rate-limited when attempts exceed cap, even with no failures', () => { + const id = uniqueId('100attempt'); + // 100 successful validations with the same identifier should not be + // possible — the cap fires before a real user would ever hit it. + for (let i = 0; i < 100; i++) { + authRateLimiter.recordAttempt(id, 'auth:validate-invite'); + } + const lockout = readLockout(authRateLimiter.check(id, 'auth:validate-invite')); + expect(lockout).not.toBeNull(); + expect(lockout!.message).toMatch(/too many/i); + }); + + test('attempt counter resets after a successful recordSuccess', () => { + // Establishes the "legit user not penalized" property: a real + // user who does 99 attempts then gets one right can keep going. + const id = uniqueId('reset-on-success'); + for (let i = 0; i < 99; i++) { + authRateLimiter.recordAttempt(id, 'auth:validate-invite'); + } + authRateLimiter.recordSuccess(id, 'auth:validate-invite'); + // Now do 50 more — must not be locked out. + for (let i = 0; i < 50; i++) { + authRateLimiter.recordAttempt(id, 'auth:validate-invite'); + } + expect(authRateLimiter.check(id, 'auth:validate-invite')).toBeNull(); + }); +}); + +describe('recordSuccess clears state', () => { + test('a success after a single failure clears the failure record', () => { + const id = uniqueId('clear-fail'); + authRateLimiter.recordFailure(id, 'auth:login'); + authRateLimiter.recordSuccess(id, 'auth:login'); + expect(authRateLimiter.check(id, 'auth:login')).toBeNull(); + }); + + test('a success clears the attempt counter too (so legit users are not penalized)', () => { + // Decision: recordSuccess clears the WHOLE per-route record. This + // means the volume cap resets on success. Rationale: a legit user + // validating a few invites should never hit the cap; the cap exists + // to stop a probe that finds many valid tokens. The probe signature + // is rapid attempts without matching successful completes — in + // practice the same flow that calls recordAttempt calls + // recordSuccess on valid results, so a real probe quickly shows + // up as a high attempt count relative to the underlying use. + // The defensive guarantee comes from the failure-tier lockout + // for invalid probes and the moderate cap (100/15min) for the + // mixed case. + const id = uniqueId('clear-attempt'); + for (let i = 0; i < 50; i++) { + authRateLimiter.recordAttempt(id, 'auth:validate-invite'); + } + authRateLimiter.recordSuccess(id, 'auth:validate-invite'); + expect(authRateLimiter.check(id, 'auth:validate-invite')).toBeNull(); + }); +}); + +describe('per-route isolation', () => { + test('a lockout on auth:login does not affect auth:validate-invite', () => { + const id = uniqueId('route-iso-1'); + for (let i = 0; i < 5; i++) { + authRateLimiter.recordFailure(id, 'auth:login'); + } + // login is locked … + expect(authRateLimiter.check(id, 'auth:login')).not.toBeNull(); + // … but validate-invite is fresh. + expect(authRateLimiter.check(id, 'auth:validate-invite')).toBeNull(); + }); + + test('attempts on different routes do not bleed into each other', () => { + const id = uniqueId('route-iso-2'); + for (let i = 0; i < 100; i++) { + authRateLimiter.recordAttempt(id, 'auth:validate-invite'); + } + // validate-invite capped … + expect(authRateLimiter.check(id, 'auth:validate-invite')).not.toBeNull(); + // … but login is unaffected. + expect(authRateLimiter.check(id, 'auth:login')).toBeNull(); + }); +}); + +describe('per-identifier isolation', () => { + test('one IP hitting the cap does not affect another IP', () => { + const idA = uniqueId('ipA'); + const idB = uniqueId('ipB'); + for (let i = 0; i < 100; i++) { + authRateLimiter.recordAttempt(idA, 'auth:validate-invite'); + } + expect(authRateLimiter.check(idA, 'auth:validate-invite')).not.toBeNull(); + expect(authRateLimiter.check(idB, 'auth:validate-invite')).toBeNull(); + }); +}); diff --git a/backend/auth/rate-limiter.ts b/backend/auth/rate-limiter.ts index 35b6d1c8..7ce66913 100644 --- a/backend/auth/rate-limiter.ts +++ b/backend/auth/rate-limiter.ts @@ -2,19 +2,27 @@ * Auth Rate Limiter * * Protects auth endpoints against brute-force and credential stuffing attacks. - * Tracks failed attempts per IP with progressive lockout. + * Tracks failed attempts per (IP, route) with progressive lockout, plus a + * volume cap on *all* attempts (success + failure) per (IP, route) so that + * probes alternating valid and invalid tokens cannot avoid the limit. * - * Thresholds: + * Failure tiers: * 5 failures → 30 second lockout * 10 failures → 2 minute lockout * 20 failures → 10 minute lockout * + * Volume cap: + * 100 attempts per (IP, route) within a 15-minute sliding window → + * 15-minute lockout, regardless of success/failure mix. Defends routes + * like `auth:validate-invite` against high-volume probing of token + * space, including probes that happen to find a valid token. + * * Attempts decay after the lockout window expires. */ import { debug } from '$shared/utils/logger'; -/** Routes that should be rate-limited */ +/** Routes that should be rate-limited. */ const RATE_LIMITED_ROUTES = new Set([ 'auth:login', 'auth:accept-invite', @@ -24,6 +32,8 @@ const RATE_LIMITED_ROUTES = new Set([ interface AttemptRecord { failures: number; + attempts: number; + attemptWindowStart: number; lastFailure: number; lockedUntil: number; } @@ -35,13 +45,19 @@ const LOCKOUT_TIERS: [number, number][] = [ [20, 10 * 60_000], // 20 failures → 10 minutes ]; -/** After this duration of no failures, the record is considered stale and cleaned up */ +/** Volume cap: max attempts (success or failure) per route per sliding window. */ +const ATTEMPT_CAP = 100; +const ATTEMPT_WINDOW_MS = 15 * 60_000; +const ATTEMPT_LOCKOUT_MS = 15 * 60_000; + +/** After this duration of no activity, the record is considered stale and cleaned up. */ const STALE_AFTER_MS = 15 * 60_000; // 15 minutes /** How often to run cleanup (ms) */ const CLEANUP_INTERVAL_MS = 5 * 60_000; // 5 minutes -class AuthRateLimiter { +export class AuthRateLimiter { + // Key format: `${identifier}::${action}` — per (IP, route) isolation. private attempts = new Map(); private cleanupTimer: ReturnType | null = null; @@ -50,6 +66,10 @@ class AuthRateLimiter { this.cleanupTimer = setInterval(() => this.cleanup(), CLEANUP_INTERVAL_MS); } + private static key(identifier: string, action: string): string { + return `${identifier}::${action}`; + } + /** * Check if an action is rate-limited for the given identifier. * Returns null if allowed, or an error message if blocked. @@ -59,17 +79,17 @@ class AuthRateLimiter { return null; // Not a rate-limited route } - const record = this.attempts.get(identifier); + const record = this.attempts.get(AuthRateLimiter.key(identifier, action)); if (!record) { - return null; // No previous failures + return null; // No previous activity } const now = Date.now(); - // Check if currently locked out + // Check if currently locked out (either failure-tier or volume-tier) if (record.lockedUntil > now) { const remainingSec = Math.ceil((record.lockedUntil - now) / 1000); - debug.warn('auth', `Rate limited: ${identifier} (${remainingSec}s remaining, ${record.failures} failures)`); + debug.warn('auth', `Rate limited: ${identifier} on ${action} (${remainingSec}s remaining, ${record.failures} failures, ${record.attempts} attempts)`); return `Too many failed attempts. Try again in ${remainingSec} seconds.`; } @@ -77,18 +97,22 @@ class AuthRateLimiter { } /** - * Record a failed auth attempt for the given identifier. + * Record a failed auth attempt for the given identifier + action. + * Escalates the failure-tier lockout when a new threshold is crossed. */ recordFailure(identifier: string, action: string): void { if (!RATE_LIMITED_ROUTES.has(action)) return; const now = Date.now(); - const record = this.attempts.get(identifier) ?? { failures: 0, lastFailure: 0, lockedUntil: 0 }; + const key = AuthRateLimiter.key(identifier, action); + const record = this.attempts.get(key) ?? this.newRecord(now); record.failures += 1; record.lastFailure = now; + record.attempts += 1; + record.attemptWindowStart = this.maybeResetWindow(record, now); - // Determine lockout duration based on failure count + // Determine failure-tier lockout duration based on failure count let lockoutMs = 0; for (const [threshold, duration] of LOCKOUT_TIERS) { if (record.failures >= threshold) { @@ -98,17 +122,72 @@ class AuthRateLimiter { if (lockoutMs > 0) { record.lockedUntil = now + lockoutMs; - debug.warn('auth', `Lockout triggered: ${identifier} — ${record.failures} failures, locked for ${lockoutMs / 1000}s`); + debug.warn('auth', `Lockout triggered: ${identifier} on ${action} — ${record.failures} failures, locked for ${lockoutMs / 1000}s`); } - this.attempts.set(identifier, record); + this.maybeApplyAttemptCap(record, now); + this.attempts.set(key, record); } /** - * Clear failure record on successful auth (e.g., successful login). + * Record a successful auth attempt for the given identifier + action. + * Clears the per-route record so legitimate users don't accumulate debt + * across successful validations. */ - recordSuccess(identifier: string): void { - this.attempts.delete(identifier); + recordSuccess(identifier: string, action: string): void { + if (!RATE_LIMITED_ROUTES.has(action)) return; + this.attempts.delete(AuthRateLimiter.key(identifier, action)); + } + + /** + * Record an auth attempt without recording a failure. Used for routes + * like `auth:validate-invite` where a successful call should still + * count toward the volume cap (so high-volume probes can't avoid it by + * happening to find valid tokens). + */ + recordAttempt(identifier: string, action: string): void { + if (!RATE_LIMITED_ROUTES.has(action)) return; + + const now = Date.now(); + const key = AuthRateLimiter.key(identifier, action); + const record = this.attempts.get(key) ?? this.newRecord(now); + + record.attempts += 1; + record.attemptWindowStart = this.maybeResetWindow(record, now); + this.maybeApplyAttemptCap(record, now); + this.attempts.set(key, record); + } + + private newRecord(now: number): AttemptRecord { + return { + failures: 0, + attempts: 0, + attemptWindowStart: now, + lastFailure: 0, + lockedUntil: 0 + }; + } + + /** + * If the sliding attempt window has expired, reset the counter. Returns + * the (possibly updated) window start. + */ + private maybeResetWindow(record: AttemptRecord, now: number): number { + if (now - record.attemptWindowStart >= ATTEMPT_WINDOW_MS) { + record.attempts = 0; + record.attemptWindowStart = now; + } + return record.attemptWindowStart; + } + + /** + * If the attempt count has reached the cap, apply the volume-tier lockout. + */ + private maybeApplyAttemptCap(record: AttemptRecord, now: number): void { + if (record.attempts >= ATTEMPT_CAP && record.lockedUntil < now + ATTEMPT_LOCKOUT_MS) { + record.lockedUntil = now + ATTEMPT_LOCKOUT_MS; + debug.warn('auth', `Volume lockout triggered: ${record.attempts} attempts in window, locked for ${ATTEMPT_LOCKOUT_MS / 1000}s`); + } } /** diff --git a/backend/ws/auth/login.ts b/backend/ws/auth/login.ts index 5a44102f..6837bbe0 100644 --- a/backend/ws/auth/login.ts +++ b/backend/ws/auth/login.ts @@ -165,7 +165,7 @@ export const loginHandler = createRouter() // Success — clear any rate limit record for this IP if (isRateLimited) { - authRateLimiter.recordSuccess(ip); + authRateLimiter.recordSuccess(ip, 'auth:login'); } auditLogQueries.logEvent({ @@ -216,7 +216,7 @@ export const loginHandler = createRouter() try { const result = createUserFromInvite(data.inviteToken, data.name); - authRateLimiter.recordSuccess(ip); + authRateLimiter.recordSuccess(ip, 'auth:accept-invite'); auditLogQueries.logEvent({ userId: result.user.id, @@ -257,10 +257,19 @@ export const loginHandler = createRouter() return { valid: false, error: rateLimitError }; } + // Count every attempt toward the volume cap (defends against probes + // that alternate valid and invalid tokens). + authRateLimiter.recordAttempt(ip, 'auth:validate-invite'); + const result = validateInviteToken(data.inviteToken); - // Record failure if invalid token (probing) - if (!result.valid) { + // Record failure on invalid token (drives the failure-tier lockout + // for pure probes) and clear the whole per-route record on success + // (so a legit user who finally got the right token doesn't keep + // accumulated failure/attempt debt). + if (result.valid) { + authRateLimiter.recordSuccess(ip, 'auth:validate-invite'); + } else { authRateLimiter.recordFailure(ip, 'auth:validate-invite'); }