Electron App Security Testing: CSP, Context Isolation, and XSS Prevention

Electron App Security Testing: CSP, Context Isolation, and XSS Prevention

Electron apps run web content in a desktop shell with access to Node.js APIs. This combination is powerful — and dangerous. Security vulnerabilities in Electron apps can lead to remote code execution (RCE), making security testing not optional but critical.

This guide covers automated security testing for Electron applications: testing Content Security Policy enforcement, context isolation, IPC input validation, nodeIntegration risks, and the migration path from Spectron to Playwright.

The Electron Security Threat Model

Electron has unique attack surfaces:

Remote code execution via XSS: If an Electron app renders untrusted content with nodeIntegration: true, a successful XSS attack gives an attacker full Node.js access — file system, child processes, network sockets.

Context isolation bypass: Without contextIsolation: true, the renderer's JavaScript shares scope with Electron's internal code, enabling prototype pollution attacks.

IPC injection: The IPC bridge between renderer and main process is a trust boundary. Unvalidated IPC messages can trigger unintended main process actions.

Protocol handler injection: Custom protocol handlers (app://, myapp://) can be exploited to load unauthorized resources.

Testing Your webPreferences Security Baseline

Start by asserting your BrowserWindow configuration is secure:

// tests/security/webPreferences.test.ts
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { _electron as electron } from '@playwright/test';
import type { ElectronApplication } from '@playwright/test';
import path from 'path';

describe('BrowserWindow security configuration', () => {
  let app: ElectronApplication;

  beforeAll(async () => {
    app = await electron.launch({
      args: [path.join(__dirname, '../../main.js')],
    });
  });

  afterAll(() => app.close());

  it('has nodeIntegration disabled', async () => {
    const webPrefs = await app.evaluate(({ BrowserWindow }) => {
      const win = BrowserWindow.getAllWindows()[0];
      return win.webContents.getURL();  // If node is enabled, we can detect it differently
    });

    // Test directly in renderer — if nodeIntegration is on, process.versions.node is defined
    const window = await app.firstWindow();
    const hasNodeAccess = await window.evaluate(() => {
      return typeof (window as any).process !== 'undefined' && 
             typeof (window as any).require !== 'undefined';
    });

    expect(hasNodeAccess).toBe(false);
  });

  it('has contextIsolation enabled', async () => {
    const window = await app.firstWindow();

    // With contextIsolation on, the renderer cannot access Electron internals
    const hasContextIsolation = await window.evaluate(() => {
      try {
        // This would only work without context isolation
        const electronInternal = (window as any).electron?.process;
        return electronInternal === undefined;
      } catch {
        return true; // Good — isolated
      }
    });

    expect(hasContextIsolation).toBe(true);
  });

  it('has webSecurity enabled', async () => {
    const webSecurityEnabled = await app.evaluate(({ BrowserWindow }) => {
      const win = BrowserWindow.getAllWindows()[0];
      // webSecurity is reflected in session's permissions
      // Test by checking if cross-origin requests are blocked
      return true; // Verify via CSP tests below
    });

    expect(webSecurityEnabled).toBe(true);
  });

  it('has sandbox enabled for renderer', async () => {
    const isSandboxed = await app.evaluate(({ BrowserWindow }) => {
      const win = BrowserWindow.getAllWindows()[0];
      return win.webContents.isCrashed(); // Fallback check
    });

    // More direct: check if child_process is unavailable in renderer
    const window = await app.firstWindow();
    const canSpawnProcess = await window.evaluate(() => {
      try {
        // In sandboxed renderer, require is undefined
        const cp = (window as any).require?.('child_process');
        return cp !== undefined;
      } catch {
        return false;
      }
    });

    expect(canSpawnProcess).toBe(false);
  });
});

Testing Content Security Policy

CSP prevents XSS by controlling which scripts, styles, and resources can load:

// tests/security/csp.test.ts
import { test, expect } from '@playwright/test';
import { _electron as electron } from '@playwright/test';

test.describe('Content Security Policy', () => {
  test('CSP header is present on main window', async () => {
    const app = await electron.launch({ args: ['main.js'] });
    const window = await app.firstWindow();

    // Intercept network requests to check CSP headers
    const response = await window.waitForResponse('**');
    const cspHeader = response.headers()['content-security-policy'];

    expect(cspHeader).toBeDefined();
    expect(cspHeader).toContain("default-src 'self'");
    expect(cspHeader).not.toContain("'unsafe-inline'");
    expect(cspHeader).not.toContain("'unsafe-eval'");

    await app.close();
  });

  test('inline scripts are blocked by CSP', async () => {
    const app = await electron.launch({ args: ['main.js'] });
    const window = await app.firstWindow();

    const cspViolations: string[] = [];
    window.on('console', (msg) => {
      if (msg.text().includes('Content Security Policy')) {
        cspViolations.push(msg.text());
      }
    });

    // Attempt to inject inline script
    const result = await window.evaluate(() => {
      try {
        const script = document.createElement('script');
        script.textContent = 'window.__injected = true';
        document.head.appendChild(script);
        return (window as any).__injected;
      } catch (e) {
        return undefined;
      }
    });

    // Inline script should have been blocked
    expect(result).toBeUndefined();

    await app.close();
  });

  test('eval is blocked', async () => {
    const app = await electron.launch({ args: ['main.js'] });
    const window = await app.firstWindow();

    const evalBlocked = await window.evaluate(() => {
      try {
        eval('1 + 1');
        return false; // eval worked — bad
      } catch (e) {
        return true; // eval blocked — good
      }
    });

    expect(evalBlocked).toBe(true);

    await app.close();
  });
});

Testing IPC Input Validation

IPC channels are trust boundaries. Test that your main process validates all inputs:

// main/ipcHandlers.ts — what we're testing
import { ipcMain, shell } from 'electron';
import path from 'path';
import fs from 'fs/promises';

const ALLOWED_DIR = path.join(process.env.HOME!, 'Documents', 'MyApp');

ipcMain.handle('read-file', async (event, filePath: string) => {
  // Security: validate path is within allowed directory
  const resolved = path.resolve(ALLOWED_DIR, filePath);
  
  if (!resolved.startsWith(ALLOWED_DIR)) {
    throw new Error('Path traversal attempt blocked');
  }
  
  return fs.readFile(resolved, 'utf-8');
});

ipcMain.handle('open-url', async (event, url: string) => {
  // Security: only allow http/https
  const parsed = new URL(url);
  if (!['http:', 'https:'].includes(parsed.protocol)) {
    throw new Error(`Blocked protocol: ${parsed.protocol}`);
  }
  
  await shell.openExternal(url);
});
// tests/security/ipc-validation.test.ts
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { _electron as electron } from '@playwright/test';
import type { ElectronApplication, Page } from '@playwright/test';

describe('IPC input validation', () => {
  let app: ElectronApplication;
  let window: Page;

  beforeAll(async () => {
    app = await electron.launch({ args: ['main.js'] });
    window = await app.firstWindow();
  });

  afterAll(() => app.close());

  describe('read-file handler', () => {
    it('allows reading files within allowed directory', async () => {
      // Set up a test file in the allowed directory first
      await app.evaluate(async ({ app: electronApp }) => {
        const fs = require('fs/promises');
        const path = require('path');
        const allowedDir = path.join(process.env.HOME, 'Documents', 'MyApp');
        await fs.mkdir(allowedDir, { recursive: true });
        await fs.writeFile(path.join(allowedDir, 'test.txt'), 'test content');
      });

      const result = await window.evaluate(async () => {
        return window.electron.ipcRenderer.invoke('read-file', 'test.txt');
      });

      expect(result).toBe('test content');
    });

    it('blocks path traversal attempts', async () => {
      const result = await window.evaluate(async () => {
        try {
          await window.electron.ipcRenderer.invoke('read-file', '../../../etc/passwd');
          return { blocked: false };
        } catch (e) {
          return { blocked: true, error: (e as Error).message };
        }
      });

      expect(result.blocked).toBe(true);
      expect(result.error).toContain('Path traversal');
    });

    it('blocks absolute path injection', async () => {
      const result = await window.evaluate(async () => {
        try {
          await window.electron.ipcRenderer.invoke('read-file', '/etc/shadow');
          return { blocked: false };
        } catch (e) {
          return { blocked: true, error: (e as Error).message };
        }
      });

      expect(result.blocked).toBe(true);
    });
  });

  describe('open-url handler', () => {
    it('allows http URLs', async () => {
      // Mock shell.openExternal to avoid actually opening browser
      await app.evaluate(({ shell }) => {
        (shell as any)._openExternalCalled = [];
        const original = shell.openExternal.bind(shell);
        shell.openExternal = async (url: string) => {
          (shell as any)._openExternalCalled.push(url);
        };
      });

      await window.evaluate(async () => {
        await window.electron.ipcRenderer.invoke('open-url', 'https://example.com');
      });

      const called = await app.evaluate(({ shell }) => (shell as any)._openExternalCalled);
      expect(called).toContain('https://example.com');
    });

    it('blocks javascript: protocol', async () => {
      const result = await window.evaluate(async () => {
        try {
          await window.electron.ipcRenderer.invoke('open-url', 'javascript:alert(1)');
          return { blocked: false };
        } catch (e) {
          return { blocked: true };
        }
      });

      expect(result.blocked).toBe(true);
    });

    it('blocks file: protocol', async () => {
      const result = await window.evaluate(async () => {
        try {
          await window.electron.ipcRenderer.invoke('open-url', 'file:///etc/passwd');
          return { blocked: false };
        } catch (e) {
          return { blocked: true };
        }
      });

      expect(result.blocked).toBe(true);
    });
  });
});

Unit Testing Security Logic (No Electron Required)

Extract security-critical logic to pure functions and test them without Electron:

// src/security/pathValidator.ts
import path from 'path';

export function validateFilePath(allowedDir: string, userInput: string): string {
  const resolved = path.resolve(allowedDir, userInput);
  
  if (!resolved.startsWith(allowedDir + path.sep) && resolved !== allowedDir) {
    throw new Error(`Path traversal attempt: ${userInput}${resolved}`);
  }
  
  return resolved;
}

export function validateUrl(url: string): URL {
  let parsed: URL;
  try {
    parsed = new URL(url);
  } catch {
    throw new Error(`Invalid URL: ${url}`);
  }
  
  const allowedProtocols = ['http:', 'https:'];
  if (!allowedProtocols.includes(parsed.protocol)) {
    throw new Error(`Blocked protocol: ${parsed.protocol}. Only http/https allowed.`);
  }
  
  return parsed;
}
// tests/security/validators.test.ts
import { describe, it, expect } from 'vitest';
import { validateFilePath, validateUrl } from '~/security/pathValidator';

describe('validateFilePath', () => {
  const allowedDir = '/home/user/Documents/MyApp';

  it('allows files within the allowed directory', () => {
    expect(() => validateFilePath(allowedDir, 'report.pdf')).not.toThrow();
    expect(validateFilePath(allowedDir, 'report.pdf')).toBe('/home/user/Documents/MyApp/report.pdf');
  });

  it('allows nested paths within allowed directory', () => {
    expect(() => validateFilePath(allowedDir, 'subdir/file.txt')).not.toThrow();
  });

  it('blocks ../traversal', () => {
    expect(() => validateFilePath(allowedDir, '../other-dir/secret')).toThrow('Path traversal');
    expect(() => validateFilePath(allowedDir, '../../etc/passwd')).toThrow('Path traversal');
  });

  it('blocks absolute paths outside allowed dir', () => {
    expect(() => validateFilePath(allowedDir, '/etc/shadow')).toThrow('Path traversal');
    expect(() => validateFilePath(allowedDir, '/home/user/Documents/other')).toThrow('Path traversal');
  });

  it('blocks URL-encoded traversal', () => {
    // path.resolve handles encoded paths — these won't bypass via encoding
    expect(() => validateFilePath(allowedDir, '%2e%2e/secret')).toThrow('Path traversal');
  });
});

describe('validateUrl', () => {
  it('allows https URLs', () => {
    expect(() => validateUrl('https://example.com/page')).not.toThrow();
  });

  it('allows http URLs', () => {
    expect(() => validateUrl('http://example.com/page')).not.toThrow();
  });

  it('blocks javascript: protocol', () => {
    expect(() => validateUrl('javascript:alert(1)')).toThrow('Blocked protocol');
  });

  it('blocks file: protocol', () => {
    expect(() => validateUrl('file:///etc/passwd')).toThrow('Blocked protocol');
  });

  it('blocks ftp: protocol', () => {
    expect(() => validateUrl('ftp://malicious.com/evil')).toThrow('Blocked protocol');
  });

  it('blocks invalid URLs', () => {
    expect(() => validateUrl('not-a-url')).toThrow('Invalid URL');
  });
});

Migrating from Spectron to Playwright

Spectron is deprecated. Here's the migration map:

// SPECTRON (old) — don't use this
import { Application } from 'spectron';

const app = new Application({ path: '/path/to/electron' });
await app.start();
const win = app.browserWindow;
const isVisible = await win.isVisible();
const title = await app.client.getTitle();
await app.stop();
// PLAYWRIGHT (new) — use this
import { _electron as electron } from '@playwright/test';

const app = await electron.launch({ args: ['main.js'] });
const window = await app.firstWindow();
const isVisible = await window.isVisible('body');
const title = await window.title();
await app.close();

Migration table:

Spectron Playwright Equivalent
app.start() electron.launch({ args: ['main.js'] })
app.client.getTitle() window.title()
app.client.$('selector') window.locator('selector')
app.browserWindow.isVisible() window.isVisible('body')
app.electron.remote.app app.evaluate(({ app }) => ...)
app.webContents.executeJavaScript(code) window.evaluate(code)
app.stop() app.close()
// Full migration example
// tests/app.spec.ts (Playwright)
import { test, expect, _electron as electron } from '@playwright/test';
import path from 'path';

test.describe('Application', () => {
  test('launches successfully', async () => {
    const app = await electron.launch({
      args: [path.join(__dirname, '../main.js')],
    });
    
    const window = await app.firstWindow();
    
    expect(await window.title()).toContain('My App');
    
    // Access main process APIs
    const appPath = await app.evaluate(({ app: electronApp }) => {
      return electronApp.getAppPath();
    });
    
    expect(appPath).toBeDefined();
    
    await app.close();
  });
});

Summary

Electron security testing requires:

  • Assert webPreferencesnodeIntegration: false, contextIsolation: true, sandbox: true in every BrowserWindow
  • Test CSP enforcement — verify 'unsafe-inline' and 'unsafe-eval' are absent; block inline script injection
  • Validate IPC inputs — path traversal prevention, protocol allowlists, type checking before any file system or shell operations
  • Extract and unit test security logic — pure functions for path and URL validation, testable without running Electron
  • Migrate from Spectron to Playwright — Spectron is deprecated and unmaintained; Playwright has native Electron support

Security testing for Electron isn't a one-time audit. Add it to your CI and run it on every PR — authorization and validation bugs are easiest to catch close to introduction, not in production.

Read more