/** * Security Configuration * * Implements OWASP Top 10 security best practices */ import { HelmetOptions } from 'helmet'; export const helmetConfig: HelmetOptions = { // Content Security Policy contentSecurityPolicy: { directives: { defaultSrc: ["'self'"], styleSrc: ["'self'", "'unsafe-inline'"], // Required for inline styles in some frameworks scriptSrc: ["'self'"], imgSrc: ["'self'", 'data:', 'https:'], connectSrc: ["'self'"], fontSrc: ["'self'"], objectSrc: ["'none'"], mediaSrc: ["'self'"], frameSrc: ["'none'"], }, }, // Cross-Origin Embedder Policy crossOriginEmbedderPolicy: false, // Set to true in production if needed // Cross-Origin Opener Policy crossOriginOpenerPolicy: { policy: 'same-origin' }, // Cross-Origin Resource Policy crossOriginResourcePolicy: { policy: 'same-origin' }, // DNS Prefetch Control dnsPrefetchControl: { allow: false }, // Frameguard frameguard: { action: 'deny' }, // Hide Powered-By hidePoweredBy: true, // HSTS (HTTP Strict Transport Security) hsts: { maxAge: 31536000, // 1 year includeSubDomains: true, preload: true, }, // IE No Open ieNoOpen: true, // No Sniff noSniff: true, // Origin Agent Cluster originAgentCluster: true, // Permitted Cross-Domain Policies permittedCrossDomainPolicies: { permittedPolicies: 'none' }, // Referrer Policy referrerPolicy: { policy: 'no-referrer' }, // XSS Filter xssFilter: true, }; /** * Rate Limiting Configuration */ export const rateLimitConfig = { // Global rate limit global: { ttl: 60, // 60 seconds limit: 100, // 100 requests per minute }, // Auth endpoints (more strict) auth: { ttl: 60, limit: 5, // 5 login attempts per minute }, // Search endpoints search: { ttl: 60, limit: 30, // 30 searches per minute }, // Booking endpoints booking: { ttl: 60, limit: 20, // 20 bookings per minute }, }; /** * Allowed CORS origins. * * Reads CORS_ORIGIN (comma-separated list — supports several frontend hosts such * as app.* and www.*), falling back to FRONTEND_URL, then to localhost in dev. * With `credentials: true` the browser requires an exact origin echo (no `*`), * so we resolve the request origin against this allow-list per request. */ const allowedOrigins: string[] = ( process.env.CORS_ORIGIN || process.env.FRONTEND_URL || 'http://localhost:3000,http://localhost:3001' ) .split(',') .map(origin => origin.trim()) .filter(Boolean); /** * CORS Configuration */ export const corsConfig = { origin: ( requestOrigin: string | undefined, callback: (err: Error | null, allow?: boolean) => void ): void => { // Allow non-browser clients (no Origin header: server-to-server, curl, health checks). if (!requestOrigin || allowedOrigins.includes(requestOrigin)) { callback(null, true); return; } callback(null, false); }, credentials: true, methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'], allowedHeaders: ['Content-Type', 'Authorization', 'X-Requested-With', 'X-CSRF-Token'], exposedHeaders: ['X-Total-Count', 'X-Page-Count'], maxAge: 86400, // 24 hours }; /** * Password Policy */ export const passwordPolicy = { minLength: 12, requireUppercase: true, requireLowercase: true, requireNumbers: true, requireSymbols: true, maxLength: 128, preventCommon: true, // Prevent common passwords preventReuse: 5, // Last 5 passwords }; /** * File Upload Configuration */ export const fileUploadConfig = { maxFileSize: 10 * 1024 * 1024, // 10MB allowedMimeTypes: [ 'application/pdf', 'image/jpeg', 'image/png', 'image/webp', 'application/vnd.ms-excel', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', 'text/csv', ], allowedExtensions: ['.pdf', '.jpg', '.jpeg', '.png', '.webp', '.xls', '.xlsx', '.csv'], scanForViruses: process.env.NODE_ENV === 'production', }; /** * Auth Cookie Configuration * * Tokens are delivered as httpOnly cookies so they are not readable from * JavaScript (XSS mitigation). COOKIE_DOMAIN must be set in production when * the API and the frontend live on different subdomains (e.g. ".xpeditis.com"). */ export const AUTH_COOKIE_NAMES = { accessToken: 'accessToken', refreshToken: 'refreshToken', /** Non-httpOnly flag the frontend reads to know a session exists (contains no token) */ session: 'xpeditis_session', } as const; export function authCookieOptions(options?: { maxAgeMs?: number; httpOnly?: boolean }): { httpOnly: boolean; secure: boolean; sameSite: 'lax' | 'strict' | 'none'; path: string; domain?: string; maxAge?: number; } { // SameSite must be 'none' when the frontend and the API live on different // sites (cross-origin), otherwise the browser drops the auth cookies set in // the cross-site login XHR response. 'none' REQUIRES Secure (HTTPS). // Configurable via COOKIE_SAMESITE; defaults to 'lax' for same-site setups. const sameSite = (process.env.COOKIE_SAMESITE?.toLowerCase() as 'lax' | 'strict' | 'none') || 'lax'; const secure = process.env.NODE_ENV === 'production' || sameSite === 'none'; return { httpOnly: options?.httpOnly ?? true, secure, sameSite, path: '/', ...(process.env.COOKIE_DOMAIN ? { domain: process.env.COOKIE_DOMAIN } : {}), ...(options?.maxAgeMs ? { maxAge: options.maxAgeMs } : {}), }; } /** * Brute Force Protection */ export const bruteForceConfig = { freeRetries: 3, minWait: 5 * 60 * 1000, // 5 minutes maxWait: 60 * 60 * 1000, // 1 hour lifetime: 24 * 60 * 60, // 24 hours };