Files
Remote-Control-Center/mc_test/src/renderer/utils/csp.ts
2025-11-25 09:56:15 +03:00

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;