Skip to content

Implement Content Security Policy (CSP) for Electron app#9

Open
OBenner wants to merge 7 commits intodevelopfrom
auto-claude/012-implement-content-security-policy-csp-for-electron
Open

Implement Content Security Policy (CSP) for Electron app#9
OBenner wants to merge 7 commits intodevelopfrom
auto-claude/012-implement-content-security-policy-csp-for-electron

Conversation

@OBenner
Copy link
Owner

@OBenner OBenner commented Feb 7, 2026

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.

OBenner and others added 7 commits February 4, 2026 20:16
- 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
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>
@pantoaibot
Copy link

pantoaibot bot commented Feb 7, 2026

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:

  • New: apps/frontend/src/main/security/csp-config.ts

    • Centralized CSP policy broken into directives (default-src, script-src, style-src, img-src, connect-src, font-src, object-src, base-uri, form-action, frame-ancestors).
    • Exports CSP_CONFIG (full header string), CSP_DIRECTIVES (per-directive values), and validateCSP(cspString) helper.
    • Policy is strict: script-src excludes 'unsafe-inline' and 'unsafe-eval'; style-src allows 'unsafe-inline' (documented trade-off for CSS-in-JS).
    • Extensive documentation/comments about rationale, testing, and change process.
  • Modified: apps/frontend/src/main/index.ts

    • Import CSP_CONFIG and register session.defaultSession.webRequest.onHeadersReceived to inject Content-Security-Policy header for all responses (primary enforcement).
    • Retains existing startup behavior; added comments explaining defense-in-depth.
  • Modified: apps/frontend/src/renderer/index.html

    • Add a commented explanation and a fallback tag that mirrors the session policy (fallback only — must be kept in sync).
  • Tests:

    • New: apps/frontend/src/main/tests/csp-enforcement.test.ts
      • Unit tests verifying CSP_CONFIG structure, individual directives, validateCSP behavior, and that the onHeadersReceived callback injects the CSP header while preserving other headers / handling empty headers.
    • Modified: apps/frontend/src/main/tests/window-security.test.ts
      • Mock updated to include session.defaultSession.webRequest.onHeadersReceived to avoid test failures when CSP injection is present.

Behavioral / security notes:

  • CSP is enforced at the session (network) layer — cannot be bypassed by the renderer — with the meta tag as a defense-in-depth fallback.
  • The policy already whitelists Google Fonts, Sentry endpoints, GitHub and Supabase image hosts; adding/removing external domains requires updating security/csp-config.ts and keeping the meta tag in sync.
  • Potential breaking behavior: resources or integrations that load from domains not included in CSP may be blocked; reviewers should validate all external assets (fonts, images, analytics, storage) still load correctly.

No dependency updates.

Reviewed by Panto AI

Comment on lines +267 to +277
// 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 });
});
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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 });
    }
  });

Comment on lines +267 to +277
// 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 });
});
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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 });
      }
    },
  );

Comment on lines +347 to +358
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('; ');
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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;
}

Comment on lines +6 to +19
<!--
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'" />
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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);
  });
});

Comment on lines +319 to +324
// 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]);
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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";
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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");
}

@pantoaibot
Copy link

pantoaibot bot commented Feb 7, 2026

Reviewed up to commit:4dba1eb68e43304f2340fa0a09c1e4fbcc4713ea

Reviewed by Panto AI

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant