// Security middleware for GammaDesk import type { Request, Response, NextFunction } from "express"; // Rate limiting store interface RateLimitEntry { count: number; resetTime: number; } const rateLimitStore: Map = new Map(); // Whitelist for local development (home subnet) const WHITELISTED_SUBNETS = [ "192.168.", // Home network "127.", // Localhost "10.", // Private network "fc00:", // IPv6 local "::1", // IPv6 localhost ]; function isWhitelisted(ip: string): boolean { return WHITELISTED_SUBNETS.some(prefix => ip.startsWith(prefix)); } // Cleanup old rate limit entries every 60 seconds const cleanupInterval = setInterval(() => { const now = Date.now(); const expiredKeys: string[] = []; rateLimitStore.forEach((entry: RateLimitEntry, key: string) => { if (entry.resetTime < now) { expiredKeys.push(key); } }); expiredKeys.forEach((key: string) => rateLimitStore.delete(key)); }, 60000); if (cleanupInterval.unref) cleanupInterval.unref(); export function rateLimit({ windowMs = 900000, // 15 minutes max = 500, // Relaxed from 100 for development }: { windowMs?: number; max?: number } = {}) { return (req: Request, res: Response, next: NextFunction) => { const ip = req.ip || req.socket.remoteAddress || "unknown"; // Skip rate limiting for whitelisted IPs (local development) if (isWhitelisted(ip)) { return next(); } const now = Date.now(); let entry = rateLimitStore.get(ip); if (!entry || now > entry.resetTime) { entry = { count: 1, resetTime: now + windowMs }; rateLimitStore.set(ip, entry); } else { entry.count++; } res.set("X-RateLimit-Limit", String(max)); res.set("X-RateLimit-Remaining", String(Math.max(0, max - entry.count))); res.set("X-RateLimit-Reset", String(Math.ceil(entry.resetTime / 1000))); if (entry.count > max) { return res.status(429).json({ error: "Too many requests. Please try again later.", retryAfter: Math.ceil((entry.resetTime - now) / 1000), }); } next(); }; } export function securityHeaders(_req: Request, res: Response, next: NextFunction) { res.set("X-Content-Type-Options", "nosniff"); res.set("X-Frame-Options", "DENY"); res.set("X-XSS-Protection", "1; mode=block"); res.set("Referrer-Policy", "strict-origin-when-cross-origin"); res.set("Permissions-Policy", "camera=(), microphone=(), geolocation=()"); // Development CSP: Relaxed for Vite HMR + Google Fonts const devCsp = [ "default-src 'self'", "script-src 'self' 'unsafe-inline' 'unsafe-eval' http: https: data:", "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com", "img-src 'self' data: blob: https:", "font-src 'self' data: https://fonts.gstatic.com", "connect-src 'self' ws: wss: http: https:", "frame-ancestors 'none'", "base-uri 'self'", "form-action 'self'", ].join("; "); // Production CSP: Strict const prodCsp = [ "default-src 'self'", "script-src 'self' 'unsafe-inline'", "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com", "img-src 'self' data: blob:", "font-src 'self' data: https://fonts.gstatic.com", "connect-src 'self'", "frame-ancestors 'none'", "base-uri 'self'", "form-action 'self'", ].join("; "); res.set( "Content-Security-Policy", process.env.NODE_ENV === "production" ? prodCsp : devCsp, ); if (process.env.NODE_ENV === "production") { res.set("Strict-Transport-Security", "max-age=31536000; includeSubDomains"); } next(); } export function sanitizeInput(input: string): string { return input .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """) .replace(/'/g, "'"); }