312 lines
9.0 KiB
TypeScript
Executable File
312 lines
9.0 KiB
TypeScript
Executable File
/**
|
|
* 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;
|