Implement Content Security Policy (CSP) for Electron app#9
Implement Content Security Policy (CSP) for Electron app#9
Conversation
- Created apps/frontend/src/main/security/csp-config.ts - Defined comprehensive CSP directives with detailed documentation - Exported CSP_CONFIG constant with all security policies - Included CSP_DIRECTIVES object for testing - Added validateCSP() helper function - Documented all allowed sources and security rationale - Covered: Google Fonts, Sentry, GitHub, Supabase resources
…ultSession.webRequest
Added documentation comment explaining that CSP is primarily enforced via Electron's session.defaultSession.webRequest API and the meta tag serves as a defense-in-depth fallback. The meta tag content matches the CSP_CONFIG from apps/frontend/src/main/security/csp-config.ts. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
- Create comprehensive unit tests for CSP configuration and enforcement - Verify CSP_CONFIG contains all required security directives - Test strict script-src directive (no unsafe-inline or unsafe-eval) - Verify session.defaultSession.webRequest.onHeadersReceived registration - Test CSP header injection into response headers - Ensure existing response headers are preserved during injection - All 15 tests pass successfully
Enhanced CSP configuration documentation with: - Comprehensive rationale for why CSP is critical in Electron apps - Detailed enforcement strategy (dual-layer: session API + meta tag) - Step-by-step testing guide for CSP modifications - Common pitfalls and how to avoid them (unsafe-inline, wildcards, etc.) - Modification examples with security checklists - Enhanced function documentation for validateCSP - CSP relationship with other security features (context isolation, sandbox, etc.) All allowed sources are documented with security rationale for each directive. Provides clear guidance on how to modify the policy safely. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Fixes: - CSP implementation broke window-security.test.ts Problem: The new CSP enforcement code requires session.defaultSession.webRequest, but window-security.test.ts mock lacked this property, causing 3 test failures. Solution: Added webRequest mock to session.defaultSession mock (lines 78-89), matching the pattern in csp-enforcement.test.ts. Verified: - All window-security tests pass (3/3) - All CSP enforcement tests pass (15/15) - Full test suite shows CSP-related tests passing - Pre-existing failures in other test files are unrelated to CSP QA Fix Session: 3 Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
|
PR Summary: Add strict Content Security Policy enforcement to the Electron app via the session API, add a central CSP configuration module, provide a meta-tag fallback in the renderer, and introduce unit tests to validate CSP injection and structure. Changes:
Behavioral / security notes:
No dependency updates. |
| // Enforce Content Security Policy via session API | ||
| // This provides defense-in-depth against XSS attacks and code injection | ||
| session.defaultSession.webRequest.onHeadersReceived((details, callback) => { | ||
| const responseHeaders = { ...details.responseHeaders }; | ||
|
|
||
| // Inject CSP header for all responses to ensure comprehensive coverage | ||
| // The CSP_CONFIG defines strict policies for scripts, styles, images, and connections | ||
| responseHeaders['Content-Security-Policy'] = [CSP_CONFIG]; | ||
|
|
||
| callback({ responseHeaders }); | ||
| }); |
There was a problem hiding this comment.
[CRITICAL_BUG] Defensive-check and normalize headers before spreading/overwriting. details.responseHeaders can be undefined (spreading undefined will throw) and header keys may be in any case. Update the handler to: (1) guard against undefined: const responseHeaders = Object.assign({}, details.responseHeaders || {}); (2) detect existing CSP header case-insensitively (e.g. const existingKey = Object.keys(responseHeaders).find(k => k.toLowerCase() === 'content-security-policy');) and avoid silently overwriting — either preserve existing header if present or explicitly replace it after documenting intent; (3) wrap the handler body in try/catch to avoid uncaught exceptions crashing the app. Example change: session.defaultSession.webRequest.onHeadersReceived((details, callback) => { try { const responseHeaders = Object.assign({}, details.responseHeaders || {}); const existingKey = Object.keys(responseHeaders).find(k => k.toLowerCase() === 'content-security-policy'); if (!existingKey) { responseHeaders['Content-Security-Policy'] = [CSP_CONFIG]; } else { responseHeaders[existingKey] = [CSP_CONFIG]; } callback({ responseHeaders }); } catch (e) { console.error('[main] CSP injection error', e); callback({}); } });
// Enforce Content Security Policy via session API
// This provides defense-in-depth against XSS attacks and code injection
session.defaultSession.webRequest.onHeadersReceived((details, callback) => {
try {
const responseHeaders: Record<string, string[]> = {
...(details.responseHeaders || {}),
};
// Find existing CSP header key in a case-insensitive way
const existingKey = Object.keys(responseHeaders).find(
(key) => key.toLowerCase() === 'content-security-policy',
);
if (existingKey) {
responseHeaders[existingKey] = [CSP_CONFIG];
} else {
responseHeaders['Content-Security-Policy'] = [CSP_CONFIG];
}
callback({ responseHeaders, cancel: false });
} catch (error) {
console.error('[main] CSP injection error', error);
callback({ cancel: false });
}
});| // Enforce Content Security Policy via session API | ||
| // This provides defense-in-depth against XSS attacks and code injection | ||
| session.defaultSession.webRequest.onHeadersReceived((details, callback) => { | ||
| const responseHeaders = { ...details.responseHeaders }; | ||
|
|
||
| // Inject CSP header for all responses to ensure comprehensive coverage | ||
| // The CSP_CONFIG defines strict policies for scripts, styles, images, and connections | ||
| responseHeaders['Content-Security-Policy'] = [CSP_CONFIG]; | ||
|
|
||
| callback({ responseHeaders }); | ||
| }); |
There was a problem hiding this comment.
[REFACTORING] Limit header injection to relevant responses to reduce potential side-effects. Injecting CSP for every resource can be unnecessary or break behavior for non-document responses. Use a filter or check details.resourceType / responseHeaders['content-type'] and only inject for main frame HTML responses. Also consider using the first argument filter form: session.defaultSession.webRequest.onHeadersReceived({ urls: [':///*'] }, (details, callback) => { if (details.resourceType !== 'mainFrame' && !(responseContentTypeIncludesHtml)) return callback({}); ... }).
// Enforce Content Security Policy via session API
// This provides defense-in-depth against XSS attacks and code injection
session.defaultSession.webRequest.onHeadersReceived(
{ urls: ['*://*/*'] },
(details, callback) => {
try {
const responseHeaders: Record<string, string[]> = {
...(details.responseHeaders || {}),
};
// Only inject CSP for main frame HTML responses
const contentTypeHeaderKey = Object.keys(responseHeaders).find(
(key) => key.toLowerCase() === 'content-type',
);
const contentType = contentTypeHeaderKey
? responseHeaders[contentTypeHeaderKey][0] || ''
: '';
const isHtmlResponse = contentType.includes('text/html');
const isMainFrame = (details as any).resourceType === 'mainFrame';
if (!isHtmlResponse || !isMainFrame) {
return callback({ cancel: false });
}
const existingCspKey = Object.keys(responseHeaders).find(
(key) => key.toLowerCase() === 'content-security-policy',
);
if (existingCspKey) {
responseHeaders[existingCspKey] = [CSP_CONFIG];
} else {
responseHeaders['Content-Security-Policy'] = [CSP_CONFIG];
}
callback({ responseHeaders, cancel: false });
} catch (error) {
console.error('[main] CSP injection error', error);
callback({ cancel: false });
}
},
);| export const CSP_CONFIG = [ | ||
| `default-src ${DEFAULT_SRC}`, | ||
| `script-src ${SCRIPT_SRC}`, | ||
| `style-src ${STYLE_SRC}`, | ||
| `img-src ${IMG_SRC}`, | ||
| `connect-src ${CONNECT_SRC}`, | ||
| `font-src ${FONT_SRC}`, | ||
| `object-src ${OBJECT_SRC}`, | ||
| `base-uri ${BASE_URI}`, | ||
| `form-action ${FORM_ACTION}`, | ||
| `frame-ancestors ${FRAME_ANCESTORS}` | ||
| ].join('; '); |
There was a problem hiding this comment.
[VALIDATION] validateCSP uses simple string.includes which can produce false positives (matching substrings). Use a directive-aware check (regex with word boundaries) and verify critical directives don't include dangerously permissive values (e.g. 'unsafe-inline' in script-src or 'unsafe-eval'). Example improvement: const hasDirective = (s, d) => new RegExp('(^|;)\s*' + d.replace(/[-/^$*+?.()|[]{}]/g,'\$&') + '(\s|;|$)').test(s); and assert script-src does not contain 'unsafe-inline' or 'unsafe-eval' (and fail tests/warn if present).
export function validateCSP(cspString: string): boolean {
const requiredDirectives = Object.keys(CSP_DIRECTIVES);
const hasDirective = (source: string, directive: string): boolean => {
const escaped = directive.replace(/[\-\/\\^$*+?.()|[\]{}]/g, "\\$&");
const pattern = new RegExp(`(^|;)\\s*${escaped}(\\s|;|$)`);
return pattern.test(source);
};
const hasAllDirectives = requiredDirectives.every((directive) =>
hasDirective(cspString, directive),
);
if (!hasAllDirectives) return false;
// Additional safety checks for critical directives
const scriptSrcMatch = cspString.match(/script-src([^;]*)/);
if (scriptSrcMatch) {
const scriptValue = scriptSrcMatch[1];
if (scriptValue.includes("'unsafe-inline'") || scriptValue.includes("'unsafe-eval'")) {
return false;
}
}
return true;
}| <!-- | ||
| Content Security Policy (CSP) - Defense-in-Depth Fallback | ||
|
|
||
| PRIMARY ENFORCEMENT: CSP is enforced via Electron's session.defaultSession.webRequest API | ||
| (see apps/frontend/src/main/index.ts and apps/frontend/src/main/security/csp-config.ts) | ||
|
|
||
| This meta tag serves as a fallback mechanism and must be kept in sync with the | ||
| session API configuration. Both use identical CSP directives to ensure consistent | ||
| protection against XSS attacks and code injection. | ||
|
|
||
| IMPORTANT: Changes to CSP must be made in apps/frontend/src/main/security/csp-config.ts | ||
| and synchronized here. | ||
| --> | ||
| <meta http-equiv="Content-Security-Policy" content="default-src 'self' https://fonts.googleapis.com https://fonts.gstatic.com; script-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; img-src 'self' data: blob: https://*.githubusercontent.com https://*.supabase.co; connect-src 'self' https://*.ingest.us.sentry.io; font-src 'self' https://fonts.gstatic.com data:; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'" /> |
There was a problem hiding this comment.
[REFACTORING] The meta tag in index.html is now a second source-of-truth for CSP (fallback), which risks configuration drift. Prefer a single source: generate this meta tag at build time from apps/frontend/src/main/security/csp-config.ts, or inject it at runtime from the main process / preload script so the HTML and session-injected CSP are always identical. At minimum add an automated test or build-time check that verifies the meta tag matches CSP_CONFIG (see csp-config.ts).
<!-- apps/frontend/src/renderer/index.html -->
<!-- CSP meta content is injected from build-time generated value -->
<meta http-equiv="Content-Security-Policy" content="__CSP_CONFIG__" />// apps/frontend/src/main/security/csp-config.ts
export const CSP_CONFIG = [
`default-src ${DEFAULT_SRC}`,
`script-src ${SCRIPT_SRC}`,
`style-src ${STYLE_SRC}`,
`img-src ${IMG_SRC}`,
`connect-src ${CONNECT_SRC}`,
`font-src ${FONT_SRC}`,
`object-src ${OBJECT_SRC}`,
`base-uri ${BASE_URI}`,
`form-action ${FORM_ACTION}`,
`frame-ancestors ${FRAME_ANCESTORS}`,
].join('; ');
// Optional: helper for build step / tests to keep meta tag in sync
export function getCspMetaTagContent(): string {
return CSP_CONFIG;
}// Example Vitest to assert sync between CSP_CONFIG and index.html
// apps/frontend/src/main/__tests__/csp-meta-sync.test.ts
import { readFileSync } from 'fs';
import { resolve } from 'path';
import { describe, it, expect } from 'vitest';
import { CSP_CONFIG } from '../security/csp-config';
describe('CSP meta tag stays in sync with CSP_CONFIG', () => {
it('index.html meta CSP matches CSP_CONFIG exactly', () => {
const htmlPath = resolve(__dirname, '../../renderer/index.html');
const html = readFileSync(htmlPath, 'utf8');
const match = html.match(/<meta[^>]+http-equiv="Content-Security-Policy"[^>]+content="([^"]+)"/);
expect(match).not.toBeNull();
const metaCsp = match?.[1];
expect(metaCsp).toBe(CSP_CONFIG);
});
});| // Verify CSP header was injected | ||
| expect(capturedResult).toBeDefined(); | ||
| expect(capturedResult.responseHeaders).toBeDefined(); | ||
| expect(capturedResult.responseHeaders['Content-Security-Policy']).toBeDefined(); | ||
| expect(capturedResult.responseHeaders['Content-Security-Policy']).toEqual([CSP_CONFIG]); | ||
| } |
There was a problem hiding this comment.
[VALIDATION] Assertions expect the injected header under the exact-cased key 'Content-Security-Policy' (line 322). Electron/web servers may return header names lowercased (e.g. 'content-security-policy'). Normalize header keys in tests before assertions, e.g. const headers = Object.fromEntries(Object.entries(capturedResult.responseHeaders).map(([k,v]) => [k.toLowerCase(), v])); expect(headers['content-security-policy']).toEqual([CSP_CONFIG]); This avoids brittle case-sensitive checks.
// After callback(mockDetails, mockCallback);
// Normalize headers to avoid case-sensitivity issues
const normalizedHeaders = Object.fromEntries(
Object.entries(capturedResult.responseHeaders).map(([key, value]) => [
key.toLowerCase(),
value,
]),
);
expect(normalizedHeaders["content-security-policy"]).toBeDefined();
expect(normalizedHeaders["content-security-policy"]).toEqual([CSP_CONFIG]);|
|
||
| // Mock process.resourcesPath for icon loading | ||
| if (!process.resourcesPath) { | ||
| process.resourcesPath = "/tmp/test/resources"; |
There was a problem hiding this comment.
[Security] Make sure publicly writable directories are used safely here.
// Mock process.resourcesPath for icon loading in tests.
// Use a directory under the test runner's temp area to avoid any writable
// system locations and ensure isolation between test runs.
if (!process.resourcesPath) {
process.resourcesPath = path.join(os.tmpdir(), "auto-claude-test-resources");
}|
Reviewed up to commit:4dba1eb68e43304f2340fa0a09c1e4fbcc4713ea |
The Electron frontend application does not appear to have Content Security Policy headers implemented. CSP is a critical defense-in-depth mechanism for Electron apps that restricts the sources from which content can be loaded, helping prevent XSS attacks and code injection even if input validation is bypassed.