import { createHash } from 'node:crypto'; import { isIP } from 'node:net'; import { Mutex } from 'async-mutex'; import { SecretManagerServiceClient } from '@google-cloud/secret-manager'; import { User, RateLimit, RateLimitConfig, MultiWindowRateLimit } from './types.js'; export interface RateLimitContext { ip?: string; fingerprint?: string; userAgent?: string; sessionId?: string; } export interface RateLimitResult { allowed: boolean; remaining: number; resetTime: number; limit: number; windowMs: number; limitType: RateLimitWindowType; } export type RateLimitWindowType = 'hourly' | 'daily' | 'custom'; export interface RateLimitUsage { count: number; limit: number; resetTime: number; windowMs: number; limitType: RateLimitWindowType; } export interface RateLimitStats { totalKeys: number; config: RateLimitConfig; type: 'memory'; } /** * Logger interface for RateLimitTool */ export interface RateLimitLogger { info: (message: string, meta?: Record) => void; warn: (message: string, meta?: Record) => void; } // Default console logger const consoleLogger: RateLimitLogger = { info: (message: string, meta?: Record) => meta ? console.info(message, meta) : console.info(message), warn: (message: string, meta?: Record) => meta ? console.warn(message, meta) : console.warn(message) }; let rateLimitLogger: RateLimitLogger = consoleLogger; export const setRateLimitLogger = (logger: RateLimitLogger): void => { rateLimitLogger = logger; }; const ONE_HOUR_MS = 60 * 60 * 1000; const ONE_DAY_MS = 24 * ONE_HOUR_MS; const getTestDefaults = (): RateLimitConfig => ({ anonymous: { requests: 5, windowMs: ONE_HOUR_MS }, authenticated: { requests: 50, windowMs: ONE_HOUR_MS }, vip: { requests: 50, windowMs: ONE_HOUR_MS }, admin: { requests: 1000, windowMs: ONE_HOUR_MS } }); const getProductionDefaults = (): RateLimitConfig => ({ anonymous: { requests: 10, windowMs: ONE_DAY_MS }, // 10 daily for anonymous authenticated: { requests: 20, windowMs: ONE_DAY_MS }, // 20 daily for non-VIP authenticated vip: { requests: 50, windowMs: ONE_HOUR_MS }, // 50 hourly for VIP admin: { requests: 1000, windowMs: ONE_HOUR_MS } }); const ANONYMOUS_HOURLY_LIMIT: RateLimit = Object.freeze({ requests: 5, windowMs: ONE_HOUR_MS }); const AUTH_NON_VIP_HOURLY_LIMIT: RateLimit = Object.freeze({ requests: 10, windowMs: ONE_HOUR_MS }); const BUILTIN_FALLBACK_ADMIN_EMAILS: ReadonlyArray = Object.freeze([ 'jleechan@gmail.com', 'jleechantest@gmail.com' ]); const EMAIL_VALIDATION_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; /** * Firebase UID validation regex. * * Firebase allows various UID formats: * - Standard Firebase UIDs: 28 alphanumeric characters (e.g., Sr5YzcQNSbM11C7qejg5tjOrOk32) * - Provider-prefixed UIDs: google-oauth2|..., auth0|..., facebook|..., github|... * - Custom UIDs: Any UTF-8 string up to 128 characters * * Official Firebase documentation states UIDs can be up to 128 characters. * Common formats include: * - google-oauth2|1234567890 * - auth0|abc-123-def * - facebook|987654321 * - Custom IDs with various lengths * * This regex accepts: * - Any non-whitespace characters (allows UTF-8, hyphens, pipes, etc.) * - Length: 1-128 characters (Firebase's documented limit) * - Rejects: Empty strings, whitespace-only strings */ const FIREBASE_UID_REGEX = /^\S{1,128}$/; export class RateLimitTool { private readonly memoryStore: Map = new Map(); private readonly lockMap: Map = new Map(); private readonly identifierWindows: Map = new Map(); protected cleanupInterval: NodeJS.Timeout | null = null; private cleanupSignalHandlers: Partial void>> = {}; // Secret Manager caching private rateLimitCache: RateLimitConfig | null = null; private cacheTimestamp: number = 0; private static readonly CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes private static readonly FALLBACK_CACHE_TTL_MS = 30 * 1000; // 30 seconds for fallback configs private isFallbackCache: boolean = false; // Track if cached config is from fallback // VIP allowlist caching private vipAllowlist: Set = new Set(); private vipAllowlistTimestamp: number = 0; private adminEmailSet: Set | null = null; private adminEmailEnvSignature: string | null = null; private cachedFallbackAdminEmails: string[] | null = null; private fallbackAdminEmailEnvSignature: string | null = null; private adminUidSet: Set | null = null; private adminUidEnvSignature: string | null = null; // Secret Manager client caching (reuse across requests) private secretManagerClient: SecretManagerServiceClient | null = null; private static readonly MAX_TRACKED_IDENTIFIERS = 10_000; private static readonly CLEANUP_INTERVAL_MS = 15 * 60 * 1000; private static readonly CLEANUP_WINDOW_MS = 60 * 60 * 1000; private static isTruthy(value?: string): boolean { return value ? ['true', '1', 'yes', 'on'].includes(value.toLowerCase()) : false; } private static maskId(value?: string): string | undefined { if (!value) { return undefined; } return value.length <= 6 ? `…${value}` : `…${value.slice(-6)}`; } private static isLikelyFirebaseUid(uid: string): boolean { return FIREBASE_UID_REGEX.test(uid); } constructor() { this.startPeriodicCleanup(); } /** * Dispose of resources such as intervals and signal handlers. * Intended primarily for test environments. */ async dispose(): Promise { this.stopCleanup(); this.cleanupSignalHandlers = {}; // Close Secret Manager client to prevent gRPC socket leaks if (this.secretManagerClient) { await this.secretManagerClient.close(); this.secretManagerClient = null; } } /** * Get VIP allowlist from Secret Manager */ private async getVipAllowlist(): Promise> { const now = Date.now(); // Return cached value if still fresh if (this.vipAllowlistTimestamp && (now - this.vipAllowlistTimestamp) < RateLimitTool.CACHE_TTL_MS) { return this.vipAllowlist; } const isTestEnvironment = process.env.NODE_ENV === 'test'; // Skip Secret Manager in test environment (no credentials available) if (isTestEnvironment) { const vipAllowlistEnv = process.env.VIP_ALLOWLIST; if (vipAllowlistEnv) { // Parse VIP allowlist from environment variable (comma-separated or JSON array) let vipEmails: string[] = []; try { const parsed = JSON.parse(vipAllowlistEnv); if (Array.isArray(parsed)) { vipEmails = parsed; } else { // Not an array, fall back to comma-separated parsing vipEmails = vipAllowlistEnv.split(',').map(e => e.trim()).filter(Boolean); } } catch { // Invalid JSON, fallback to comma-separated format vipEmails = vipAllowlistEnv.split(',').map(e => e.trim()).filter(Boolean); } // Validate email format (basic check: contains @) const validEmails = vipEmails.filter(email => { const isValid = typeof email === 'string' && email.includes('@'); if (!isValid && email.trim()) { rateLimitLogger.warn('Invalid email format in VIP_ALLOWLIST, skipping', { value: email, source: 'environment' }); } return isValid; }); const allowlist = new Set(validEmails.map(email => email.toLowerCase())); this.vipAllowlist = allowlist; this.vipAllowlistTimestamp = now; rateLimitLogger.info('✅ Loaded VIP allowlist from VIP_ALLOWLIST environment variable', { count: allowlist.size, totalProvided: vipEmails.length, source: 'environment' }); return allowlist; } rateLimitLogger.info('Test environment: using empty VIP allowlist (set VIP_ALLOWLIST env var to enable)', { hint: 'Set VIP_ALLOWLIST environment variable with comma-separated emails or JSON array to enable VIP in tests' }); return new Set(); } const projectId = process.env.GOOGLE_CLOUD_PROJECT || process.env.GCP_PROJECT; if (!projectId) { return new Set(); } try { // Reuse cached Secret Manager client if (!this.secretManagerClient) { this.secretManagerClient = new SecretManagerServiceClient(); } const client = this.secretManagerClient; const secretName = `projects/${projectId}/secrets/vip-allowlist/versions/latest`; const [version] = await client.accessSecretVersion({ name: secretName }); const payload = version.payload?.data?.toString(); if (payload) { // Parse VIP allowlist (expect JSON array or comma-separated list) let vipEmails: string[] = []; try { const parsed = JSON.parse(payload); if (Array.isArray(parsed)) { vipEmails = parsed; } else { // Not an array, fall back to comma-separated parsing vipEmails = payload.split(',').map((e: string) => e.trim()).filter(Boolean); } } catch { // Invalid JSON, fallback to comma-separated format vipEmails = payload.split(',').map((e: string) => e.trim()).filter(Boolean); } // Validate email format (basic check: contains @) const validEmails = vipEmails.filter(email => { const isValid = typeof email === 'string' && email.includes('@'); if (!isValid && email.trim()) { rateLimitLogger.warn('Invalid email format in Secret Manager vip-allowlist, skipping', { value: email, projectId, secretName: 'vip-allowlist' }); } return isValid; }); this.vipAllowlist = new Set(validEmails.map(email => email.toLowerCase())); this.vipAllowlistTimestamp = now; rateLimitLogger.info('✅ Loaded VIP allowlist from Secret Manager (cache for 5min)', { count: this.vipAllowlist.size, totalProvided: vipEmails.length, projectId, secretName: 'vip-allowlist' }); return this.vipAllowlist; } } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); if (errorMessage.includes('NOT_FOUND') || errorMessage.includes('not found')) { rateLimitLogger.info('Secret Manager vip-allowlist not found, VIP tier disabled', { projectId, hint: 'Create with: gcloud secrets create vip-allowlist --data-file=vip-emails.json' }); } else { rateLimitLogger.warn('Failed to fetch VIP allowlist from Secret Manager', { error: errorMessage, projectId }); } } this.vipAllowlist = new Set(); this.vipAllowlistTimestamp = now; return this.vipAllowlist; } /** * Get current rate limit configuration from Secret Manager or environment defaults */ private async getRateLimitConfig(): Promise { const now = Date.now(); // Return cached value if still fresh (use shorter TTL for fallback configs) if (this.rateLimitCache) { const ttl = this.isFallbackCache ? RateLimitTool.FALLBACK_CACHE_TTL_MS : RateLimitTool.CACHE_TTL_MS; if ((now - this.cacheTimestamp) < ttl) { return this.rateLimitCache; } } const isDevEnvironment = !process.env.NODE_ENV || process.env.NODE_ENV === 'development'; const isTestEnvironment = process.env.NODE_ENV === 'test'; // Skip Secret Manager in test environment (no credentials available, fallback works correctly) // This prevents 30+ log warnings per test run from expected credential errors if (isTestEnvironment) { rateLimitLogger.info('Test environment detected - skipping Secret Manager, using test defaults', { environment: 'test', hint: 'This prevents expected credential errors from polluting test logs' }); // Fall through to test defaults below } else { // Try Secret Manager first (works in dev/prod with GCP credentials) const projectId = process.env.GOOGLE_CLOUD_PROJECT || process.env.GCP_PROJECT; if (projectId) { try { // Reuse cached Secret Manager client (avoid creating new instances per request) if (!this.secretManagerClient) { this.secretManagerClient = new SecretManagerServiceClient(); } const client = this.secretManagerClient; const secretName = `projects/${projectId}/secrets/rate-limit-config/versions/latest`; const [version] = await client.accessSecretVersion({ name: secretName }); const payload = version.payload?.data?.toString(); if (payload) { const secretConfig = JSON.parse(payload); // Helper function to parse rate limit (handles both flat and structured formats) const parseRateLimit = (value: any, defaultRequests: number): RateLimit => { const defaultWindowMs = 60 * 60 * 1000; // 1 hour // Helper to validate and get requests value const getValidRequests = (req: any): number | null => { if (typeof req === 'number' && Number.isFinite(req) && req > 0) { return Math.floor(req); } return null; }; // Helper to validate and get windowMs value const getValidWindowMs = (win: any): number => { if (typeof win === 'number' && Number.isFinite(win) && win > 0) { return Math.floor(win); } return secretConfig.windowMs ?? defaultWindowMs; }; // Structured format: {requests: 100, windowMs: 3600000} if (typeof value === 'object' && value !== null && 'requests' in value) { const requests = getValidRequests(value.requests); if (requests !== null) { return { requests, windowMs: getValidWindowMs(value.windowMs) }; } } // Flat format: just a number for requests (use shared windowMs from root) if (typeof value === 'number') { const requests = getValidRequests(value); if (requests !== null) { return { requests, windowMs: getValidWindowMs(secretConfig.windowMs) }; } } // Fallback to defaults return { requests: defaultRequests, windowMs: getValidWindowMs(secretConfig.windowMs) }; }; const config: RateLimitConfig = { anonymous: parseRateLimit(secretConfig.anonymous, 100), authenticated: parseRateLimit(secretConfig.authenticated, 500), vip: parseRateLimit(secretConfig.vip, 50), admin: parseRateLimit(secretConfig.admin, 1000) }; this.rateLimitCache = config; this.cacheTimestamp = now; this.isFallbackCache = false; // Mark as successful Secret Manager fetch rateLimitLogger.info('✅ Loaded rate limits from Secret Manager (cache for 5min)', { config, projectId, secretName: 'rate-limit-config' }); return config; } } catch (error) { // Log as info if secret doesn't exist yet (expected during initial setup) const errorMessage = error instanceof Error ? error.message : String(error); if (errorMessage.includes('NOT_FOUND') || errorMessage.includes('not found')) { rateLimitLogger.info('Secret Manager rate-limit-config not found, falling back to environment defaults', { projectId, environment: isDevEnvironment ? 'development' : isTestEnvironment ? 'test' : 'production', hint: 'Create with: gcloud secrets create rate-limit-config' }); } else { rateLimitLogger.warn('Failed to fetch rate limits from Secret Manager, falling back to environment defaults', { error: errorMessage, projectId, environment: isDevEnvironment ? 'development' : isTestEnvironment ? 'test' : 'production' }); } } } } // Use environment-appropriate defaults // NOTE: Cache fallback config with shorter TTL (30s instead of 5min) // This allows faster retry after transient Secret Manager failures // while still avoiding repeated network calls within the same test/request batch let defaults: RateLimitConfig; if (isDevEnvironment) { defaults = getTestDefaults(); rateLimitLogger.info('Using development rate limits (test defaults)', { environment: process.env.NODE_ENV || 'undefined (treated as dev)', config: defaults, hint: 'Create Secret Manager secret for dynamic config' }); } else if (isTestEnvironment) { defaults = getTestDefaults(); rateLimitLogger.info('Using test rate limits', { environment: 'test', config: defaults }); } else { defaults = getProductionDefaults(); rateLimitLogger.info('Using production rate limits', { environment: 'production', config: defaults }); } // Cache the fallback config with shorter TTL to allow faster Secret Manager retry this.rateLimitCache = defaults; this.cacheTimestamp = now; this.isFallbackCache = true; // Mark as fallback (will use 30s TTL instead of 5min) return defaults; } /** * Get rate limit for user type */ private async getRateLimit(user: User | null): Promise { const config = await this.getRateLimitConfig(); // Log user info for debugging (redacted for privacy) if (user?.isAuthenticated) { rateLimitLogger.info('Getting rate limit for authenticated user', { hasEmail: !!user.email, email: user.email ? `${user.email.substring(0, 3)}***` : undefined, hasUid: !!user.uid, uid: RateLimitTool.maskId(user.uid) }); } // Check for admin first (requires authentication check via authTool) if (user?.isAuthenticated) { const trimmedEmail = user.email?.trim(); // Primary: Check by email when available if (trimmedEmail) { const normalizedEmail = trimmedEmail.toLowerCase(); const adminEmails = this.getAdminEmailSet(); if (adminEmails.has(normalizedEmail)) { rateLimitLogger.info('Admin rate limit applied via EMAIL', { email: `${normalizedEmail.substring(0, 3)}***`, uid: RateLimitTool.maskId(user.uid), limit: config.admin?.requests, windowMs: config.admin?.windowMs }); return config.admin || config.vip || config.authenticated; } // Check for VIP status const vipAllowlist = await this.getVipAllowlist(); if (vipAllowlist.has(normalizedEmail)) { rateLimitLogger.info('VIP rate limit applied via email', { email: `${normalizedEmail.substring(0, 3)}***`, limit: config.vip?.requests }); return config.vip || config.authenticated; } } else if (user.uid) { // FIX: Fallback to UID check when email is missing // This resolves the bug where admin users with missing email in Firebase token // get treated as authenticated (50/hour) instead of admin (1000/hour) const adminUids = this.getAdminUidSet(); if (adminUids.has(user.uid)) { rateLimitLogger.info('Admin rate limit applied via UID FALLBACK', { uid: RateLimitTool.maskId(user.uid), emailMissing: !user.email, limit: config.admin?.requests, windowMs: config.admin?.windowMs }); return config.admin || config.vip || config.authenticated; } } } const limitType = user?.isAuthenticated ? 'authenticated' : 'anonymous'; const limit = user?.isAuthenticated ? config.authenticated : config.anonymous; rateLimitLogger.info(`Applying ${limitType} rate limit`, { hasEmail: !!user?.email, hasUid: !!user?.uid, limit: limit?.requests, windowMs: limit?.windowMs }); return user?.isAuthenticated ? config.authenticated : config.anonymous; } private static isValidEmail(email: string): boolean { return EMAIL_VALIDATION_REGEX.test(email); } /** * Assembles fallback admin emails using builtin constants and validated env overrides. * * Sources: * - RATE_LIMIT_CONTACT_EMAIL: single contact address. * - RATE_LIMIT_FALLBACK_ADMIN_EMAILS: comma-separated list of fallback admins. * * Results are cached and only recomputed when relevant env vars change. */ private getFallbackAdminEmails(): string[] { const contactEmail = process.env.RATE_LIMIT_CONTACT_EMAIL || ''; const fallbackEnv = process.env.RATE_LIMIT_FALLBACK_ADMIN_EMAILS || ''; const signature = `${contactEmail.trim()}||${fallbackEnv.trim()}`; if (this.fallbackAdminEmailEnvSignature !== signature) { const fallbackEmails = new Set( BUILTIN_FALLBACK_ADMIN_EMAILS.map(email => email.toLowerCase()) ); const trimmedContactEmail = contactEmail.trim(); if (trimmedContactEmail && RateLimitTool.isValidEmail(trimmedContactEmail)) { fallbackEmails.add(trimmedContactEmail.toLowerCase()); } if (fallbackEnv) { fallbackEnv .split(',') .map(email => email.trim()) .filter(email => email && RateLimitTool.isValidEmail(email)) .map(email => email.toLowerCase()) .forEach(email => fallbackEmails.add(email)); } this.cachedFallbackAdminEmails = Array.from(fallbackEmails); this.fallbackAdminEmailEnvSignature = signature; } return this.cachedFallbackAdminEmails || []; } private getAdminEmailSet(): Set { // Check both ADMIN_EMAILS and FIREBASE_ADMIN_EMAILS for backwards compatibility const adminEnv = process.env.ADMIN_EMAILS || process.env.FIREBASE_ADMIN_EMAILS || ''; const fallbackAdmins = this.getFallbackAdminEmails(); const signature = `${adminEnv}||${this.fallbackAdminEmailEnvSignature || ''}`; if (this.adminEmailEnvSignature !== signature) { const parsed = adminEnv .split(',') .map(email => email.trim().toLowerCase()) .filter(Boolean); for (const fallbackEmail of fallbackAdmins) { parsed.push(fallbackEmail); } if (!adminEnv && fallbackAdmins.length > 0) { rateLimitLogger.info('Admin emails not configured; using fallback contact allowlist', { fallbackCount: fallbackAdmins.length }); } this.adminEmailSet = new Set(parsed); this.adminEmailEnvSignature = signature; } if (this.adminEmailSet && this.adminEmailSet.size > 0) { return this.adminEmailSet; } return new Set(fallbackAdmins); } /** * FIX: Get admin UIDs as fallback when email is missing from Firebase token * * This resolves the bug where admin users without email claim in their * Firebase token get treated as anonymous, hitting 5/hour rate limit * instead of 1000/hour admin limit. * * Usage: Set FIREBASE_ADMIN_UIDS environment variable with comma-separated UIDs * Example: FIREBASE_ADMIN_UIDS=UlET1VDO7ORksFiAPQ2b9jI9K9t1,anotherUID123 */ private getAdminUidSet(): Set { const adminUidsEnv = process.env.FIREBASE_ADMIN_UIDS || ''; const signature = adminUidsEnv.trim(); if (this.adminUidEnvSignature !== signature) { const parsed: string[] = []; if (!signature) { rateLimitLogger.info('Admin UIDs not configured; UID-based admin fallback disabled'); } adminUidsEnv .split(',') .map(uid => uid.trim()) .filter(Boolean) .forEach(uid => { if (!RateLimitTool.isLikelyFirebaseUid(uid)) { rateLimitLogger.warn('Ignoring invalid Firebase UID format in FIREBASE_ADMIN_UIDS (contains whitespace or >128 chars)', { uid: RateLimitTool.maskId(uid), length: uid.length, hint: 'Firebase UIDs must be 1-128 non-whitespace characters' }); return; } parsed.push(uid); }); this.adminUidSet = new Set(parsed); this.adminUidEnvSignature = signature; } return this.adminUidSet ?? new Set(); } private async getMultiWindowLimits(user: User | null, baseLimit: RateLimit): Promise { if (!user?.isAuthenticated) { return { hourly: ANONYMOUS_HOURLY_LIMIT, daily: baseLimit }; } if (await this.isAuthenticatedNonVIP(user)) { return { hourly: AUTH_NON_VIP_HOURLY_LIMIT, daily: baseLimit }; } return {}; } private mergeWindowResults(results: RateLimitResult[]): RateLimitResult { if (results.length === 0) { throw new Error('mergeWindowResults requires at least one result'); } const primary = results.reduce((best, current) => { if (current.remaining < best.remaining) { return current; } if (current.remaining === best.remaining) { if (current.windowMs < best.windowMs) { return current; } if (current.windowMs === best.windowMs && current.limit < best.limit) { return current; } } return best; }); return { allowed: true, remaining: primary.remaining, resetTime: primary.resetTime, limit: primary.limit, windowMs: primary.windowMs, limitType: primary.limitType }; } private selectUsageWindow(usages: RateLimitUsage[]): RateLimitUsage { if (usages.length === 0) { throw new Error('selectUsageWindow requires at least one usage result'); } return usages.reduce((best, current) => { const bestRemaining = Math.max(best.limit - best.count, 0); const currentRemaining = Math.max(current.limit - current.count, 0); if (currentRemaining < bestRemaining) { return current; } if (currentRemaining === bestRemaining) { if (current.windowMs < best.windowMs) { return current; } if (current.windowMs === best.windowMs && current.limit < best.limit) { return current; } } return best; }); } private determineLimitType(windowMs: number): RateLimitWindowType { if (windowMs === ONE_HOUR_MS) { return 'hourly'; } if (windowMs === ONE_DAY_MS) { return 'daily'; } return 'custom'; } /** * Check if request is allowed under rate limits * * Note: This implementation uses in-memory storage. For distributed deployments * with multiple instances, consider using Redis-backed rate limiting. */ async checkRateLimit(user: User | null, context: RateLimitContext = {}): Promise { const identifier = this.buildIdentifier(user, context); const limit = await this.getRateLimit(user); const multiWindowLimits = await this.getMultiWindowLimits(user, limit); const windowResults: RateLimitResult[] = []; if (multiWindowLimits.hourly) { const hourlyResult = await this.checkRateLimitMemoryAtomic( `${identifier}:hourly`, multiWindowLimits.hourly, 'hourly' ); if (!hourlyResult.allowed) { return hourlyResult; } windowResults.push(hourlyResult); } if (multiWindowLimits.daily) { const dailyResult = await this.checkRateLimitMemoryAtomic( `${identifier}:daily`, multiWindowLimits.daily, 'daily' ); if (!dailyResult.allowed) { return dailyResult; } windowResults.push(dailyResult); } if (windowResults.length > 0) { return this.mergeWindowResults(windowResults); } return await this.checkRateLimitMemoryAtomic(identifier, limit); } /** * Check if user is authenticated but not admin or VIP * * FIX: Also checks UID-based admin status when email is missing * This ensures admin users with missing email claims still get admin privileges */ private async isAuthenticatedNonVIP(user: User | null): Promise { // Not authenticated at all if (!user?.isAuthenticated) { return false; } // Log UID for verification (redacted for privacy) rateLimitLogger.info('Checking admin status for authenticated user', { hasEmail: !!user.email, email: user.email ? `${user.email.substring(0, 3)}***` : undefined, hasUid: !!user.uid, uid: RateLimitTool.maskId(user.uid) }); // Check admin by email (when available) const trimmedEmail = user.email?.trim(); if (trimmedEmail) { const normalizedEmail = trimmedEmail.toLowerCase(); // Check if admin const adminEmails = this.getAdminEmailSet(); if (adminEmails.has(normalizedEmail)) { rateLimitLogger.info('Admin recognized via EMAIL', { email: `${normalizedEmail.substring(0, 3)}***`, uid: RateLimitTool.maskId(user.uid) }); return false; } // Check if VIP const vipAllowlist = await this.getVipAllowlist(); if (vipAllowlist.has(normalizedEmail)) { rateLimitLogger.info('VIP recognized via email', { email: normalizedEmail.substring(0, 3) + '***' }); return false; } } // FIX: Check admin by UID when email is missing (fallback for missing email claims) // This prevents admin users with missing email from being incorrectly treated as non-VIP if (!trimmedEmail && user.uid) { const adminUids = this.getAdminUidSet(); if (adminUids.has(user.uid)) { rateLimitLogger.info('Admin recognized via UID FALLBACK in isAuthenticatedNonVIP', { uid: RateLimitTool.maskId(user.uid), emailMissing: !user.email }); return false; // Is admin, not non-VIP } } // Default: Authenticated but neither admin nor VIP rateLimitLogger.info('User is authenticated non-VIP', { hasEmail: !!user.email, hasUid: !!user.uid }); return true; } /** * Build a stable identifier for rate limiting based on authentication and client metadata */ private buildIdentifier(user: User | null, context: RateLimitContext): string { if (user?.isAuthenticated && user.id) { return `user:${user.id}`; } if (user?.isAuthenticated) { rateLimitLogger.warn('Authenticated user missing ID for rate limiting; using fallback identifier', { hasEmail: !!user.email, hasName: !!user.name }); // Create stable fallback for authenticated users without ID // CRITICAL: Do NOT use Date.now() or timestamps - creates unique ID per request, bypassing rate limits const fallbackData = `${user.email?.toLowerCase() || 'no-email'}:${user.name || 'no-name'}`; const fallbackHash = createHash('sha256').update(fallbackData).digest('hex').substring(0, 16); return `auth-fallback:${fallbackHash}`; } return this.buildContextIdentifier(context, 'anon'); } private buildContextIdentifier(context: RateLimitContext, prefix: 'anon' | 'auth'): string { const normalizedIp = this.normalizeIp(context.ip); const fingerprint = this.sanitizePart(context.fingerprint); const userAgent = this.sanitizePart(context.userAgent, 256); const sessionId = this.sanitizePart(context.sessionId, 64); const parts = [ normalizedIp ? `ip:${normalizedIp}` : 'ip:unknown', fingerprint ? `fp:${fingerprint}` : '', userAgent ? `ua:${userAgent}` : '', sessionId ? `sid:${sessionId}` : '' ].filter(Boolean); const fallbackLabel = prefix === 'auth' ? 'authenticated' : 'anonymous'; const combinedIdentifier = parts.length > 0 ? parts.join('|') : fallbackLabel; const hashedIdentifier = createHash('sha256').update(combinedIdentifier).digest('hex'); return `${prefix}:${hashedIdentifier}`; } private normalizeIp(ip?: string): string | null { if (!ip) { return null; } // Support forwarding headers with multiple IPs (take the first entry) const firstIp = ip.split(',')[0]?.trim(); if (!firstIp) { return null; } return isIP(firstIp) ? firstIp : null; } private sanitizePart(part?: string, maxLength = 128): string | null { if (!part) { return null; } const trimmed = part.trim(); if (!trimmed) { return null; } // Remove separators used in identifier construction to prevent collisions const cleaned = trimmed.replace(/[|:\n\r]/g, ' ').slice(0, maxLength); return cleaned.length > 0 ? cleaned : null; } /** * Memory-based rate limiting */ /** * Truly atomic memory-based rate limiting with proper concurrency control using async-mutex */ private async checkRateLimitMemoryAtomic( identifier: string, limit: RateLimit, limitType: RateLimitWindowType = this.determineLimitType(limit.windowMs) ): Promise { // Get or create mutex for this identifier let mutex = this.lockMap.get(identifier); if (!mutex) { mutex = new Mutex(); this.lockMap.set(identifier, mutex); } // Use async-mutex for proper atomic operations with automatic release return await mutex.runExclusive(async () => { return this.performAtomicRateCheck(identifier, limit, limitType); }); } /** * Perform the actual atomic rate limit check */ private async performAtomicRateCheck( identifier: string, limit: RateLimit, limitType: RateLimitWindowType ): Promise { const now = Date.now(); const windowStart = now - limit.windowMs; // Get current requests and filter within the window const currentRequests = this.memoryStore.get(identifier) || []; const filteredRequests = currentRequests.filter(req => req > windowStart); // Check if limit exceeded BEFORE adding new request if (filteredRequests.length >= limit.requests) { const oldestTimestamp = filteredRequests[0] ?? now; const resetTime = oldestTimestamp + limit.windowMs; rateLimitLogger.warn('Rate limit exceeded (atomic check)', { identifier, currentCount: filteredRequests.length, limit: limit.requests, resetTime: new Date(resetTime), limitType }); return { allowed: false, remaining: 0, resetTime, limit: limit.requests, windowMs: limit.windowMs, limitType }; } // Add new request atomically filteredRequests.push(now); this.memoryStore.set(identifier, filteredRequests); this.identifierWindows.set(identifier, limit.windowMs); this.enforceMemoryLimits(); const remaining = limit.requests - filteredRequests.length; const resetTime = (filteredRequests[0] ?? now) + limit.windowMs; return { allowed: true, remaining, resetTime, limit: limit.requests, windowMs: limit.windowMs, limitType }; } // DEAD CODE REMOVED: Legacy checkRateLimitMemory method (34 lines) // Replaced by checkRateLimitMemoryAtomic with proper mutex-based concurrency control /** * Get current usage for a user/IP */ async getCurrentUsage( user: User | null, context: RateLimitContext = {} ): Promise { const identifier = this.buildIdentifier(user, context); const limit = await this.getRateLimit(user); const multiWindowLimits = await this.getMultiWindowLimits(user, limit); const usages: RateLimitUsage[] = []; if (multiWindowLimits.hourly) { usages.push( this.getCurrentUsageMemory(`${identifier}:hourly`, multiWindowLimits.hourly, 'hourly') ); } if (multiWindowLimits.daily) { usages.push( this.getCurrentUsageMemory(`${identifier}:daily`, multiWindowLimits.daily, 'daily') ); } if (usages.length > 0) { return this.selectUsageWindow(usages); } return this.getCurrentUsageMemory(identifier, limit, this.determineLimitType(limit.windowMs)); } /** * Memory-based current usage */ private getCurrentUsageMemory( identifier: string, limit: RateLimit, limitType: RateLimitWindowType ): RateLimitUsage { const now = Date.now(); const windowStart = now - limit.windowMs; // Get request history const requests = this.pruneRequests(identifier, windowStart); const resetTime = requests.length > 0 ? (requests[0] ?? now) + limit.windowMs : now + limit.windowMs; return { count: requests.length, limit: limit.requests, resetTime, windowMs: limit.windowMs, limitType }; } /** * Reset rate limit for a specific user/IP (memory-only) */ async resetRateLimit(user: User | null, context: RateLimitContext = {}): Promise { const identifier = this.buildIdentifier(user, context); // Clear base identifier this.deleteIdentifierState(identifier); // For anonymous and authenticated non-VIP users, also clear hourly and daily identifiers if (!user?.isAuthenticated || await this.isAuthenticatedNonVIP(user)) { this.deleteIdentifierState(`${identifier}:hourly`); this.deleteIdentifierState(`${identifier}:daily`); } rateLimitLogger.info(`Rate limit reset for ${identifier}`); } /** * Get statistics about current rate limiting (memory-only) */ async getStats(): Promise { const config = await this.getRateLimitConfig(); return { totalKeys: this.memoryStore.size, config, type: 'memory' }; } /** * Start periodic cleanup to keep the in-memory store bounded over time. */ private startPeriodicCleanup(): void { if (process.env.NODE_ENV === 'test' || process.env.JEST_WORKER_ID || this.cleanupInterval) { return; } this.cleanupInterval = setInterval(() => { this.cleanupExpiredEntries(); }, RateLimitTool.CLEANUP_INTERVAL_MS); // Cleanup on process exit const sigintHandler = () => this.stopCleanup(); const sigtermHandler = () => this.stopCleanup(); this.cleanupSignalHandlers.SIGINT = sigintHandler; this.cleanupSignalHandlers.SIGTERM = sigtermHandler; process.on('SIGINT', sigintHandler); process.on('SIGTERM', sigtermHandler); } /** * Stop periodic cleanup */ private stopCleanup(): void { if (this.cleanupInterval) { clearInterval(this.cleanupInterval); this.cleanupInterval = null; } if (this.cleanupSignalHandlers.SIGINT) { process.off('SIGINT', this.cleanupSignalHandlers.SIGINT); delete this.cleanupSignalHandlers.SIGINT; } if (this.cleanupSignalHandlers.SIGTERM) { process.off('SIGTERM', this.cleanupSignalHandlers.SIGTERM); delete this.cleanupSignalHandlers.SIGTERM; } } /** * Remove identifiers whose requests have fallen outside the cleanup window. */ cleanupExpiredEntries(): void { const now = Date.now(); let removedEntries = 0; for (const [identifier, requests] of this.memoryStore.entries()) { const configuredWindow = this.identifierWindows.get(identifier) ?? RateLimitTool.CLEANUP_WINDOW_MS; const cleanupWindow = Math.max(configuredWindow, RateLimitTool.CLEANUP_WINDOW_MS); const windowStart = now - cleanupWindow; const validRequests = requests.filter(timestamp => timestamp > windowStart); if (validRequests.length === 0) { this.deleteIdentifierState(identifier); removedEntries += 1; } else if (validRequests.length !== requests.length) { this.memoryStore.set(identifier, validRequests); } } if (removedEntries > 0) { rateLimitLogger.info(`Cleaned up memory store, removed ${removedEntries} stale identifiers`); } } /** * Enforce an upper bound on tracked identifiers using efficient LRU-style eviction. * NOTE: This is a temporary solution - production should use Redis for distributed rate limiting. */ private enforceMemoryLimits(): void { if (this.memoryStore.size <= RateLimitTool.MAX_TRACKED_IDENTIFIERS) { return; } // Log warning about architectural limitation rateLimitLogger.warn('Memory store size limit reached - consider migrating to Redis for production', { currentSize: this.memoryStore.size, maxSize: RateLimitTool.MAX_TRACKED_IDENTIFIERS }); // More efficient approach: remove oldest entries without full array sort const targetSize = Math.floor(RateLimitTool.MAX_TRACKED_IDENTIFIERS * 0.9); const entriesToRemove = this.memoryStore.size - targetSize; if (entriesToRemove <= 0) return; // Simple FIFO removal - remove entries as we encounter them during iteration let removed = 0; const now = Date.now(); const cutoffTime = now - (60 * 60 * 1000); // Remove entries older than 1 hour for (const [key, requests] of this.memoryStore.entries()) { if (removed >= entriesToRemove) break; const oldestRequest = requests[0]; if (!oldestRequest || oldestRequest < cutoffTime) { this.deleteIdentifierState(key); removed++; } } if (removed > 0) { rateLimitLogger.info(`Memory cleanup: Removed ${removed} old rate limit entries`); } } private deleteIdentifierState(identifier: string): void { this.memoryStore.delete(identifier); this.lockMap.delete(identifier); this.identifierWindows.delete(identifier); } private pruneRequests(identifier: string, windowStart: number): number[] { const existing = this.memoryStore.get(identifier); if (!existing) { const initial: number[] = []; this.memoryStore.set(identifier, initial); return initial; } const filtered = existing.filter(timestamp => timestamp > windowStart); if (filtered.length === existing.length) { return existing; } this.memoryStore.set(identifier, filtered); return filtered; } /** * Health check */ async healthCheck(): Promise<{ status: string; details: any }> { return { status: 'healthy', details: { type: 'memory', activeIdentifiers: this.memoryStore.size, timestamp: new Date().toISOString() } }; } }