10 KiB
IPC Architecture & Security Model
This document explains the security model of the Electron desktop wrapper, documents the existing IPC channels, and provides a step-by-step guide for extending the app with new native features.
Table of Contents
- Security Model
- The Three-Step Bridge Pattern
- Existing IPC Channels
- Adding a New Feature: Step-by-Step Example
- Anti-Patterns to Avoid
Security Model
The Electron desktop wrapper enforces a strict security boundary between the main process (Node.js) and the renderer process (web app). This is critical because the renderer runs untrusted web content that could be compromised by XSS, malicious dependencies, or supply chain attacks.
Core Principles
| Setting | Value | Why |
|---|---|---|
contextIsolation |
true |
The preload script runs in an isolated JavaScript context. The renderer cannot access Node.js APIs, require(), or the preload's scope. |
nodeIntegration |
false |
Node.js APIs (fs, child_process, os, etc.) are completely unavailable in the renderer. |
sandbox |
true |
The renderer process runs in a Chromium sandbox with restricted OS-level access. |
webSecurity |
true |
Same-origin policy is enforced. Cross-origin requests follow standard browser rules. |
What This Means in Practice
┌──────────────────────────────────────────────────────────────────┐
│ Main Process │
│ Full Node.js access: filesystem, printers, native APIs, │
│ auto-updater, child processes, network (unrestricted) │
│ │
│ ipcMain.handle('channel', handler) │
├──────────────────────────────────────────────────────────────────┤
│ Preload Script │
│ Isolated context. Can use ipcRenderer (send/invoke only). │
│ Exposes a MINIMAL API surface via contextBridge. │
│ │
│ contextBridge.exposeInMainWorld('electronAPI', { ... }) │
├──────────────────────────────────────────────────────────────────┤
│ Renderer (React App) │
│ Standard browser environment. NO Node.js access. │
│ Can ONLY call methods on window.electronAPI. │
│ Cannot access ipcRenderer, require, fs, etc. │
│ │
│ window.electronAPI.someMethod() │
└──────────────────────────────────────────────────────────────────┘
The renderer communicates with the main process only through the API surface defined in the preload script. This API surface is deliberately narrow — each exposed method does exactly one thing.
The Three-Step Bridge Pattern
Every native feature follows the same three-step pattern:
Step 1: Register the Handler in the Main Process
File: apps/desktop/src/main/index.ts
// Use ipcMain.handle for request/response (returns a value)
ipcMain.handle('feature:action', async (_event, arg1, arg2) => {
// Perform the native operation
const result = await someNativeAPI(arg1, arg2);
return result;
});
// Use ipcMain.on for fire-and-forget (no return value)
ipcMain.on('feature:fire', (_event, data) => {
doSomething(data);
});
Naming convention: Use namespace:action format. Examples: printer:get-list, updater:check, fs:read-file.
Step 2: Expose via contextBridge in the Preload Script
File: apps/desktop/src/preload/index.ts
const electronAPI = {
// For request/response channels
featureAction: (arg1: string, arg2: number): Promise<ResultType> => {
return ipcRenderer.invoke('feature:action', arg1, arg2);
},
// For fire-and-forget channels
featureFire: (data: SomeType): void => {
ipcRenderer.send('feature:fire', data);
},
// For main→renderer events (push notifications)
onFeatureEvent: createEventSubscription<EventDataType>('feature:event'),
};
contextBridge.exposeInMainWorld('electronAPI', electronAPI);
Rules:
- Never expose
ipcRendererdirectly. - Never expose
ipcRenderer.on— use thecreateEventSubscription()helper that returns an unsubscribe function. - Always specify TypeScript types for function signatures.
Step 3: Update TypeScript Declarations in the React App
File: apps/web/src/types/electron.d.ts
interface ElectronAPI {
// ... existing methods ...
// New feature
featureAction: (arg1: string, arg2: number) => Promise<ResultType>;
featureFire: (data: SomeType) => void;
onFeatureEvent: (callback: (data: EventDataType) => void) => () => void;
}
All three files must stay in sync. If you add a channel to the main process, you must expose it in the preload and declare it in the type file.
Existing IPC Channels
Printer Channels
| Channel | Direction | Type | Description |
|---|---|---|---|
printer:get-list |
Renderer → Main → Renderer | invoke / handle |
Returns ElectronPrinterInfo[] — list of all connected printers. |
printer:print |
Renderer → Main → Renderer | invoke / handle |
Triggers a print job with given options. Returns { success, failureReason? }. |
Main process implementation: setupPrinterIPC() in src/main/index.ts
Preload exposure:
getPrinters: () => ipcRenderer.invoke('printer:get-list')
print: (options?) => ipcRenderer.invoke('printer:print', options)
React hook: useElectronPrinter() in apps/web/src/hooks/use-electron-printer.ts
Auto-Updater Channels
| Channel | Direction | Type | Description |
|---|---|---|---|
updater:check |
Renderer → Main | invoke / handle |
Triggers a manual update check. Returns the check result. |
updater:install |
Renderer → Main | send / on |
Quits the app and installs the downloaded update. |
updater:checking |
Main → Renderer | send |
Emitted when the updater starts checking. |
updater:available |
Main → Renderer | send |
Emitted when an update is found. Payload: UpdateInfo. |
updater:not-available |
Main → Renderer | send |
Emitted when the app is up to date. Payload: UpdateInfo. |
updater:progress |
Main → Renderer | send |
Emitted during download. Payload: ProgressInfo. |
updater:downloaded |
Main → Renderer | send |
Emitted when download completes. Payload: UpdateInfo. |
updater:error |
Main → Renderer | send |
Emitted on error. Payload: error message string. |
Main process implementation: setupAutoUpdaterIPC() and setupAutoUpdaterEvents() in src/main/index.ts
Preload exposure: checkForUpdates(), installUpdate(), onUpdateAvailable(), onDownloadProgress(), onUpdateDownloaded(), onUpdateError(), onUpdateChecking(), onUpdateNotAvailable()
React hook: useElectronUpdater() in apps/web/src/hooks/use-electron-updater.ts
Adding a New Feature: Step-by-Step Example
Scenario: Add a method to read the app's version from the main process.
1. Main Process
In src/main/index.ts, add inside app.whenReady():
ipcMain.handle('app:get-version', () => {
return app.getVersion();
});
2. Preload Script
In src/preload/index.ts, add to the electronAPI object:
const electronAPI = {
// ... existing methods ...
getAppVersion: (): Promise<string> => {
return ipcRenderer.invoke('app:get-version');
},
};
3. TypeScript Declarations
In apps/web/src/types/electron.d.ts, add to the ElectronAPI interface:
interface ElectronAPI {
// ... existing methods ...
getAppVersion: () => Promise<string>;
}
4. React Usage
function VersionDisplay() {
const [version, setVersion] = useState('');
useEffect(() => {
if (window.electronAPI) {
window.electronAPI.getAppVersion().then(setVersion);
}
}, []);
if (!version) return null;
return <span>v{version}</span>;
}
Anti-Patterns to Avoid
❌ Never expose ipcRenderer directly
// BAD — gives the renderer unrestricted IPC access
contextBridge.exposeInMainWorld('ipc', ipcRenderer);
❌ Never expose require or Node.js APIs
// BAD — allows arbitrary code execution from the renderer
contextBridge.exposeInMainWorld('require', require);
❌ Never use nodeIntegration: true
// BAD — completely disables the security boundary
new BrowserWindow({
webPreferences: { nodeIntegration: true, contextIsolation: false }
});
❌ Never pass unsanitized IPC data to shell commands
// BAD — command injection vulnerability
ipcMain.handle('run-cmd', (_event, cmd: string) => {
exec(cmd); // Attacker can run ANY command
});
✅ Always validate IPC arguments in the main process
// GOOD — validate and constrain inputs
ipcMain.handle('file:read', async (_event, filename: string) => {
// Validate: only allow specific filenames, no path separators
if (filename.includes('/') || filename.includes('\\')) {
throw new Error('Invalid filename');
}
const safePath = join(app.getPath('userData'), 'data', filename);
return readFileSync(safePath, 'utf-8');
});
✅ Always return unsubscribe functions for event listeners
// GOOD — prevents memory leaks in React's useEffect
onSomeEvent: createEventSubscription<DataType>('channel:event')
// In React:
useEffect(() => {
const unsub = window.electronAPI.onSomeEvent((data) => { /* ... */ });
return () => unsub(); // Cleanup on unmount
}, []);