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>
65 lines
2.4 KiB
TypeScript
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");
|
|
});
|
|
});
|