deckhearth/lib/rate-limit.js

132 lines
4.7 KiB
JavaScript
Raw Normal View History

fix(auth): tighten public auth surface — CORS + rate limit (Brief 4 of fix-auth-bypass) Adds rate limiting to /api/auth/login and /api/auth/register and removes their wide-open CORS allowlist. Rate limiting (@upstash/ratelimit + @upstash/redis): - 5 attempts per 15-minute sliding window per IP, prefix "tcgvault:auth" - new lib/rate-limit.js, lazy singleton, single source of truth - reads KV_REST_API_URL / KV_REST_API_TOKEN (Vercel Upstash Marketplace convention — auto-provisioned, no manual env-var setup needed) - fail-closed in production if env vars are missing (better to error one login than silently disable brute-force protection on live) - fail-open in dev/test if env vars are missing (single console.warn) - fail-open on Upstash backend outage (defense-in-depth — don't lock the entire userbase out if Upstash is down) - IP extracted from x-forwarded-for first hop, with socket fallback; NOT req.body.email (rotates) or Authorization header (absent on unauthenticated login) CORS: - Removed Access-Control-Allow-Origin: * + companion headers + OPTIONS preflight from login.js and register.js - These are first-party endpoints called from the same-origin SPA; the "*" allowlist was a development convenience that shipped to prod - verify.js is OUT OF SCOPE per architect's "cors-tighten" deferral (see convoy plan § Architect's calls) Other handler ordering preserved verbatim per brief: method gate first, then rate-limit check (returns 429 with Retry-After header), then the existing try/catch + body parsing + DB work. Pre-merge requirements: KV_REST_API_URL + KV_REST_API_TOKEN must be set in Vercel Production (already done — Upstash marketplace integration auto-provisioned both, confirmed by maintainer 2026-05-23). Convoy: fix-auth-bypass / Brief 4 Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-23 11:51:33 -04:00
import { Ratelimit } from '@upstash/ratelimit';
import { Redis } from '@upstash/redis';
// Per-class limiter configuration. Distinct Redis prefix per class is
// REQUIRED — without it, a search-class hit would consume the auth-class
// budget for the same identifier. `slidingWindow` chosen across all
// classes to match Brief 4's existing algorithm; switching to
// `tokenBucket` per-class would be its own convoy.
const LIMITER_CONFIG = {
feat(brand): infrastructure + email migration for Deck Hearth (B2 of 2) Closes the pick-a-name convoy. Applies D1-D5 + Risk 4 PRESERVE per operator gate-1 ratification. Infrastructure renames: - lib/rate-limit.js: 5 Redis key prefixes tcgvault:* → deckhearth:* (D5). One-time per-15-min / per-1-hour counter reset accepted; no user impact because counter windows are short anyway. Existing rate-limit state in Upstash will accumulate at the new prefix on first request. - package.json: name field tcg-vault → deck-hearth (D2) - package-lock.json: regenerated for the name change; STOP-on-churn protocol confirmed only the two name lines changed (no dep churn) - All three test users (admin/alice/bob) renamed to @deckhearth.com (D4) - One-off migration script scripts/migrations/2026-05-24-rename-admin- email.js (NEW): ESM, idempotent, UNIQUE-collision-safe. Per the no-go-zones rule for new migrations. Operator MUST run post-deploy. - README.md + TESTING_GUIDE.md operator-caveat blockquotes flagged - pages/login.js demo-credential pre-fill updated PRESERVED per Risk 4: - test/lib/permission-middleware.test.js literal admin@tcgvault.com with 7-line architect-authored "why" comment block. This is the documented pre-fix-auth-bypass bug shape; the regression-lock literal stays as historical truth. Verification: - npm run lint: 128 problems (baseline preserved) - npm run test:run: 21/21 pass (preserved literal keeps green) - Grep across full repo: 0 hits for TCG Vault / tcgvault / tcg-vault except the explicit preserve in the test file + .convoys/ historical - lib/rate-limit.js: 5 deckhearth: prefixes, 0 tcgvault: prefixes - node --check on the new migration script: exit 0 - git diff package-lock.json: only the 2 "name": lines changed (no churn) Operator post-merge action: - Run `node scripts/migrations/2026-05-24-rename-admin-email.js` against the production Neon DB. Order matters: migration FIRST, then any subsequent `npm run setup-db` invocation. Migration script will refuse to run if collision detected (means setup-db already ran post-rename). Architect brief: .convoys/pick-a-name/brief-2-infrastructure-and-email-migration.md Architect commit: 50ce9ab Operator gate-1: D1-D5 + Risk 4 PRESERVE ratified. Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-25 02:58:48 -04:00
auth: { limit: 5, window: '15 m', prefix: 'deckhearth:auth' },
search: { limit: 60, window: '1 m', prefix: 'deckhearth:search' },
upload: { limit: 10, window: '1 h', prefix: 'deckhearth:upload' },
generate: { limit: 5, window: '1 h', prefix: 'deckhearth:generate' },
import: { limit: 5, window: '1 h', prefix: 'deckhearth:import' },
};
fix(auth): tighten public auth surface — CORS + rate limit (Brief 4 of fix-auth-bypass) Adds rate limiting to /api/auth/login and /api/auth/register and removes their wide-open CORS allowlist. Rate limiting (@upstash/ratelimit + @upstash/redis): - 5 attempts per 15-minute sliding window per IP, prefix "tcgvault:auth" - new lib/rate-limit.js, lazy singleton, single source of truth - reads KV_REST_API_URL / KV_REST_API_TOKEN (Vercel Upstash Marketplace convention — auto-provisioned, no manual env-var setup needed) - fail-closed in production if env vars are missing (better to error one login than silently disable brute-force protection on live) - fail-open in dev/test if env vars are missing (single console.warn) - fail-open on Upstash backend outage (defense-in-depth — don't lock the entire userbase out if Upstash is down) - IP extracted from x-forwarded-for first hop, with socket fallback; NOT req.body.email (rotates) or Authorization header (absent on unauthenticated login) CORS: - Removed Access-Control-Allow-Origin: * + companion headers + OPTIONS preflight from login.js and register.js - These are first-party endpoints called from the same-origin SPA; the "*" allowlist was a development convenience that shipped to prod - verify.js is OUT OF SCOPE per architect's "cors-tighten" deferral (see convoy plan § Architect's calls) Other handler ordering preserved verbatim per brief: method gate first, then rate-limit check (returns 429 with Retry-After header), then the existing try/catch + body parsing + DB work. Pre-merge requirements: KV_REST_API_URL + KV_REST_API_TOKEN must be set in Vercel Production (already done — Upstash marketplace integration auto-provisioned both, confirmed by maintainer 2026-05-23). Convoy: fix-auth-bypass / Brief 4 Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-23 11:51:33 -04:00
// Lazy singleton. Module-load init would throw in environments without
// Upstash env vars (local dev pre-onboarding, tests that transitively
// import the auth handlers, Vercel build-time bundling). Defer
// construction until the first request actually arrives.
fix(auth): tighten public auth surface — CORS + rate limit (Brief 4 of fix-auth-bypass) Adds rate limiting to /api/auth/login and /api/auth/register and removes their wide-open CORS allowlist. Rate limiting (@upstash/ratelimit + @upstash/redis): - 5 attempts per 15-minute sliding window per IP, prefix "tcgvault:auth" - new lib/rate-limit.js, lazy singleton, single source of truth - reads KV_REST_API_URL / KV_REST_API_TOKEN (Vercel Upstash Marketplace convention — auto-provisioned, no manual env-var setup needed) - fail-closed in production if env vars are missing (better to error one login than silently disable brute-force protection on live) - fail-open in dev/test if env vars are missing (single console.warn) - fail-open on Upstash backend outage (defense-in-depth — don't lock the entire userbase out if Upstash is down) - IP extracted from x-forwarded-for first hop, with socket fallback; NOT req.body.email (rotates) or Authorization header (absent on unauthenticated login) CORS: - Removed Access-Control-Allow-Origin: * + companion headers + OPTIONS preflight from login.js and register.js - These are first-party endpoints called from the same-origin SPA; the "*" allowlist was a development convenience that shipped to prod - verify.js is OUT OF SCOPE per architect's "cors-tighten" deferral (see convoy plan § Architect's calls) Other handler ordering preserved verbatim per brief: method gate first, then rate-limit check (returns 429 with Retry-After header), then the existing try/catch + body parsing + DB work. Pre-merge requirements: KV_REST_API_URL + KV_REST_API_TOKEN must be set in Vercel Production (already done — Upstash marketplace integration auto-provisioned both, confirmed by maintainer 2026-05-23). Convoy: fix-auth-bypass / Brief 4 Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-23 11:51:33 -04:00
let cached = null;
function init() {
// Env-var names match Vercel's Upstash Marketplace integration, which
// auto-provisions KV_REST_API_URL and KV_REST_API_TOKEN. See
// https://upstash.com/docs/redis/howto/vercelintegration. Single-source-
// of-truth — do NOT alias to UPSTASH_REDIS_REST_*.
const url = process.env.KV_REST_API_URL;
const token = process.env.KV_REST_API_TOKEN;
if (url && token) {
const redis = new Redis({ url, token });
const instances = new Map();
for (const [name, cfg] of Object.entries(LIMITER_CONFIG)) {
instances.set(
name,
new Ratelimit({
redis,
limiter: Ratelimit.slidingWindow(cfg.limit, cfg.window),
prefix: cfg.prefix,
})
);
}
return { mode: 'live', instances };
fix(auth): tighten public auth surface — CORS + rate limit (Brief 4 of fix-auth-bypass) Adds rate limiting to /api/auth/login and /api/auth/register and removes their wide-open CORS allowlist. Rate limiting (@upstash/ratelimit + @upstash/redis): - 5 attempts per 15-minute sliding window per IP, prefix "tcgvault:auth" - new lib/rate-limit.js, lazy singleton, single source of truth - reads KV_REST_API_URL / KV_REST_API_TOKEN (Vercel Upstash Marketplace convention — auto-provisioned, no manual env-var setup needed) - fail-closed in production if env vars are missing (better to error one login than silently disable brute-force protection on live) - fail-open in dev/test if env vars are missing (single console.warn) - fail-open on Upstash backend outage (defense-in-depth — don't lock the entire userbase out if Upstash is down) - IP extracted from x-forwarded-for first hop, with socket fallback; NOT req.body.email (rotates) or Authorization header (absent on unauthenticated login) CORS: - Removed Access-Control-Allow-Origin: * + companion headers + OPTIONS preflight from login.js and register.js - These are first-party endpoints called from the same-origin SPA; the "*" allowlist was a development convenience that shipped to prod - verify.js is OUT OF SCOPE per architect's "cors-tighten" deferral (see convoy plan § Architect's calls) Other handler ordering preserved verbatim per brief: method gate first, then rate-limit check (returns 429 with Retry-After header), then the existing try/catch + body parsing + DB work. Pre-merge requirements: KV_REST_API_URL + KV_REST_API_TOKEN must be set in Vercel Production (already done — Upstash marketplace integration auto-provisioned both, confirmed by maintainer 2026-05-23). Convoy: fix-auth-bypass / Brief 4 Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-23 11:51:33 -04:00
}
if (process.env.NODE_ENV === 'production') {
// Fail-closed in production. A single failed login is a better outcome
// than silently disabling brute-force protection on the live site.
throw new Error(
'[rate-limit] Upstash not configured. Set KV_REST_API_URL and KV_REST_API_TOKEN in the deployment environment (auto-provisioned by the Vercel Upstash Marketplace integration) before serving auth traffic.'
);
}
console.warn(
'[rate-limit] KV_REST_API_URL / KV_REST_API_TOKEN not set — rate limiting disabled (dev/test only)'
);
return { mode: 'noop' };
}
function extractIpIdentifier(req) {
fix(auth): tighten public auth surface — CORS + rate limit (Brief 4 of fix-auth-bypass) Adds rate limiting to /api/auth/login and /api/auth/register and removes their wide-open CORS allowlist. Rate limiting (@upstash/ratelimit + @upstash/redis): - 5 attempts per 15-minute sliding window per IP, prefix "tcgvault:auth" - new lib/rate-limit.js, lazy singleton, single source of truth - reads KV_REST_API_URL / KV_REST_API_TOKEN (Vercel Upstash Marketplace convention — auto-provisioned, no manual env-var setup needed) - fail-closed in production if env vars are missing (better to error one login than silently disable brute-force protection on live) - fail-open in dev/test if env vars are missing (single console.warn) - fail-open on Upstash backend outage (defense-in-depth — don't lock the entire userbase out if Upstash is down) - IP extracted from x-forwarded-for first hop, with socket fallback; NOT req.body.email (rotates) or Authorization header (absent on unauthenticated login) CORS: - Removed Access-Control-Allow-Origin: * + companion headers + OPTIONS preflight from login.js and register.js - These are first-party endpoints called from the same-origin SPA; the "*" allowlist was a development convenience that shipped to prod - verify.js is OUT OF SCOPE per architect's "cors-tighten" deferral (see convoy plan § Architect's calls) Other handler ordering preserved verbatim per brief: method gate first, then rate-limit check (returns 429 with Retry-After header), then the existing try/catch + body parsing + DB work. Pre-merge requirements: KV_REST_API_URL + KV_REST_API_TOKEN must be set in Vercel Production (already done — Upstash marketplace integration auto-provisioned both, confirmed by maintainer 2026-05-23). Convoy: fix-auth-bypass / Brief 4 Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-23 11:51:33 -04:00
const xff = req.headers?.['x-forwarded-for'];
const firstHop = Array.isArray(xff) ? xff[0] : xff?.split(',')[0]?.trim();
return firstHop || req.socket?.remoteAddress || 'anonymous';
}
// THROWS on missing userId. Per-user limiters MUST sit AFTER the auth
// check in the handler body — silently falling back to IP here would
// convert a per-user limit into a per-IP limit, locking out other
// household members for one user's behavior. The throw surfaces the
// misordering immediately during development rather than at first
// production incident.
function extractUserIdentifier(userId) {
if (
userId === null ||
userId === undefined ||
userId === '' ||
(typeof userId === 'number' && Number.isNaN(userId))
) {
throw new Error(
'[rate-limit] extractUserIdentifier called without an authenticated userId. Place the rate-limit gate AFTER the auth check, never before.'
);
}
return `user:${userId}`;
}
async function check(className, identifier) {
fix(auth): tighten public auth surface — CORS + rate limit (Brief 4 of fix-auth-bypass) Adds rate limiting to /api/auth/login and /api/auth/register and removes their wide-open CORS allowlist. Rate limiting (@upstash/ratelimit + @upstash/redis): - 5 attempts per 15-minute sliding window per IP, prefix "tcgvault:auth" - new lib/rate-limit.js, lazy singleton, single source of truth - reads KV_REST_API_URL / KV_REST_API_TOKEN (Vercel Upstash Marketplace convention — auto-provisioned, no manual env-var setup needed) - fail-closed in production if env vars are missing (better to error one login than silently disable brute-force protection on live) - fail-open in dev/test if env vars are missing (single console.warn) - fail-open on Upstash backend outage (defense-in-depth — don't lock the entire userbase out if Upstash is down) - IP extracted from x-forwarded-for first hop, with socket fallback; NOT req.body.email (rotates) or Authorization header (absent on unauthenticated login) CORS: - Removed Access-Control-Allow-Origin: * + companion headers + OPTIONS preflight from login.js and register.js - These are first-party endpoints called from the same-origin SPA; the "*" allowlist was a development convenience that shipped to prod - verify.js is OUT OF SCOPE per architect's "cors-tighten" deferral (see convoy plan § Architect's calls) Other handler ordering preserved verbatim per brief: method gate first, then rate-limit check (returns 429 with Retry-After header), then the existing try/catch + body parsing + DB work. Pre-merge requirements: KV_REST_API_URL + KV_REST_API_TOKEN must be set in Vercel Production (already done — Upstash marketplace integration auto-provisioned both, confirmed by maintainer 2026-05-23). Convoy: fix-auth-bypass / Brief 4 Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-23 11:51:33 -04:00
if (!cached) {
cached = init();
}
if (cached.mode === 'noop') {
return { allowed: true, remaining: Infinity, reset: 0 };
}
const limiter = cached.instances.get(className);
if (!limiter) {
throw new Error(`[rate-limit] Unknown limiter class: ${className}`);
}
fix(auth): tighten public auth surface — CORS + rate limit (Brief 4 of fix-auth-bypass) Adds rate limiting to /api/auth/login and /api/auth/register and removes their wide-open CORS allowlist. Rate limiting (@upstash/ratelimit + @upstash/redis): - 5 attempts per 15-minute sliding window per IP, prefix "tcgvault:auth" - new lib/rate-limit.js, lazy singleton, single source of truth - reads KV_REST_API_URL / KV_REST_API_TOKEN (Vercel Upstash Marketplace convention — auto-provisioned, no manual env-var setup needed) - fail-closed in production if env vars are missing (better to error one login than silently disable brute-force protection on live) - fail-open in dev/test if env vars are missing (single console.warn) - fail-open on Upstash backend outage (defense-in-depth — don't lock the entire userbase out if Upstash is down) - IP extracted from x-forwarded-for first hop, with socket fallback; NOT req.body.email (rotates) or Authorization header (absent on unauthenticated login) CORS: - Removed Access-Control-Allow-Origin: * + companion headers + OPTIONS preflight from login.js and register.js - These are first-party endpoints called from the same-origin SPA; the "*" allowlist was a development convenience that shipped to prod - verify.js is OUT OF SCOPE per architect's "cors-tighten" deferral (see convoy plan § Architect's calls) Other handler ordering preserved verbatim per brief: method gate first, then rate-limit check (returns 429 with Retry-After header), then the existing try/catch + body parsing + DB work. Pre-merge requirements: KV_REST_API_URL + KV_REST_API_TOKEN must be set in Vercel Production (already done — Upstash marketplace integration auto-provisioned both, confirmed by maintainer 2026-05-23). Convoy: fix-auth-bypass / Brief 4 Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-23 11:51:33 -04:00
try {
const { success, remaining, reset } = await limiter.limit(identifier);
fix(auth): tighten public auth surface — CORS + rate limit (Brief 4 of fix-auth-bypass) Adds rate limiting to /api/auth/login and /api/auth/register and removes their wide-open CORS allowlist. Rate limiting (@upstash/ratelimit + @upstash/redis): - 5 attempts per 15-minute sliding window per IP, prefix "tcgvault:auth" - new lib/rate-limit.js, lazy singleton, single source of truth - reads KV_REST_API_URL / KV_REST_API_TOKEN (Vercel Upstash Marketplace convention — auto-provisioned, no manual env-var setup needed) - fail-closed in production if env vars are missing (better to error one login than silently disable brute-force protection on live) - fail-open in dev/test if env vars are missing (single console.warn) - fail-open on Upstash backend outage (defense-in-depth — don't lock the entire userbase out if Upstash is down) - IP extracted from x-forwarded-for first hop, with socket fallback; NOT req.body.email (rotates) or Authorization header (absent on unauthenticated login) CORS: - Removed Access-Control-Allow-Origin: * + companion headers + OPTIONS preflight from login.js and register.js - These are first-party endpoints called from the same-origin SPA; the "*" allowlist was a development convenience that shipped to prod - verify.js is OUT OF SCOPE per architect's "cors-tighten" deferral (see convoy plan § Architect's calls) Other handler ordering preserved verbatim per brief: method gate first, then rate-limit check (returns 429 with Retry-After header), then the existing try/catch + body parsing + DB work. Pre-merge requirements: KV_REST_API_URL + KV_REST_API_TOKEN must be set in Vercel Production (already done — Upstash marketplace integration auto-provisioned both, confirmed by maintainer 2026-05-23). Convoy: fix-auth-bypass / Brief 4 Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-23 11:51:33 -04:00
return { allowed: success, remaining, reset };
} catch (err) {
// Fail-open on Upstash outage. A hard outage at the rate-limit backend
// should not lock the entire user base out. Brute-force protection
// lives behind defense-in-depth (Vercel firewall, etc.).
fix(auth): tighten public auth surface — CORS + rate limit (Brief 4 of fix-auth-bypass) Adds rate limiting to /api/auth/login and /api/auth/register and removes their wide-open CORS allowlist. Rate limiting (@upstash/ratelimit + @upstash/redis): - 5 attempts per 15-minute sliding window per IP, prefix "tcgvault:auth" - new lib/rate-limit.js, lazy singleton, single source of truth - reads KV_REST_API_URL / KV_REST_API_TOKEN (Vercel Upstash Marketplace convention — auto-provisioned, no manual env-var setup needed) - fail-closed in production if env vars are missing (better to error one login than silently disable brute-force protection on live) - fail-open in dev/test if env vars are missing (single console.warn) - fail-open on Upstash backend outage (defense-in-depth — don't lock the entire userbase out if Upstash is down) - IP extracted from x-forwarded-for first hop, with socket fallback; NOT req.body.email (rotates) or Authorization header (absent on unauthenticated login) CORS: - Removed Access-Control-Allow-Origin: * + companion headers + OPTIONS preflight from login.js and register.js - These are first-party endpoints called from the same-origin SPA; the "*" allowlist was a development convenience that shipped to prod - verify.js is OUT OF SCOPE per architect's "cors-tighten" deferral (see convoy plan § Architect's calls) Other handler ordering preserved verbatim per brief: method gate first, then rate-limit check (returns 429 with Retry-After header), then the existing try/catch + body parsing + DB work. Pre-merge requirements: KV_REST_API_URL + KV_REST_API_TOKEN must be set in Vercel Production (already done — Upstash marketplace integration auto-provisioned both, confirmed by maintainer 2026-05-23). Convoy: fix-auth-bypass / Brief 4 Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-23 11:51:33 -04:00
console.error('[rate-limit]', err);
return { allowed: true, remaining: Infinity, reset: 0 };
}
}
export async function checkAuthRateLimit(req) {
return check('auth', extractIpIdentifier(req));
}
export async function checkSearchRateLimit(req) {
return check('search', extractIpIdentifier(req));
}
export async function checkUploadRateLimit(req, userId) {
return check('upload', extractUserIdentifier(userId));
}
export async function checkGenerateRateLimit(req, userId) {
return check('generate', extractUserIdentifier(userId));
}
export async function checkImportRateLimit(req, userId) {
return check('import', extractUserIdentifier(userId));
}