Files
scan-receipts/scripts/verify_rate_limit.mjs
Timo 84b9987c49 Add full application: receipt scanning, auth, billing, and account deletion
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>
2026-08-19 20:59:04 +02:00

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");