import {Request, Response} from '@loopback/rest'; import {DEFAULT_CORS_CONFIG} from '../constants/security-defaults'; import {CorsConfig} from '../types'; import {SecurityError} from '../utils/security-error'; import {SecurityLogger} from '../utils/security-logger'; /** * CORS handler that manages Cross-Origin Resource Sharing policies */ export class CorsHandler { constructor( private config?: CorsConfig, private logger?: SecurityLogger ) { // Set default configuration with custom overrides this.config = { ...DEFAULT_CORS_CONFIG, // Custom overrides for this handler origin: [ "*.flavorcloud.com", "http://localhost:*", "https://localhost:*", "http://127.0.0.1:*", "https://127.0.0.1:*", // Add common development domains "http://localhost:3000", "http://localhost:3005", "http://localhost:4200", "http://localhost:8080", "http://localhost:9000" ], allowedHeaders: [ 'Content-Type', 'Authorization', 'X-Requested-With', 'Accept', 'Accept-Language', 'Accept-Encoding', 'Cache-Control', 'Connection', 'Host', 'Origin', 'Referer', 'User-Agent', 'Root-Request-Id', 'X-CSRF-Token', 'X-Forwarded-For', 'X-Real-IP', 'X-API-Key' ], ...config }; this.logger?.logSecurityEvent({}, { timestamp: new Date().toISOString(), eventType: 'SECURITY_CHECK_PASSED', severity: 'LOW', metadata: { component: 'CorsHandler', config: { origin: typeof this.config.origin === 'boolean' ? this.config.origin : 'array/string', methods: this.config.methods, allowedHeaders: this.config.allowedHeaders, credentials: this.config.credentials } } }); } /** * Handle CORS for the request */ async handleCors(req: Request, res: Response): Promise { const origin = req.headers.origin as string; const requestMethod = (req.headers['access-control-request-method'] as string) ?? req.method; const requestHeaders = req.headers['access-control-request-headers'] as string; // Check if origin is allowed if (!this.isOriginAllowed(origin)) { this.logger?.logSecurityViolation(req, 'CORS origin not allowed', { origin, configOrigin: this.config?.origin }); throw SecurityError.corsViolation(origin, 'Origin not allowed by CORS policy'); } // Set CORS headers this.setCorsHeaders(res, origin, requestMethod, requestHeaders); // Handle preflight requests if (req.method === 'OPTIONS') { await this.handlePreflightRequest(req, res, requestMethod, requestHeaders); } this.logger?.logSecurityEvent(req, { timestamp: new Date().toISOString(), eventType: 'SECURITY_CHECK_PASSED', severity: 'LOW', metadata: { component: 'CorsHandler', origin, method: req.method, isPreflight: req.method === 'OPTIONS' } }); } /** * Check if origin is allowed */ private isOriginAllowed(origin: string | undefined): boolean { if (!this.config?.origin) { return false; // No origins allowed } if (this.config.origin === true) { return true; // All origins allowed } if (!origin) { // Allow requests with no origin (like from mobile apps, Postman, swagger explorer, etc.) return true; } if (typeof this.config.origin === 'string') { return this.matchOrigin(origin, this.config.origin); } if (Array.isArray(this.config.origin)) { return this.config.origin.some(allowedOrigin => this.matchOrigin(origin, allowedOrigin)); } return false; } /** * Match origin against allowed origin pattern */ private matchOrigin(origin: string, allowedOrigin: string): boolean { // Exact match or wildcard if (allowedOrigin === '*' || allowedOrigin === origin) { return true; } // Handle wildcard domains like *.flavorcloud.com if (allowedOrigin.startsWith('*.')) { const domain = allowedOrigin.substring(2); return origin.endsWith('.' + domain) || origin === domain; } // Handle localhost with port wildcards like http://localhost:* if (allowedOrigin.includes(':*')) { const baseUrl = allowedOrigin.replace(':*', ''); return origin.startsWith(baseUrl + ':') || origin === baseUrl; } // Handle protocol wildcards if (allowedOrigin.startsWith('*.') && origin.includes('://')) { const originDomain = origin.split('://')[1]; const allowedDomain = allowedOrigin.substring(2); return originDomain === allowedDomain || originDomain.endsWith('.' + allowedDomain); } return false; } /** * Set CORS headers on response */ private setCorsHeaders( res: Response, origin: string | undefined, requestMethod: string, requestHeaders: string | undefined ): void { // Set Access-Control-Allow-Origin if (this.config?.origin === true) { res.setHeader('Access-Control-Allow-Origin', '*'); } else if (origin && this.isOriginAllowed(origin)) { res.setHeader('Access-Control-Allow-Origin', origin); } // Set Access-Control-Allow-Credentials if (this.config?.credentials) { res.setHeader('Access-Control-Allow-Credentials', 'true'); } // Set Access-Control-Allow-Methods if (this.config?.methods && this.config.methods.length > 0) { res.setHeader('Access-Control-Allow-Methods', this.config.methods.join(',')); } // Set Access-Control-Allow-Headers if (this.config?.allowedHeaders && this.config.allowedHeaders.length > 0) { res.setHeader('Access-Control-Allow-Headers', this.config.allowedHeaders.join(',')); } else if (requestHeaders) { res.setHeader('Access-Control-Allow-Headers', requestHeaders); } // Set Access-Control-Max-Age for preflight caching if (this.config?.maxAge) { res.setHeader('Access-Control-Max-Age', this.config.maxAge.toString()); } // Set Vary header to prevent caching issues const varyHeaders: string[] = []; if (this.config?.origin !== true && this.config?.origin !== false) { varyHeaders.push('Origin'); } if (requestHeaders) { varyHeaders.push('Access-Control-Request-Headers'); } if (varyHeaders.length > 0) { res.setHeader('Vary', varyHeaders.join(', ')); } } /** * Handle preflight OPTIONS request */ private async handlePreflightRequest( req: Request, res: Response, requestMethod: string, requestHeaders: string | undefined ): Promise { // Validate requested method if (this.config?.methods && !this.config.methods.includes(requestMethod)) { this.logger?.logSecurityViolation(req, 'CORS method not allowed', { requestMethod, allowedMethods: this.config.methods }); throw SecurityError.corsViolation( req.headers.origin as string, `Method ${requestMethod} not allowed by CORS policy`, {requestMethod, allowedMethods: this.config.methods} ); } // Validate requested headers if (requestHeaders) { const requestedHeaders = requestHeaders.toLowerCase().split(',').map(h => h.trim()); const allowedHeaders = this.config?.allowedHeaders?.map(h => h.toLowerCase()) ?? []; const unauthorizedHeaders = requestedHeaders.filter(h => !allowedHeaders.includes(h)); if (unauthorizedHeaders.length > 0) { this.logger?.logSecurityViolation(req, 'CORS headers not allowed', { requestedHeaders: unauthorizedHeaders, allowedHeaders: this.config?.allowedHeaders }); throw SecurityError.corsViolation( req.headers.origin as string, `Headers ${unauthorizedHeaders.join(', ')} not allowed by CORS policy`, {unauthorizedHeaders, allowedHeaders: this.config?.allowedHeaders} ); } } // Set status code for preflight response const statusCode = this.config?.optionsSuccessStatus ?? 204; res.status(statusCode); // Continue to next middleware if configured if (this.config?.preflightContinue) { return; } // End preflight response res.end(); } /** * Get CORS configuration for debugging */ getConfig(): CorsConfig | undefined { return this.config; } /** * Check if a specific origin is allowed (for testing) */ checkOrigin(origin: string): boolean { return this.isOriginAllowed(origin); } /** * Add allowed origin */ addAllowedOrigin(origin: string): void { if (!this.config) { this.config = {}; } if (typeof this.config.origin === 'string') { this.config.origin = [this.config.origin, origin]; } else if (Array.isArray(this.config.origin)) { if (!this.config.origin.includes(origin)) { this.config.origin.push(origin); } } else if (this.config.origin === false) { this.config.origin = [origin]; } this.logger?.logSecurityEvent({}, { timestamp: new Date().toISOString(), eventType: 'SECURITY_CHECK_PASSED', severity: 'LOW', metadata: { component: 'CorsHandler', action: 'ORIGIN_ADDED', origin } }); } /** * Remove allowed origin */ removeAllowedOrigin(origin: string): void { if (!this.config || !Array.isArray(this.config.origin)) { return; } const index = this.config.origin.indexOf(origin); if (index > -1) { this.config.origin.splice(index, 1); this.logger?.logSecurityEvent({}, { timestamp: new Date().toISOString(), eventType: 'SECURITY_CHECK_PASSED', severity: 'LOW', metadata: { component: 'CorsHandler', action: 'ORIGIN_REMOVED', origin } }); } } /** * Add allowed method */ addAllowedMethod(method: string): void { if (!this.config) { this.config = {}; } if (!this.config.methods) { this.config.methods = []; } const upperMethod = method.toUpperCase(); if (!this.config.methods.includes(upperMethod)) { this.config.methods.push(upperMethod); this.logger?.logSecurityEvent({}, { timestamp: new Date().toISOString(), eventType: 'SECURITY_CHECK_PASSED', severity: 'LOW', metadata: { component: 'CorsHandler', action: 'METHOD_ADDED', method: upperMethod } }); } } /** * Add allowed header */ addAllowedHeader(header: string): void { if (!this.config) { this.config = {}; } if (!this.config.allowedHeaders) { this.config.allowedHeaders = []; } if (!this.config.allowedHeaders.includes(header)) { this.config.allowedHeaders.push(header); this.logger?.logSecurityEvent({}, { timestamp: new Date().toISOString(), eventType: 'SECURITY_CHECK_PASSED', severity: 'LOW', metadata: { component: 'CorsHandler', action: 'HEADER_ADDED', header } }); } } /** * Update CORS configuration */ updateConfig(config: Partial): void { this.config = {...this.config, ...config}; this.logger?.logSecurityEvent({}, { timestamp: new Date().toISOString(), eventType: 'SECURITY_CHECK_PASSED', severity: 'LOW', metadata: { component: 'CorsHandler', action: 'CONFIG_UPDATED', newConfig: config } }); } /** * Get CORS statistics */ getStats(): { allowedOrigins: number; allowedMethods: number; allowedHeaders: number; credentialsEnabled: boolean; preflightMaxAge: number; } { const origins = Array.isArray(this.config?.origin) ? (this.config?.origin as string[]).length : this.config?.origin === true ? -1 // All origins : this.config?.origin === false ? 0 : 1; return { allowedOrigins: origins, allowedMethods: this.config?.methods?.length ?? 0, allowedHeaders: this.config?.allowedHeaders?.length ?? 0, credentialsEnabled: this.config?.credentials ?? false, preflightMaxAge: this.config?.maxAge ?? 0 }; } }