trails/apps/journal/app/lib/rate-limit.server.ts
Ullrich Schäfer 6afd996e19
fix(journal): rate-limit auth endpoints
Adds per-process in-memory fixed-window rate limiting (lib/rate-limit.server.ts)
and applies it across /api/auth/login and /api/auth/register:

- All login steps: 30 attempts per IP per minute (defense against
  step-fanout flooding).
- finish-passkey: 10 per IP per minute (assertions don't usefully retry
  faster).
- magic-link generation: 3 per email per 5 min + 10 per IP per 5 min
  (defeats inbox-spam-the-victim + cross-email IP fanout).
- verify-code: 10 per email per 15 min — makes 6-digit code brute force
  (10^6 search) infeasible before code expiry.
- /api/auth/register: 10 per IP per hour (legitimate signup completes
  in 2-3 requests; sustained churn from one IP is account spam).

Single-instance is fine for the flagship's current topology (one journal
container). When we horizontally scale we revisit with a Postgres- or
Redis-backed store. Buckets self-clean on next read and a 5-minute
background sweep drops stale entries.

Client IP: honors X-Forwarded-For first entry (Caddy in front of the
journal sets it), falls back to a stable "unknown" bucket so the
limiter still bites when the header is missing.

Tests: rate-limit.server.test.ts (8 cases — exhaustion, independent
scopes/keys, remaining countdown, reset window, XFF parsing, fallback,
trimming). Existing auth tests still pass; limits sized to not trip
during the ~7 test cases that all share the "unknown" IP bucket.

Full repo: pnpm typecheck, pnpm lint, pnpm test all green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 11:51:43 +02:00

89 lines
3 KiB
TypeScript

// Per-process in-memory rate limiter. Single-instance is fine for the
// flagship's current topology (one journal container); when we
// horizontally scale we revisit with a Postgres- or Redis-backed store.
//
// Algorithm: fixed-window counter. Each `(scope, key)` pair tracks the
// first-attempt timestamp and a counter. The next attempt after the
// window resets the counter. Simpler than token-bucket and good enough
// for "5 logins per minute" / "3 magic links per 5 minutes" semantics.
interface Bucket {
windowStart: number;
count: number;
}
const buckets = new Map<string, Bucket>();
// Periodic sweep so stale entries don't grow unbounded. 5-minute interval
// is fine — entries are tiny and expire on next check anyway.
let sweepTimer: ReturnType<typeof setInterval> | undefined;
function ensureSweeper(): void {
if (sweepTimer || process.env.NODE_ENV === "test" || process.env.VITEST) return;
sweepTimer = setInterval(() => {
const now = Date.now();
for (const [k, b] of buckets) {
// Conservative: drop entries older than 1 hour regardless of
// configured window. Active limiters re-create their entry.
if (now - b.windowStart > 60 * 60 * 1000) buckets.delete(k);
}
}, 5 * 60 * 1000);
sweepTimer.unref?.();
}
export interface RateLimitResult {
allowed: boolean;
remaining: number;
resetMs: number;
}
export interface RateLimitOptions {
scope: string;
key: string;
limit: number;
windowMs: number;
}
/**
* Check (and consume) one slot from the given limiter. Returns whether
* the call is allowed plus how many slots remain in the current window.
*
* The limiter is keyed by `${scope}:${key}` — `scope` distinguishes the
* route family (e.g. "login-attempt", "magic-link-send") so a flood on
* one endpoint doesn't lock out the others.
*/
export function consumeRateLimit(opts: RateLimitOptions): RateLimitResult {
ensureSweeper();
const now = Date.now();
const id = `${opts.scope}:${opts.key}`;
const existing = buckets.get(id);
if (!existing || now - existing.windowStart >= opts.windowMs) {
buckets.set(id, { windowStart: now, count: 1 });
return { allowed: true, remaining: opts.limit - 1, resetMs: opts.windowMs };
}
existing.count += 1;
const resetMs = opts.windowMs - (now - existing.windowStart);
if (existing.count > opts.limit) {
return { allowed: false, remaining: 0, resetMs };
}
return { allowed: true, remaining: opts.limit - existing.count, resetMs };
}
/**
* Extract the client IP from a Request. Honors `X-Forwarded-For`
* (Caddy in front of the journal sets it) and falls back to a stable
* "unknown" bucket so the limiter still meaningfully rate-limits when
* the header is missing.
*/
export function clientIp(request: Request): string {
const xff = request.headers.get("x-forwarded-for");
if (xff) {
// First entry is the original client; everything after is proxies.
return xff.split(",")[0]!.trim();
}
return "unknown";
}
/** Test-only: reset all buckets between tests. */
export function _resetRateLimitsForTest(): void {
buckets.clear();
}