/** * Shared Rate Limiting Middleware for Edge Functions * Prevents abuse and DoS attacks with in-memory rate limiting */ import { recordRateLimitMetric } from './rateLimitMetrics.ts'; import { extractUserIdFromAuth, getClientIP } from './authHelpers.ts'; export interface RateLimitConfig { windowMs: number; // Time window in milliseconds maxRequests: number; // Max requests per window maxMapSize?: number; // Max IPs to track (default: 10000) keyGenerator?: (req: Request) => string; // Custom key generator trustProxy?: boolean; // Trust X-Forwarded-For header } export interface RateLimitResult { allowed: boolean; retryAfter?: number; remaining?: number; } class RateLimiter { private rateLimitMap = new Map(); private config: Required; private cleanupInterval: number; private tierName: string; private functionName?: string; constructor(config: RateLimitConfig, tierName: string = 'custom', functionName?: string) { this.tierName = tierName; this.functionName = functionName; this.config = { maxMapSize: 10000, keyGenerator: (req: Request) => this.getClientIP(req), trustProxy: true, ...config }; // Setup periodic cleanup this.cleanupInterval = setInterval( () => this.cleanupExpiredEntries(), Math.min(this.config.windowMs / 2, 30000) ); } private getClientIP(req: Request): string { // Use centralized auth helper for consistent IP extraction return getClientIP(req); } private cleanupExpiredEntries(): void { try { const now = Date.now(); let deletedCount = 0; for (const [key, data] of this.rateLimitMap.entries()) { if (now > data.resetAt) { this.rateLimitMap.delete(key); deletedCount++; } } // Cleanup runs silently unless there are issues } catch (error) { // Emergency: Clear oldest 30% if cleanup fails if (this.rateLimitMap.size > this.config.maxMapSize) { const toClear = Math.floor(this.rateLimitMap.size * 0.3); const keys = Array.from(this.rateLimitMap.keys()).slice(0, toClear); keys.forEach(key => this.rateLimitMap.delete(key)); } } } check(req: Request, functionName?: string): RateLimitResult { const key = this.config.keyGenerator(req); const now = Date.now(); const existing = this.rateLimitMap.get(key); // Extract metadata for metrics const clientIP = getClientIP(req); const userId = extractUserIdFromAuth(req); const actualFunctionName = functionName || this.functionName || 'unknown'; // Check existing entry if (existing && now <= existing.resetAt) { if (existing.count >= this.config.maxRequests) { const retryAfter = Math.ceil((existing.resetAt - now) / 1000); // Record blocked request metric recordRateLimitMetric({ timestamp: now, functionName: actualFunctionName, clientIP, userId: userId || undefined, allowed: false, remaining: 0, retryAfter, tier: this.tierName, }); return { allowed: false, retryAfter, remaining: 0 }; } existing.count++; const remaining = this.config.maxRequests - existing.count; // Record allowed request metric recordRateLimitMetric({ timestamp: now, functionName: actualFunctionName, clientIP, userId: userId || undefined, allowed: true, remaining, tier: this.tierName, }); return { allowed: true, remaining }; } // Handle capacity if (!existing && this.rateLimitMap.size >= this.config.maxMapSize) { this.cleanupExpiredEntries(); // LRU eviction if still at capacity if (this.rateLimitMap.size >= this.config.maxMapSize) { const toDelete = Math.floor(this.config.maxMapSize * 0.3); const sortedEntries = Array.from(this.rateLimitMap.entries()) .sort((a, b) => a[1].resetAt - b[1].resetAt); for (let i = 0; i < toDelete && i < sortedEntries.length; i++) { this.rateLimitMap.delete(sortedEntries[i][0]); } } } // Create new entry this.rateLimitMap.set(key, { count: 1, resetAt: now + this.config.windowMs }); const remaining = this.config.maxRequests - 1; // Record allowed request metric recordRateLimitMetric({ timestamp: now, functionName: actualFunctionName, clientIP, userId: userId || undefined, allowed: true, remaining, tier: this.tierName, }); return { allowed: true, remaining }; } destroy(): void { clearInterval(this.cleanupInterval); this.rateLimitMap.clear(); } } // Import centralized rate limit configurations import { RATE_LIMIT_STRICT, RATE_LIMIT_MODERATE, RATE_LIMIT_STANDARD, RATE_LIMIT_LENIENT, RATE_LIMIT_GENEROUS, RATE_LIMIT_PER_USER_STRICT, RATE_LIMIT_PER_USER_MODERATE, RATE_LIMIT_PER_USER_STANDARD, RATE_LIMIT_PER_USER_LENIENT, } from './rateLimitConfig.ts'; // Export factory function for creating custom rate limiters export function createRateLimiter(config: RateLimitConfig, tierName?: string, functionName?: string): RateLimiter { return new RateLimiter(config, tierName, functionName); } /** * Pre-configured rate limiters using centralized tier definitions * * These are singleton instances that should be imported and used by edge functions. * See rateLimitConfig.ts for detailed documentation on when to use each tier. */ export const rateLimiters = { // Strict: 5 requests/minute - For expensive operations strict: createRateLimiter(RATE_LIMIT_STRICT, 'strict'), // Moderate: 10 requests/minute - For moderation and submissions moderate: createRateLimiter(RATE_LIMIT_MODERATE, 'moderate'), // Standard: 20 requests/minute - For typical operations (DEPRECATED: use 'moderate' for 10/min or 'standard' for 20/min) standard: createRateLimiter(RATE_LIMIT_MODERATE, 'standard'), // Keeping for backward compatibility // Lenient: 30 requests/minute - For lightweight reads lenient: createRateLimiter(RATE_LIMIT_LENIENT, 'lenient'), // Generous: 60 requests/minute - For high-frequency operations generous: createRateLimiter(RATE_LIMIT_GENEROUS, 'generous'), // Per-user rate limiters (key by user ID instead of IP) perUserStrict: createRateLimiter(RATE_LIMIT_PER_USER_STRICT, 'perUserStrict'), perUserModerate: createRateLimiter(RATE_LIMIT_PER_USER_MODERATE, 'perUserModerate'), perUserStandard: createRateLimiter(RATE_LIMIT_PER_USER_STANDARD, 'perUserStandard'), perUserLenient: createRateLimiter(RATE_LIMIT_PER_USER_LENIENT, 'perUserLenient'), // Legacy per-user factory function (DEPRECATED: use perUserStrict, perUserModerate, etc.) perUser: (maxRequests: number = 20) => createRateLimiter({ ...RATE_LIMIT_PER_USER_STANDARD, maxRequests, }, 'perUser'), }; // Middleware helper export function withRateLimit( handler: (req: Request) => Promise, limiter: RateLimiter, corsHeaders: Record = {}, functionName?: string ): (req: Request) => Promise { return async (req: Request) => { const result = limiter.check(req, functionName); if (!result.allowed) { return new Response( JSON.stringify({ error: 'Rate limit exceeded', message: 'Too many requests. Please try again later.', retryAfter: result.retryAfter }), { status: 429, headers: { ...corsHeaders, 'Content-Type': 'application/json', 'Retry-After': String(result.retryAfter || 60), 'X-RateLimit-Limit': String(limiter['config'].maxRequests), 'X-RateLimit-Remaining': String(result.remaining || 0), } } ); } const response = await handler(req); // Add rate limit headers to successful responses if (result.remaining !== undefined) { response.headers.set('X-RateLimit-Limit', String(limiter['config'].maxRequests)); response.headers.set('X-RateLimit-Remaining', String(result.remaining)); } return response; }; }