IPC Architecture & Security Model
Architectural Foundation: Electron contextBridge · Electron ipcMain · Electron ipcRenderer
Description: Hardened IPC security model enforcing privilege separation via contextBridge, defining the Three-Step Bridge SOP for native feature exposure, the verified channel manifest, and critical anti-pattern audit checklist.
Scope: Electron Main ↔ Renderer process communication
Enforcement Level: Mandatory — deviations constitute security violations
This document defines the hardened security perimeter and communication topology governing the Desktop Wrapper. Every native capability exposed to the Renderer is mediated through a non-bypassable IPC bridge, enforcing strict privilege separation between the Node.js Main Process and untrusted web content.
The architecture operates on three invariants:
| Invariant | Guarantee |
|---|---|
| Context Encapsulation | The Preload Script executes in a hermetically sealed V8 context, isolated from both Main Process globals and the Renderer DOM. |
| Interface Narrowing | Only explicitly declared, type-safe API surfaces are exposed via contextBridge. No wildcard access patterns exist. |
| Deterministic Lifecycle | All IPC subscriptions are paired with unsubscribe functions, tying native event listeners to React's component lifecycle to prevent memory leaks. |
Table of Contents
- Privilege Separation Model
- Standard Operating Procedure: The Three-Step Bridge
- Verified Channel Manifest
- Extending the Bridge: Guided Walkthrough
- Critical Audit Checklist: Anti-Patterns
- The Gold Standard for Native Integration
Privilege Separation Model
The desktop wrapper enforces a strict privilege separation between three execution contexts, each operating under fundamentally different trust levels. This architecture ensures that a compromise in any single layer cannot escalate to full system access. The model is built on Electron's security primitives: contextBridge, ipcMain, and ipcRenderer.
Trust Level Matrix
| Context | Trust Level | Privilege Scope | Security Guarantee |
|---|---|---|---|
| Main Process | Fully Trusted | Unrestricted Node.js access: filesystem, network, printers, OS APIs, child processes | Only code authored by the engineering team executes here |
| Preload Script | Controlled | Restricted to ipcRenderer.invoke() and ipcRenderer.send() — no direct Node.js access | Executes in a Hermetically Sealed Context — isolated from both the Main Process globals and the Renderer's DOM |
| Renderer | Untrusted | Standard browser sandbox — zero Node.js API surface | Designated as a Zero-Trust Environment — may execute third-party code, npm packages, or XSS payloads |
Process Topology
The Preload Script functions as a Secure Gateway that performs Interface Narrowing — it transforms the broad, unrestricted IPC capabilities of the Main Process into a deliberately narrow, type-safe API surface. The renderer communicates with native functionality exclusively through this gateway. There are no alternative paths, no escape hatches, and no backdoors.
Enforcement Configuration
These settings are declared in BrowserWindow.webPreferences and are non-negotiable:
| Setting | Value | Enforcement |
|---|---|---|
contextIsolation | true | The Preload executes in a hermetically sealed V8 context. The renderer cannot access require(), Node.js globals, or any variable from the preload's scope. |
nodeIntegration | false | Zero Node.js API surface in the renderer. fs, child_process, os, net, and all built-in modules are completely unavailable. |
sandbox | true | The renderer process runs inside a Chromium OS-level sandbox, restricting system calls and file access at the kernel level. |
webSecurity | true | The same-origin policy is strictly enforced, preventing cross-origin data exfiltration from the renderer. |
Standard Operating Procedure: The Three-Step Bridge
Every native feature in this architecture must follow the Three-Step Bridge — a Standard Operating Procedure (SOP) that ensures traceability, type-safety, and auditability across the entire IPC surface.
> **Deterministic Synchronization**: Maintaining parity between the Main Process handler, the Preload Gateway exposure, and the TypeScript interface declaration is **mandatory**. A mismatch between any two of the three layers will result in either a **Type-Safety Gap** (silent failures in development) or a **Runtime Regression** (crashes in production).
Step 1: Register the Handler — Main Process
File: apps/desktop/src/main/index.ts
// COMMAND PATTERN: Use ipcMain.handle for request/response operations
// The handler returns a value to the renderer via a resolved Promise.
ipcMain.handle('feature:action', async (_event, arg1: string, arg2: number) => {
// Validate inputs. Never trust data from the renderer.
if (typeof arg1 !== 'string' || typeof arg2 !== 'number') {
throw new Error('Invalid arguments');
}
const result = await someNativeAPI(arg1, arg2);
return result;
});
// EVENT PATTERN: Use ipcMain.on for fire-and-forget operations
// No return value — the renderer does not wait for a response.
ipcMain.on('feature:fire', (_event, data: SomeType) => {
performSideEffect(data);
});Channel naming convention: namespace:action — examples: printer:get-list, updater:check, app:get-version. Namespaces must be unique, descriptive, and never generic.
Step 2: Expose via contextBridge — Preload Gateway
File: apps/desktop/src/preload/index.ts
const electronAPI = {
// Command pattern exposure
featureAction: (arg1: string, arg2: number): Promise<ResultType> => {
return ipcRenderer.invoke('feature:action', arg1, arg2);
},
// Event pattern exposure
featureFire: (data: SomeType): void => {
ipcRenderer.send('feature:fire', data);
},
// Main→Renderer push events (with automatic lifecycle cleanup)
onFeatureEvent: createEventSubscription<EventDataType>('feature:event'),
};
contextBridge.exposeInMainWorld('electronAPI', electronAPI);Non-negotiable rules:
- Never expose
ipcRendererdirectly — this is a Catastrophic Failure pattern. - Never expose
ipcRenderer.onwithout cleanup — usecreateEventSubscription(), which returns an unsubscribe function for ReactuseEffectlifecycle management. - Always declare explicit TypeScript types for all function signatures.
Step 3: Declare the Interface — React Application
File: apps/web/src/types/electron.d.ts
interface ElectronAPI {
// ... existing methods ...
featureAction: (arg1: string, arg2: number) => Promise<ResultType>;
featureFire: (data: SomeType) => void;
onFeatureEvent: (callback: (data: EventDataType) => void) => () => void;
}Verified Channel Manifest
The following is the complete, authoritative registry of all authorized IPC channels. These channels are the only permitted vectors for native interaction. Any IPC channel not listed here is unauthorized and must be treated as a security anomaly.
Printer Subsystem
| Channel | Direction | Pattern | Payload | Access Control |
|---|---|---|---|---|
printer:get-list | Renderer → Main → Renderer | invoke / handle | Returns ElectronPrinterInfo[] | Read-only hardware enumeration |
printer:print | Renderer → Main → Renderer | invoke / handle | Accepts ElectronPrintOptions, returns { success, failureReason? } | Controlled hardware invocation |
Main process handler: setupPrinterIPC() in src/main/index.ts
Preload Gateway surface:
getPrinters: () => ipcRenderer.invoke('printer:get-list');
print: (options?) => ipcRenderer.invoke('printer:print', options);React consumption hook: useElectronPrinter() in apps/web/src/hooks/use-electron-printer.ts
Auto-Updater Subsystem
| Channel | Direction | Pattern | Payload | Access Control |
|---|---|---|---|---|
updater:check | Renderer → Main | invoke / handle | Returns update check result | Read-only version query |
updater:install | Renderer → Main | send / on | No payload | Privileged: quits app and installs |
updater:checking | Main → Renderer | send | No payload | Status notification |
updater:available | Main → Renderer | send | UpdateInfo { version, releaseDate, releaseNotes } | Status notification |
updater:not-available | Main → Renderer | send | UpdateInfo | Status notification |
updater:progress | Main → Renderer | send | ProgressInfo { percent, bytesPerSecond, transferred, total } | Progress telemetry |
updater:downloaded | Main → Renderer | send | UpdateInfo | Status notification |
updater:error | Main → Renderer | send | Error message string | Error telemetry |
Main process handlers: setupAutoUpdaterIPC() + setupAutoUpdaterEvents() in src/main/index.ts
React consumption hook: useElectronUpdater() in apps/web/src/hooks/use-electron-updater.ts
NOTE
The updater:install channel is the highest-privilege IPC operation in the system — it terminates the running process and launches a new binary. It should only be triggered by an explicit user action, never automatically.
Extending the Bridge: Guided Walkthrough
Scenario: Expose the application version to the React UI.
1. Main Process — Register Handler
// In app.whenReady() callback, src/main/index.ts
ipcMain.handle('app:get-version', () => {
return app.getVersion();
});2. Preload Gateway — Expose Method
// Add to the electronAPI object, src/preload/index.ts
const electronAPI = {
// ... existing methods ...
getAppVersion: (): Promise<string> => {
return ipcRenderer.invoke('app:get-version');
},
};3. TypeScript Interface — Declare Type
// Add to ElectronAPI interface, apps/web/src/types/electron.d.ts
interface ElectronAPI {
// ... existing methods ...
getAppVersion: () => Promise<string>;
}4. React — Consume
function VersionBadge() {
const [version, setVersion] = useState('');
useEffect(() => {
if (window.electronAPI) {
window.electronAPI.getAppVersion().then(setVersion);
}
}, []);
if (!version) return null;
return <span className="version-badge">v{version}</span>;
}5. Update This Manifest
After implementing a new channel, add it to the Verified Channel Manifest in this document. Undocumented channels are unauthorized channels.
Critical Audit Checklist: Anti-Patterns
The following patterns constitute critical security violations. Each one expands the attack surface from "browser-level sandboxed web content" to "unrestricted OS-level code execution." Their presence in production code warrants immediate incident response.
❌ Exposing raw ipcRenderer
// VIOLATION: Catastrophic Failure — Total Attack Surface Expansion
contextBridge.exposeInMainWorld('ipc', ipcRenderer);Threat: The renderer gains unrestricted IPC access — it can invoke any channel, including channels that were never intended to be callable from the renderer. A single XSS vulnerability escalates to arbitrary native code execution.
Classification: Total System Compromise
❌ Exposing require or Node.js APIs
// VIOLATION: Unauthenticated Code Execution
contextBridge.exposeInMainWorld('require', require);Threat: The renderer can require('child_process').exec('rm -rf /'). A single XSS vulnerability in any dependency — including transitive ones — escalates to full filesystem access, credential theft, reverse shells, and data exfiltration.
Classification: Total System Compromise
❌ Enabling nodeIntegration
// VIOLATION: Catastrophic Failure — Complete Boundary Collapse
new BrowserWindow({
webPreferences: { nodeIntegration: true, contextIsolation: false },
});Threat: Every <script> tag in the renderer — including XSS payloads, compromised npm packages, and injected analytics scripts — gains full Node.js capabilities. The isolation boundary ceases to exist.
Classification: Total System Compromise
❌ Passing unsanitized IPC data to shell commands
// VIOLATION: Command Injection — Unauthenticated Code Execution
ipcMain.handle('run-cmd', (_event, cmd: string) => {
exec(cmd); // The renderer controls the command string
});Threat: The renderer can execute arbitrary system commands with the privileges of the Electron main process (typically the current user). This is the most direct path from XSS to OS-level compromise.
Classification: Unauthenticated Code Execution
❌ Registering overly broad IPC channels
// VIOLATION: Attack Surface Expansion — Unrestricted File Read
ipcMain.handle('file:read', (_event, path: string) => {
return readFileSync(path, 'utf-8'); // No validation
});Threat: The renderer can read any file on the filesystem — SSH keys, environment files, database credentials, browser cookies. Input validation is not optional.
Classification: Sensitive Data Exfiltration
The Gold Standard for Native Integration
The following patterns represent the mandatory standard for all IPC implementations. Adherence is non-negotiable.
✅ Validate and constrain all IPC arguments
// GOLD STANDARD: Input validation, path confinement, scope restriction
ipcMain.handle('file:read', async (_event, filename: string) => {
// Reject path separators — confine to a single directory
if (filename.includes('/') || filename.includes('\\') || filename.includes('..')) {
throw new Error('Invalid filename');
}
// Resolve within a controlled directory only
const safePath = join(app.getPath('userData'), 'data', filename);
// Verify the resolved path stays within bounds
if (!safePath.startsWith(join(app.getPath('userData'), 'data'))) {
throw new Error('Path traversal detected');
}
return readFileSync(safePath, 'utf-8');
});Principle: Never trust data originating from the renderer. Validate types, constrain scope, and verify resolved paths.
✅ Return unsubscribe functions — Memory Leak Mitigation
// GOLD STANDARD: The createEventSubscription helper ensures automatic cleanup
function createEventSubscription<T>(channel: string) {
return (callback: (data: T) => void): (() => void) => {
const handler = (_event: Electron.IpcRendererEvent, data: T) => callback(data);
ipcRenderer.on(channel, handler);
// Return an unsubscribe function — critical for React lifecycle
return () => {
ipcRenderer.removeListener(channel, handler);
};
};
}In React's useEffect:
useEffect(() => {
if (!window.electronAPI) return;
// Subscribe — handler is registered in the Preload's IPC layer
const unsub = window.electronAPI.onSomeEvent((data) => {
setState(data);
});
// Cleanup on unmount — prevents listener accumulation
return () => unsub();
}, []);Principle: Without the unsubscribe pattern, every component mount adds a new IPC listener that persists after unmount. Over time — especially with React's StrictMode double-mounting in development — this causes memory leaks, duplicate event handling, and performance degradation. The createEventSubscription helper enforces automatic, deterministic cleanup tied to React's component lifecycle.
✅ Gate all Electron calls behind runtime detection
// GOLD STANDARD: Environment-safe consumption
function useElectronFeature() {
const isElectron = typeof window !== 'undefined' && !!window.electronAPI;
const doSomething = useCallback(() => {
if (!window.electronAPI) return; // No-op in browser
window.electronAPI.someMethod();
}, []);
return { isElectron, doSomething };
}Principle: The React app must run identically in both Electron and standard browser environments. All window.electronAPI access must be gated behind a runtime check. Never assume the IPC bridge exists.