Brings the working codebase (Next.js app, auth system, Stripe billing, Docker/deploy config, tests, docs) into version control on top of the placeholder initial commit, and adds account self-deletion (Danger Zone in Settings, password + typed-email confirmation, cascading DB cleanup, Stripe cancellation) per GDPR right-to-erasure. Excludes local build caches, node_modules, and internal agent scratch files; .gitignore hardened to keep those out going forward. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
228 lines
8.9 KiB
TypeScript
228 lines
8.9 KiB
TypeScript
/**
|
|
* 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:<ip> 5/h + forgot:email:<key> 3/h
|
|
* reset-password : reset:burst:ip:<ip> 5/10min + reset:ip:<ip> 10/h
|
|
* resend-verification: resend:ip:<ip> 5/h + resend:email:<key> 3/h
|
|
* login : login:ip:<ip> 20/15min + login:email:<key> 10/15min
|
|
* signup : signup:ip:<ip> 5/h + signup:email:<key> 3/h
|
|
* change-password : change:ip:<ip> 10/15min
|
|
* verify : verify:ip:<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 <cwd>/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<void>((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<string, string>): 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));
|
|
}
|