/** * Robust rate limiter using both IP address and cookie-based tracking. * Implements sliding window with burst protection and automatic cleanup. */ // Separate maps for IP and cookie tracking const ipRateLimitMap = new Map(); const cookieRateLimitMap = new Map(); // Global flood protection - track overall request volume per IP const floodProtectionMap = new Map(); // Cleanup old entries every 60 seconds to prevent memory leaks const CLEANUP_INTERVAL = 60_000; let lastCleanup = Date.now(); function cleanupStaleEntries() { const now = Date.now(); if (now - lastCleanup < CLEANUP_INTERVAL) return; lastCleanup = now; for (const [key, entry] of ipRateLimitMap) { if (now > entry.resetTime + 60_000) ipRateLimitMap.delete(key); } for (const [key, entry] of cookieRateLimitMap) { if (now > entry.resetTime + 60_000) cookieRateLimitMap.delete(key); } for (const [key, entry] of floodProtectionMap) { if (now > entry.resetTime + 60_000) floodProtectionMap.delete(key); } } /** * Extract client IP from request headers with proxy support. * Falls back through common proxy headers. */ export function getClientIp(request) { const headers = request.headers; // Try standard proxy headers in order of preference const xForwardedFor = headers.get('x-forwarded-for'); if (xForwardedFor) { // Take first IP (original client), trim whitespace const ip = xForwardedFor.split(',')[0].trim(); if (ip && isValidIp(ip)) return normalizeIp(ip); } const xRealIp = headers.get('x-real-ip'); if (xRealIp && isValidIp(xRealIp)) return normalizeIp(xRealIp); const cfConnectingIp = headers.get('cf-connecting-ip'); if (cfConnectingIp && isValidIp(cfConnectingIp)) return normalizeIp(cfConnectingIp); const trueClientIp = headers.get('true-client-ip'); if (trueClientIp && isValidIp(trueClientIp)) return normalizeIp(trueClientIp); // Fallback return 'unknown'; } /** * Basic IP validation to prevent header injection attacks. */ function isValidIp(ip) { if (!ip || typeof ip !== 'string') return false; // Basic sanity check - no weird characters, reasonable length if (ip.length > 45) return false; // Max IPv6 length if (!/^[\d.:a-fA-F]+$/.test(ip)) return false; return true; } /** * Normalize IP for consistent keying (lowercase, trim). */ function normalizeIp(ip) { return ip.toLowerCase().trim(); } /** * Extract nonce cookie from request. */ export function getCookieId(request) { const cookieHeader = request.headers.get('cookie'); if (!cookieHeader) return null; const match = cookieHeader.split(';').find(c => c.trim().startsWith('nonce=')); if (!match) return null; const value = match.split('=')[1]?.trim(); // Validate UUID format loosely if (!value || value.length < 32 || value.length > 40) return null; return value; } /** * Check if an identifier is rate limited. */ function isLimited(map, key, limit, windowMs) { const now = Date.now(); const entry = map.get(key); if (!entry || now > entry.resetTime) { return false; // Not limited, window expired or new } return entry.count >= limit; } /** * Record a request for rate limiting. */ function recordHit(map, key, windowMs) { const now = Date.now(); const entry = map.get(key); if (!entry || now > entry.resetTime) { map.set(key, { count: 1, resetTime: now + windowMs }); } else { entry.count++; } } /** * Get current count for an identifier. */ function getCount(map, key) { const now = Date.now(); const entry = map.get(key); if (!entry || now > entry.resetTime) return 0; return entry.count; } /** * Check flood protection - aggressive rate limit for potential abuse. * This triggers when an IP sends too many requests in a short burst. */ function checkFloodProtection(ip, burstLimit = 30, burstWindowMs = 10_000) { if (ip === 'unknown') return false; // Can't flood-protect unknown IPs effectively const now = Date.now(); const entry = floodProtectionMap.get(ip); if (!entry || now > entry.resetTime) { floodProtectionMap.set(ip, { count: 1, resetTime: now + burstWindowMs }); return false; } entry.count++; return entry.count > burstLimit; } /** * Main rate limit check combining IP and cookie tracking. * * @param {Request} request - The incoming request * @param {Object} options - Rate limit configuration * @param {number} options.limit - Max requests per window * @param {number} options.windowMs - Window duration in ms * @param {number} options.burstLimit - Flood protection burst limit * @param {number} options.burstWindowMs - Flood protection window * @returns {{ allowed: boolean, ip: string, cookieId: string|null, isFlooding: boolean }} */ export function checkRateLimit(request, options = {}) { const { limit = 5, windowMs = 1000, burstLimit = 30, burstWindowMs = 10_000, } = options; cleanupStaleEntries(); const ip = getClientIp(request); const cookieId = getCookieId(request); // Check flood protection first (aggressive anti-abuse) const isFlooding = checkFloodProtection(ip, burstLimit, burstWindowMs); if (isFlooding) { return { allowed: false, ip, cookieId, isFlooding: true }; } // Check both IP and cookie limits // Both must be under limit to allow the request const ipLimited = ip !== 'unknown' && isLimited(ipRateLimitMap, ip, limit, windowMs); const cookieLimited = cookieId && isLimited(cookieRateLimitMap, cookieId, limit, windowMs); // If either is limited, deny if (ipLimited || cookieLimited) { return { allowed: false, ip, cookieId, isFlooding: false }; } return { allowed: true, ip, cookieId, isFlooding: false }; } /** * Record a successful request for rate limiting tracking. * Call this after the request is processed. */ export function recordRequest(request, windowMs = 1000) { const ip = getClientIp(request); const cookieId = getCookieId(request); if (ip !== 'unknown') { recordHit(ipRateLimitMap, ip, windowMs); } if (cookieId) { recordHit(cookieRateLimitMap, cookieId, windowMs); } } /** * Generate a new nonce cookie value. */ export function generateNonce() { return crypto.randomUUID(); } /** * Create Set-Cookie header value for nonce. */ export function createNonceCookie(nonce) { return `nonce=${nonce}; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age=31536000`; } export default { checkRateLimit, recordRequest, getClientIp, getCookieId, generateNonce, createNonceCookie, };