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>
145 lines
5.2 KiB
JavaScript
145 lines
5.2 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* Verification for the canonical rate limiter at src/lib/security/rateLimit.ts.
|
|
*
|
|
* Runs under plain Node (>= 22.6, type-stripping) — no tsx, no build step:
|
|
* node scripts/verify_rate_limit.mjs
|
|
*
|
|
* Covers the three behaviours the security gate cares about:
|
|
* 1. A burst over the limit is denied with a positive retryAfter (the routes
|
|
* map that to HTTP 429 + Retry-After — see the audit doc).
|
|
* 2. Once the window expires, the same key is allowed again.
|
|
* 3. Different keys are independent buckets.
|
|
* Plus the clientIp header policy (last valid XFF entry wins; spoofed first
|
|
* entries ignored; x-real-ip fallback; unknown fallback).
|
|
*/
|
|
|
|
import { rateLimit, clientIp, resetRateLimits } from "../src/lib/security/rateLimit.ts";
|
|
|
|
let failures = 0;
|
|
let checks = 0;
|
|
|
|
function check(name, condition, detail = "") {
|
|
checks += 1;
|
|
if (condition) {
|
|
console.log(` PASS ${name}`);
|
|
} else {
|
|
failures += 1;
|
|
console.error(` FAIL ${name}${detail ? ` — ${detail}` : ""}`);
|
|
}
|
|
}
|
|
|
|
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 1. Burst over the limit
|
|
// ---------------------------------------------------------------------------
|
|
console.log("\n[1] Burst over the limit → denied with retryAfter > 0");
|
|
{
|
|
resetRateLimits();
|
|
const LIMIT = 3;
|
|
const WINDOW_MS = 60_000;
|
|
const key = "login:ip:203.0.113.9";
|
|
|
|
const results = [];
|
|
for (let i = 0; i < LIMIT + 2; i++) results.push(rateLimit(key, LIMIT, WINDOW_MS));
|
|
|
|
const allowedCount = results.filter((r) => r.allowed).length;
|
|
const blocked = results[LIMIT]; // first denial
|
|
check(
|
|
"exactly `limit` requests allowed before denial",
|
|
allowedCount === LIMIT,
|
|
`allowed ${allowedCount}, expected ${LIMIT}`
|
|
);
|
|
check(
|
|
"first over-limit request is denied",
|
|
blocked && blocked.allowed === false,
|
|
JSON.stringify(blocked)
|
|
);
|
|
check(
|
|
"denied request reports a positive retryAfter (→ Retry-After header)",
|
|
blocked && blocked.retryAfter > 0,
|
|
`retryAfter=${blocked?.retryAfter}`
|
|
);
|
|
check(
|
|
"every further request stays denied inside the window",
|
|
results.slice(LIMIT).every((r) => !r.allowed)
|
|
);
|
|
check(
|
|
"allowed requests report retryAfter 0",
|
|
results.slice(0, LIMIT).every((r) => r.retryAfter === 0)
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 2. Window expiry → allowed again
|
|
// ---------------------------------------------------------------------------
|
|
console.log("\n[2] Window expiry resets the bucket");
|
|
{
|
|
resetRateLimits();
|
|
const key = "signup:ip:198.51.100.4";
|
|
const WINDOW_MS = 120; // short window so the test does not stall
|
|
for (let i = 0; i < 2; i++) rateLimit(key, 1, WINDOW_MS);
|
|
const blockedNow = rateLimit(key, 1, WINDOW_MS);
|
|
check("denied while inside the window", blockedNow.allowed === false);
|
|
|
|
await sleep(WINDOW_MS + 30);
|
|
const allowedAfter = rateLimit(key, 1, WINDOW_MS);
|
|
check("allowed again after the window rolls over", allowedAfter.allowed === true);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 3. Different keys do not interfere
|
|
// ---------------------------------------------------------------------------
|
|
console.log("\n[3] Keys are isolated buckets");
|
|
{
|
|
resetRateLimits();
|
|
const keyA = "reset:ip:192.0.2.1";
|
|
const keyB = "reset:ip:192.0.2.2";
|
|
rateLimit(keyA, 1, 60_000);
|
|
rateLimit(keyA, 1, 60_000); // exhaust A
|
|
check("key A is denied after its own limit", rateLimit(keyA, 1, 60_000).allowed === false);
|
|
check("key B is still allowed", rateLimit(keyB, 1, 60_000).allowed === true);
|
|
check(
|
|
"route prefix keeps routes apart",
|
|
rateLimit("login:ip:192.0.2.1", 1, 60_000).allowed === true
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 4. clientIp header policy
|
|
// ---------------------------------------------------------------------------
|
|
console.log("\n[4] clientIp trusts the LAST valid XFF entry, never the first");
|
|
{
|
|
const req = (headers) => new Request("http://localhost", { headers });
|
|
|
|
check(
|
|
"takes last valid IP when the client spoofs leading entries",
|
|
clientIp(req({ "x-forwarded-for": "203.0.113.7, 10.0.0.1, 198.51.100.9" })) ===
|
|
"198.51.100.9",
|
|
clientIp(req({ "x-forwarded-for": "203.0.113.7, 10.0.0.1, 198.51.100.9" }))
|
|
);
|
|
check(
|
|
"ignores a spoofed-only chain (no valid IP) and falls back to x-real-ip",
|
|
clientIp(req({ "x-forwarded-for": "evil, not-an-ip", "x-real-ip": "10.0.0.5" })) ===
|
|
"10.0.0.5"
|
|
);
|
|
check(
|
|
"falls back to x-real-ip when XFF is absent",
|
|
clientIp(req({ "x-real-ip": "10.0.0.6" })) === "10.0.0.6"
|
|
);
|
|
check(
|
|
"never returns a spoofed string — 'unknown' when nothing validates",
|
|
clientIp(req({ "x-forwarded-for": "spoofed-value" })) === "unknown"
|
|
);
|
|
check("no headers at all → 'unknown'", clientIp(req({})) === "unknown");
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
console.log(`\n${checks} checks, ${failures} failure(s)`);
|
|
if (failures > 0) {
|
|
console.error("RATE-LIMIT VERIFICATION FAILED");
|
|
process.exit(1);
|
|
}
|
|
console.log("RATE-LIMIT VERIFICATION PASSED");
|