/** * Security Middleware & Utilities for GammaDesk * * Provides: * 1. Input sanitization (XSS prevention) * 2. Rate limiting (general + auth-specific) * 3. Security headers (CSP, HSTS, etc.) * 4. CORS configuration * 5. Request ID tracking * 6. Password/email validation */ import type { Request, Response, NextFunction } from "express"; /* ────────────────────────────────────────────────────────────── * 1. INPUT SANITIZATION (XSS Prevention) * ────────────────────────────────────────────────────────────── */ /** Sanitize a string by removing HTML tags and encoding special chars */ export function sanitize(input: string): string { if (typeof input !== "string") return ""; return input .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """) .replace(/'/g, "'"); } /** Sanitize all string values in a body object */ export function sanitizeBody(body: Record): Record { const sanitized: Record = {}; for (const [key, value] of Object.entries(body)) { if (typeof value === "string") { sanitized[key] = sanitize(value); } else { sanitized[key] = value; } } return sanitized; } /* ────────────────────────────────────────────────────────────── * 2. RATE LIMITING * ────────────────────────────────────────────────────────────── */ interface RateLimitEntry { count: number; resetTime: number; } export function rateLimit( windowMs: number, maxRequests: number, message: string = "Too many requests. Please try again later.", ) { const store = new Map(); // Cleanup expired entries every 60 seconds const cleanupInterval = setInterval(() => { const now = Date.now(); store.forEach((entry, key) => { if (now >= entry.resetTime) store.delete(key); }); }, 60_000); // Allow process to exit if (cleanupInterval.unref) cleanupInterval.unref(); return (req: Request, res: Response, next: NextFunction) => { const ip = req.ip || req.socket.remoteAddress || "unknown"; const now = Date.now(); const entry = store.get(ip); if (!entry || now >= entry.resetTime) { store.set(ip, { count: 1, resetTime: now + windowMs }); res.set("X-RateLimit-Limit", String(maxRequests)); res.set("X-RateLimit-Remaining", String(maxRequests - 1)); res.set("X-RateLimit-Reset", String(Math.ceil((now + windowMs) / 1000))); return next(); } entry.count++; res.set("X-RateLimit-Limit", String(maxRequests)); res.set("X-RateLimit-Remaining", String(Math.max(0, maxRequests - entry.count))); res.set("X-RateLimit-Reset", String(Math.ceil(entry.resetTime / 1000))); if (entry.count > maxRequests) { res.set("Retry-After", String(Math.ceil((entry.resetTime - now) / 1000))); return res.status(429).json({ error: message, retryAfter: Math.ceil((entry.resetTime - now) / 1000), }); } next(); }; } /** General rate limit: 100 requests per 15 minutes per IP */ export const generalRateLimit = rateLimit(15 * 60 * 1000, 100); /** Auth rate limit: 10 requests per 15 minutes per IP */ export const authRateLimit = rateLimit( 15 * 60 * 1000, 10, "Too many authentication attempts. Please try again later.", ); /* ────────────────────────────────────────────────────────────── * 3. SECURITY HEADERS * ────────────────────────────────────────────────────────────── */ export function securityHeaders( _req: Request, res: Response, next: NextFunction, ) { // Prevent MIME sniffing res.set("X-Content-Type-Options", "nosniff"); // Clickjacking protection res.set("X-Frame-Options", "DENY"); // XSS filter (legacy browsers) res.set("X-XSS-Protection", "1; mode=block"); // Referrer policy res.set("Referrer-Policy", "strict-origin-when-cross-origin"); // Permissions policy res.set( "Permissions-Policy", "camera=(), microphone=(), geolocation=(), payment=()", ); // Content Security Policy res.set( "Content-Security-Policy", [ "default-src 'self'", "script-src 'self'", "style-src 'self' 'unsafe-inline'", // Required for Tailwind JIT "img-src 'self' data: blob:", "font-src 'self'", "connect-src 'self'", "frame-ancestors 'none'", "base-uri 'self'", "form-action 'self'", ].join("; "), ); // HSTS (production only) if (process.env.NODE_ENV === "production") { res.set( "Strict-Transport-Security", "max-age=63072000; includeSubDomains; preload", ); } next(); } /* ────────────────────────────────────────────────────────────── * 4. CORS * ────────────────────────────────────────────────────────────── */ export function corsMiddleware( _req: Request, res: Response, next: NextFunction, ) { const origin = _req.headers.origin as string; const allowedOrigins = (process.env.CORS_ORIGINS || "") .split(",") .map((s) => s.trim()) .filter(Boolean); if (origin && allowedOrigins.includes(origin)) { res.set("Access-Control-Allow-Origin", origin); } res.set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS"); res.set("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Request-ID"); res.set("Access-Control-Allow-Credentials", "true"); res.set("Access-Control-Max-Age", "86400"); // Handle preflight if (_req.method === "OPTIONS") { return res.status(204).end(); } next(); } /* ────────────────────────────────────────────────────────────── * 5. REQUEST ID * ────────────────────────────────────────────────────────────── */ export function requestIdMiddleware( _req: Request, res: Response, next: NextFunction, ) { const id = (_req.headers["x-request-id"] as string) || `req_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`; res.set("X-Request-ID", id); next(); } /* ────────────────────────────────────────────────────────────── * 6. BODY SIZE LIMIT * ────────────────────────────────────────────────────────────── */ export function bodyLimit(maxBytes: number = 1_048_576) { // 1MB default return (_req: Request, res: Response, next: NextFunction) => { const cl = parseInt(_req.headers["content-length"] || "0", 10); if (cl > maxBytes) { return res.status(413).json({ error: "Request body too large" }); } next(); }; } /* ────────────────────────────────────────────────────────────── * 7. VALIDATION HELPERS * ────────────────────────────────────────────────────────────── */ /** Validate email format (RFC 5322 simplified) */ export function isValidEmail(email: string): boolean { return /^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/.test( email, ); } /** Validate password strength */ export function validatePassword(password: string): { valid: boolean; errors: string[] } { const errors: string[] = []; if (password.length < 8) errors.push("Password must be at least 8 characters"); if (password.length > 128) errors.push("Password must be at most 128 characters"); if (!/[A-Z]/.test(password)) errors.push("Password must contain an uppercase letter"); if (!/[a-z]/.test(password)) errors.push("Password must contain a lowercase letter"); if (!/[0-9]/.test(password)) errors.push("Password must contain a number"); if (!/[!@#$%^&*()_+\-=[\]{};:'",.<>?\/\\|`~]/.test(password)) errors.push("Password must contain a special character"); return { valid: errors.length === 0, errors }; }