import * as crypto from 'crypto'; import * as dotenv from 'dotenv'; dotenv.config(); const RATE_LIMIT_WINDOW_MIN = parseInt(process.env.TICKETS_RATE_LIMIT_WINDOW_MIN || '10', 10); const RATE_LIMIT_WINDOW_MS = RATE_LIMIT_WINDOW_MIN * 60 * 1000; // Salted per-process (not persisted/configured) - these limiters are // in-memory-only with no DB backstop, so the salt only needs to survive // for the current process's lifetime, unlike Feedback's FEEDBACK_IP_SALT // which also salts a persisted ip_hash column. const IP_SALT = crypto.randomBytes(32).toString('hex'); export const hashIp = (ip: string): string => { return crypto.createHash('sha256').update(IP_SALT + ip).digest('hex'); }; /** * Two independent budgets, not one shared counter: validating a code (GET) * is a cheap, repeatable lookup a guest's own browser triggers on every * page load/reload/back-navigation of their redemption link - a shared * budget with redeem meant a guest could exhaust it just by reloading the * page a few times before ever submitting. Redeeming (POST) is the * sensitive, code-consuming action and stays tightly limited; validating * is limited too (it's still the enumeration vector for guessing codes), * just with a much larger allowance headroomed for normal page-reload * behaviour. */ const createLimiter = (max: number) => { const recentRequests = new Map(); const pruneOld = (timestamps: number[], now: number): number[] => { return timestamps.filter(t => now - t < RATE_LIMIT_WINDOW_MS); }; const sweepInterval = setInterval(() => { const now = Date.now(); for (const [ipHash, timestamps] of recentRequests) { if (pruneOld(timestamps, now).length === 0) { recentRequests.delete(ipHash); } } }, RATE_LIMIT_WINDOW_MS); sweepInterval.unref(); return { isRateLimited: (ipHash: string): boolean => { const now = Date.now(); const timestamps = pruneOld(recentRequests.get(ipHash) || [], now); if (timestamps.length > 0) { recentRequests.set(ipHash, timestamps); } else { recentRequests.delete(ipHash); } return timestamps.length >= max; }, recordRequest: (ipHash: string): void => { const now = Date.now(); const timestamps = pruneOld(recentRequests.get(ipHash) || [], now); timestamps.push(now); recentRequests.set(ipHash, timestamps); } }; }; export const validateLimiter = createLimiter(parseInt(process.env.TICKETS_VALIDATE_RATE_LIMIT_MAX || '30', 10)); export const redeemLimiter = createLimiter(parseInt(process.env.TICKETS_REDEEM_RATE_LIMIT_MAX || '10', 10));