/** * Content Security Policy utilities for Electron app */ export interface CSPConfig { defaultSrc: readonly string[]; scriptSrc: readonly string[]; styleSrc: readonly string[]; imgSrc: readonly string[]; fontSrc: readonly string[]; connectSrc: readonly string[]; frameSrc: readonly string[]; objectSrc: readonly string[]; baseUri: readonly string[]; formAction: readonly string[]; frameAncestors: readonly string[]; } type CSPDirective = keyof CSPConfig; const NONCE_PLACEHOLDER = "'nonce-{NONCE}'" as const; const NONCE_SIZE = 16; export class CSPManager { private static nonce: string | null = null; private static readonly DEVELOPMENT_CONFIG: CSPConfig = { defaultSrc: ["'self'"], scriptSrc: [ "'self'", "'unsafe-inline'", // Required for Vite in dev mode "'unsafe-eval'", // Required for React DevTools "http://localhost:*" ], styleSrc: [ "'self'", "'unsafe-inline'", // Required for styled-components/emotion "http://localhost:*" ], imgSrc: [ "'self'", "data:", "blob:", "http://localhost:*" ], fontSrc: [ "'self'", "data:", "http://localhost:*" ], connectSrc: [ "'self'", "http://localhost:*", "https://localhost:*", "ws://localhost:*", "wss://localhost:*" ], frameSrc: [ "'self'", "http://localhost:8080", // Guacamole in development "https://localhost:8080" ], objectSrc: ["'none'"], baseUri: ["'self'"], formAction: ["'self'"], frameAncestors: ["'none'"] }; private static readonly PRODUCTION_CONFIG: CSPConfig = { defaultSrc: ["'self'"], scriptSrc: [ "'self'", "'nonce-{NONCE}'" // Nonce for inline scripts ], styleSrc: [ "'self'", "'nonce-{NONCE}'", // Nonce for inline styles "https://fonts.googleapis.com" ], imgSrc: [ "'self'", "data:", "blob:" ], fontSrc: [ "'self'", "data:", "https://fonts.gstatic.com" ], connectSrc: [ "'self'", `https://${import.meta.env.VITE_PROD_DOMAIN || 'mc.exbytestudios.com'}`, `wss://${import.meta.env.VITE_PROD_DOMAIN || 'mc.exbytestudios.com'}` ], frameSrc: [ "'self'", `https://${import.meta.env.VITE_PROD_DOMAIN || 'mc.exbytestudios.com'}` ], objectSrc: ["'none'"], baseUri: ["'self'"], formAction: ["'self'"], frameAncestors: ["'none'"] }; /** * Generate random nonce */ static generateNonce(): string { const array = new Uint8Array(NONCE_SIZE); crypto.getRandomValues(array); return Array.from(array, (byte) => byte.toString(16).padStart(2, '0')).join(''); } /** * Set nonce for use in CSP */ static setNonce(nonce: string): void { this.nonce = nonce; } /** * Get current nonce */ static getNonce(): string | null { return this.nonce; } /** * Get CSP configuration for current environment * Dynamically adds API URL from env variables */ static getConfig(): CSPConfig { const baseConfig = this.getBaseConfig(); const apiUrl = import.meta.env.VITE_API_URL; if (!apiUrl) { return baseConfig; } return this.addApiUrlToConfig(baseConfig, apiUrl); } private static getBaseConfig(): CSPConfig { return process.env.NODE_ENV === 'development' ? this.DEVELOPMENT_CONFIG : this.PRODUCTION_CONFIG; } private static addApiUrlToConfig(config: CSPConfig, apiUrl: string): CSPConfig { try { const url = new URL(apiUrl); const { domain, wssDomain } = this.buildDomains(url); const updatedConfig: CSPConfig = { ...config }; if (!config.connectSrc.includes(domain)) { updatedConfig.connectSrc = [...config.connectSrc, domain, wssDomain]; } if (!config.frameSrc.includes(domain)) { updatedConfig.frameSrc = [...config.frameSrc, domain]; } console.log('CSP updated with API domain:', domain); return updatedConfig; } catch (error) { console.error('Failed to parse VITE_API_URL for CSP:', error); return config; } } private static buildDomains(url: URL): { domain: string; wssDomain: string } { const protocol = url.protocol.replace(':', ''); const portSuffix = url.port ? `:${url.port}` : ''; const domain = `${protocol}://${url.hostname}${portSuffix}`; const wssProtocol = protocol === 'https' ? 'wss' : 'ws'; const wssDomain = `${wssProtocol}://${url.hostname}${portSuffix}`; return { domain, wssDomain }; } /** * Generate CSP string from configuration */ static generateCSPString(config: CSPConfig = this.getConfig()): string { const directives: string[] = [ this.buildDirective('default-src', config.defaultSrc), this.buildDirective('script-src', config.scriptSrc), this.buildDirective('style-src', config.styleSrc), this.buildDirective('img-src', config.imgSrc), this.buildDirective('font-src', config.fontSrc), this.buildDirective('connect-src', config.connectSrc), this.buildDirective('frame-src', config.frameSrc), this.buildDirective('object-src', config.objectSrc), this.buildDirective('base-uri', config.baseUri), this.buildDirective('form-action', config.formAction), this.buildDirective('frame-ancestors', config.frameAncestors), ]; return directives.join(' ').trim(); } private static buildDirective(name: string, sources: readonly string[]): string { const processed = this.processSources([...sources]); return `${name} ${processed.join(' ')};`; } private static processSources(sources: string[]): string[] { return sources.map((source) => { if (source === NONCE_PLACEHOLDER && this.nonce) { return `'nonce-${this.nonce}'`; } return source; }); } /** * Apply CSP to document */ static applyCSP(config: CSPConfig = this.getConfig()): void { if (typeof document === 'undefined') return; // Generate nonce for production if (process.env.NODE_ENV === 'production' && !this.nonce) { this.nonce = this.generateNonce(); } // Remove existing CSP meta tag const existingCSP = document.querySelector('meta[http-equiv="Content-Security-Policy"]'); if (existingCSP) { existingCSP.remove(); } // Create new CSP meta tag const meta = document.createElement('meta'); meta.setAttribute('http-equiv', 'Content-Security-Policy'); meta.setAttribute('content', this.generateCSPString(config)); document.head.appendChild(meta); // Add nonce to meta tag for JavaScript access if (this.nonce) { const nonceMeta = document.createElement('meta'); nonceMeta.setAttribute('name', 'csp-nonce'); nonceMeta.setAttribute('content', this.nonce); document.head.appendChild(nonceMeta); } } /** * Setup CSP violation reporting */ static setupCSPReporting(): void { if (typeof document === 'undefined') return; document.addEventListener('securitypolicyviolation', (event) => { console.warn('CSP Violation:', { directive: event.violatedDirective, blockedURI: event.blockedURI, lineNumber: event.lineNumber, columnNumber: event.columnNumber, sourceFile: event.sourceFile, sample: event.sample }); // In production, send to server if (process.env.NODE_ENV === 'production') { // fetch('/api/csp-violation', { // method: 'POST', // headers: { 'Content-Type': 'application/json' }, // body: JSON.stringify({ // directive: event.violatedDirective, // blockedURI: event.blockedURI, // sourceFile: event.sourceFile, // timestamp: new Date().toISOString() // }) // }); } }); } /** * Validate CSP configuration */ static validateConfig(config: CSPConfig): string[] { const errors: string[] = []; // Check for dangerous directives if (config.scriptSrc.includes("'unsafe-inline'")) { errors.push("script-src contains 'unsafe-inline' - security risk"); } if (config.scriptSrc.includes("'unsafe-eval'")) { errors.push("script-src contains 'unsafe-eval' - security risk"); } if (config.objectSrc.includes("'self'") || config.objectSrc.length === 0) { errors.push("object-src should be 'none' for security"); } if (config.frameAncestors.includes("'self'")) { errors.push("frame-ancestors should be 'none' for clickjacking protection"); } return errors; } } // Export convenience functions export const generateCSP = CSPManager.generateCSPString; export const applyCSP = CSPManager.applyCSP; export const setupCSPReporting = CSPManager.setupCSPReporting;