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 webPreferences —
nodeIntegration: false,contextIsolation: true,sandbox: truein 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.