Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
177 changes: 177 additions & 0 deletions backend/auth/rate-limiter.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
113 changes: 96 additions & 17 deletions backend/auth/rate-limiter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -24,6 +32,8 @@ const RATE_LIMITED_ROUTES = new Set([

interface AttemptRecord {
failures: number;
attempts: number;
attemptWindowStart: number;
lastFailure: number;
lockedUntil: number;
}
Expand All @@ -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<string, AttemptRecord>();
private cleanupTimer: ReturnType<typeof setInterval> | null = null;

Expand All @@ -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.
Expand All @@ -59,36 +79,40 @@ 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.`;
}

return null;
}

/**
* 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) {
Expand All @@ -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`);
}
}

/**
Expand Down
Loading
Loading