trails/apps/journal/app/lib/rate-limit.server.test.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

65 lines
2.4 KiB
TypeScript

import { describe, it, expect, beforeEach } from "vitest";
import { consumeRateLimit, clientIp, _resetRateLimitsForTest } from "./rate-limit.server.ts";
describe("consumeRateLimit", () => {
beforeEach(() => {
_resetRateLimitsForTest();
});
it("allows up to `limit` calls in the window, blocks the (limit+1)th", () => {
for (let i = 0; i < 5; i++) {
const r = consumeRateLimit({ scope: "t", key: "a", limit: 5, windowMs: 60_000 });
expect(r.allowed).toBe(true);
}
const blocked = consumeRateLimit({ scope: "t", key: "a", limit: 5, windowMs: 60_000 });
expect(blocked.allowed).toBe(false);
expect(blocked.remaining).toBe(0);
});
it("counts buckets independently per (scope, key) pair", () => {
for (let i = 0; i < 5; i++) {
consumeRateLimit({ scope: "t", key: "a", limit: 5, windowMs: 60_000 });
}
// Different key → unaffected
expect(
consumeRateLimit({ scope: "t", key: "b", limit: 5, windowMs: 60_000 }).allowed,
).toBe(true);
// Different scope, same key → unaffected
expect(
consumeRateLimit({ scope: "u", key: "a", limit: 5, windowMs: 60_000 }).allowed,
).toBe(true);
});
it("decrements `remaining` toward zero", () => {
const a = consumeRateLimit({ scope: "t", key: "k", limit: 3, windowMs: 60_000 });
const b = consumeRateLimit({ scope: "t", key: "k", limit: 3, windowMs: 60_000 });
const c = consumeRateLimit({ scope: "t", key: "k", limit: 3, windowMs: 60_000 });
expect([a.remaining, b.remaining, c.remaining]).toEqual([2, 1, 0]);
});
it("reports a reset window roughly equal to the configured window on first call", () => {
const r = consumeRateLimit({ scope: "t", key: "k", limit: 1, windowMs: 60_000 });
expect(r.resetMs).toBe(60_000);
});
});
describe("clientIp", () => {
it("uses the first entry from X-Forwarded-For", () => {
const req = new Request("http://x/", {
headers: { "x-forwarded-for": "203.0.113.5, 10.0.0.1" },
});
expect(clientIp(req)).toBe("203.0.113.5");
});
it("falls back to a stable 'unknown' bucket when no header is set", () => {
const req = new Request("http://x/");
expect(clientIp(req)).toBe("unknown");
});
it("trims whitespace from the parsed IP", () => {
const req = new Request("http://x/", {
headers: { "x-forwarded-for": " 198.51.100.7 , 10.0.0.1" },
});
expect(clientIp(req)).toBe("198.51.100.7");
});
});