#!/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");