/** * Password-Reset Rate-Limit Security Suite * * Pure-logic tests for the auth flood control the password-reset endpoints run * on: the fixed-window limiter, the trusted client-IP extraction and the shared * 429 response helper. No database, no network — the route handlers themselves * are not imported because they call `requireDatabase()` first, which needs a * live Postgres. The primitives they call are exactly what is exercised here. * * Covered budgets (kept in sync with the routes): * forgot-password : forgot:ip: 5/h + forgot:email: 3/h * reset-password : reset:burst:ip: 5/10min + reset:ip: 10/h * resend-verification: resend:ip: 5/h + resend:email: 3/h * login : login:ip: 20/15min + login:email: 10/15min * signup : signup:ip: 5/h + signup:email: 3/h * change-password : change:ip: 10/15min * verify : verify:ip: 60/h */ import { describe, test, expect, runAllTests } from "../e2e/runner"; import { clientIp, rateLimit, resetRateLimits, } from "../../src/lib/security/rateLimit"; // sucrase-node runs plain CJS without Next.js's path-alias loader, so the // "@/..." imports inside the shared HTTP helper would not resolve. Teach Node's // resolver to map "@/x" onto /src/x before loading that helper. This is // module plumbing only — no test logic and no database. const nodeModule = require("node:module") as typeof import("node:module") & { _resolveFilename: (request: string, ...args: unknown[]) => string; }; const nodePath: typeof import("node:path") = require("node:path"); const originalResolve = nodeModule._resolveFilename; nodeModule._resolveFilename = function ( this: unknown, request: string, ...args: unknown[] ): string { if (request.startsWith("@/")) { return originalResolve.call( this, nodePath.join(process.cwd(), "src", request.slice(2)), ...args ); } return originalResolve.call(this, request, ...args); }; // Must be loaded after the shim above is installed. const { rateLimited } = require("../../src/lib/auth/http") as typeof import("../../src/lib/auth/http"); const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); const MINUTE = 60 * 1000; const HOUR = 60 * MINUTE; describe("Rate limit — fixed-window semantics", () => { test("the first `limit` calls within a window are allowed", () => { resetRateLimits(); expect(rateLimit("rl:a", 3, MINUTE).allowed).toBe(true); expect(rateLimit("rl:a", 3, MINUTE).allowed).toBe(true); expect(rateLimit("rl:a", 3, MINUTE).allowed).toBe(true); }); test("the next call is denied with a positive retryAfter", () => { resetRateLimits(); rateLimit("rl:b", 1, MINUTE); const denied = rateLimit("rl:b", 1, MINUTE); expect(denied.allowed).toBe(false); expect(denied.retryAfter).toBeGreaterThan(0); }); test("denied calls never push the window back (fixed window)", () => { resetRateLimits(); rateLimit("rl:c", 1, MINUTE); const first = rateLimit("rl:c", 1, MINUTE); const second = rateLimit("rl:c", 1, MINUTE); expect(first.allowed).toBe(false); expect(second.allowed).toBe(false); // retryAfter only ever shrinks as time passes; a larger value would mean the // window was extended on a denial, which must not happen. expect(second.retryAfter).toBeLessThanOrEqual(first.retryAfter); }); test("different keys are fully isolated", () => { resetRateLimits(); rateLimit("rl:exhausted", 1, MINUTE); expect(rateLimit("rl:exhausted", 1, MINUTE).allowed).toBe(false); expect(rateLimit("rl:fresh", 1, MINUTE).allowed).toBe(true); expect(rateLimit("rl:other", 5, MINUTE).allowed).toBe(true); }); test("the window resets after it elapses", async () => { resetRateLimits(); expect(rateLimit("rl:window", 1, 50).allowed).toBe(true); expect(rateLimit("rl:window", 1, 50).allowed).toBe(false); await sleep(60); expect(rateLimit("rl:window", 1, 50).allowed).toBe(true); }); test("malformed input fails open instead of locking anyone out", () => { resetRateLimits(); expect(rateLimit("", 5, MINUTE).allowed).toBe(true); expect(rateLimit("rl:zero-limit", 0, MINUTE).allowed).toBe(true); expect(rateLimit("rl:zero-window", 5, 0).allowed).toBe(true); expect(rateLimit("rl:nan", Number.NaN, MINUTE).allowed).toBe(true); }); test("resetRateLimits empties every bucket", () => { resetRateLimits(); rateLimit("rl:drain", 1, MINUTE); expect(rateLimit("rl:drain", 1, MINUTE).allowed).toBe(false); resetRateLimits(); expect(rateLimit("rl:drain", 1, MINUTE).allowed).toBe(true); }); }); describe("Rate limit — the password-reset budgets in production", () => { test("reset endpoint: the burst cap (5/10min) blocks a rapid scripted run", () => { resetRateLimits(); const key = "reset:burst:ip:203.0.113.7"; for (let i = 0; i < 5; i++) { expect(rateLimit(key, 5, 10 * MINUTE).allowed).toBe(true); } const denied = rateLimit(key, 5, 10 * MINUTE); expect(denied.allowed).toBe(false); expect(denied.retryAfter).toBeGreaterThan(0); }); test("reset endpoint: the hourly budget (10/h) still applies on top", () => { resetRateLimits(); const key = "reset:ip:203.0.113.7"; for (let i = 0; i < 10; i++) { expect(rateLimit(key, 10, HOUR).allowed).toBe(true); } expect(rateLimit(key, 10, HOUR).allowed).toBe(false); }); test("verify endpoint: the generous per-IP budget (60/h) survives a normal click", () => { resetRateLimits(); const key = "verify:ip:203.0.113.7"; for (let i = 0; i < 60; i++) { expect(rateLimit(key, 60, HOUR).allowed).toBe(true); } expect(rateLimit(key, 60, HOUR).allowed).toBe(false); }); test("forgot-password: IP 5/h and per-email 3/h are independent", () => { resetRateLimits(); for (let i = 0; i < 5; i++) { expect(rateLimit("forgot:ip:203.0.113.7", 5, HOUR).allowed).toBe(true); } expect(rateLimit("forgot:ip:203.0.113.7", 5, HOUR).allowed).toBe(false); for (let i = 0; i < 3; i++) { expect(rateLimit("forgot:email:timo%40example.com", 3, HOUR).allowed).toBe(true); } expect(rateLimit("forgot:email:timo%40example.com", 3, HOUR).allowed).toBe(false); }); }); describe("Rate limit — the 429 response helper", () => { test("rateLimited() answers 429 with a Retry-After header", () => { const response = rateLimited(42); expect(response.status).toBe(429); expect(response.headers.get("retry-after")).toBe("42"); }); test("rateLimited() carries the stable rate_limited error code", async () => { const response = rateLimited(9); const body = await response.json(); expect(body.error).toBe("rate_limited"); expect(body.retryAfter).toBe(9); }); test("the limiter's retryAfter round-trips into the Retry-After header", () => { resetRateLimits(); const key = "rl:roundtrip"; rateLimit(key, 1, MINUTE); const denied = rateLimit(key, 1, MINUTE); const response = rateLimited(denied.retryAfter); expect(response.status).toBe(429); expect(Number(response.headers.get("retry-after"))).toBe(denied.retryAfter); expect(Number(response.headers.get("retry-after"))).toBeGreaterThan(0); }); }); describe("Rate limit — client IP extraction", () => { const request = (headers: Record): Request => new Request("https://example.com/api/auth/reset-password", { headers }); test("x-forwarded-for: the right-most valid IP wins over a spoofed prefix", () => { const req = request({ "x-forwarded-for": "6.6.6.6, 203.0.113.7" }); expect(clientIp(req)).toBe("203.0.113.7"); }); test("x-forwarded-for: a three-hop chain resolves to the proxy-appended tail", () => { const req = request({ "x-forwarded-for": "1.2.3.4, 5.6.7.8, 198.51.100.9" }); expect(clientIp(req)).toBe("198.51.100.9"); }); test("x-forwarded-for: junk entries are skipped, the last valid one wins", () => { const req = request({ "x-forwarded-for": "not-an-ip, also-junk, 203.0.113.7" }); expect(clientIp(req)).toBe("203.0.113.7"); }); test("x-real-ip is the fallback when x-forwarded-for is absent", () => { const req = request({ "x-real-ip": "198.51.100.9" }); expect(clientIp(req)).toBe("198.51.100.9"); }); test("x-real-ip also saves the day when x-forwarded-for has no valid IP", () => { const req = request({ "x-forwarded-for": "garbage", "x-real-ip": "198.51.100.9" }); expect(clientIp(req)).toBe("198.51.100.9"); }); test("an empty or invalid header chain resolves to \"unknown\"", () => { expect(clientIp(request({}))).toBe("unknown"); expect(clientIp(request({ "x-forwarded-for": "nonsense" }))).toBe("unknown"); expect(clientIp(request({ "x-real-ip": "nonsense" }))).toBe("unknown"); }); }); if (typeof require !== "undefined" && require.main === module) { runAllTests().then((ok) => process.exit(ok ? 0 : 1)); }