Skip to content

Commit 2c7195f

Browse files
committed
chore: harden electron security
1 parent 620c727 commit 2c7195f

8 files changed

Lines changed: 268 additions & 31 deletions

File tree

src-electron/config.js

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
/**
2+
* Centralized Configuration Module
3+
*
4+
* This module provides a single source of truth for all configuration values.
5+
* It reads from package.json and can apply stage-wise transforms as needed.
6+
*
7+
* Usage:
8+
* const { stage, trustedElectronDomains, productName } = require('./config');
9+
*/
10+
11+
const packageJson = require('./package.json');
12+
13+
// Core package.json values
14+
const name = packageJson.name;
15+
const identifier = packageJson.identifier;
16+
const stage = packageJson.stage;
17+
const version = packageJson.version;
18+
const productName = packageJson.productName;
19+
const description = packageJson.description;
20+
21+
// Security configuration
22+
const trustedElectronDomains = packageJson.trustedElectronDomains || [];
23+
24+
/**
25+
* Initialize configuration (call once at app startup if needed).
26+
* Currently a no-op but can be extended for async config loading,
27+
* environment variable overrides, or stage-wise transforms.
28+
*/
29+
function initConfig() {
30+
// Future: Add stage-wise transforms, env overrides, etc.
31+
// Example:
32+
// if (stage === 'prod') {
33+
// // Apply production-specific config
34+
// }
35+
}
36+
37+
module.exports = {
38+
// Package info
39+
name,
40+
identifier,
41+
stage,
42+
version,
43+
productName,
44+
description,
45+
46+
// Security
47+
trustedElectronDomains,
48+
49+
// Initialization
50+
initConfig
51+
};

src-electron/ipc-security.js

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
/**
2+
* IPC Security - Trusted Domain Validation
3+
*
4+
* This module implements security measures to ensure Electron APIs are only
5+
* accessible from trusted origins. Trust is evaluated at window load/navigation
6+
* time (not on every IPC call) for optimal performance.
7+
*
8+
* Trust rules:
9+
* - Dev stage: trustedElectronDomains + all localhost URLs
10+
* - Other stages (staging/prod): only trustedElectronDomains
11+
*/
12+
13+
const { stage, trustedElectronDomains } = require('./config');
14+
15+
// Track trusted webContents IDs (Set for O(1) lookup)
16+
const _trustedWebContents = new Set();
17+
18+
/**
19+
* Check if a URL is trusted based on stage configuration.
20+
* - Dev stage: trustedElectronDomains + all localhost URLs
21+
* - Other stages: only trustedElectronDomains
22+
*/
23+
function _isTrustedOrigin(url) {
24+
if (!url) return false;
25+
26+
// Check against trustedElectronDomains
27+
for (const domain of trustedElectronDomains) {
28+
if (url.startsWith(domain)) {
29+
return true;
30+
}
31+
}
32+
33+
// In dev stage, also allow localhost URLs
34+
if (stage === 'dev') {
35+
try {
36+
const parsed = new URL(url);
37+
if (parsed.hostname === 'localhost' || parsed.hostname === '127.0.0.1') {
38+
return true;
39+
}
40+
} catch {
41+
return false;
42+
}
43+
}
44+
45+
return false;
46+
}
47+
48+
/**
49+
* Mark a webContents as trusted/untrusted based on its current URL.
50+
* Call this when window loads or navigates.
51+
*/
52+
function updateTrustStatus(webContents) {
53+
const url = webContents.getURL();
54+
if (_isTrustedOrigin(url)) {
55+
_trustedWebContents.add(webContents.id);
56+
} else {
57+
_trustedWebContents.delete(webContents.id);
58+
}
59+
}
60+
61+
/**
62+
* Remove trust tracking when webContents is destroyed.
63+
*/
64+
function cleanupTrust(webContentsId) {
65+
_trustedWebContents.delete(webContentsId);
66+
}
67+
68+
/**
69+
* Fast check if webContents is trusted (O(1) lookup).
70+
*/
71+
function _isWebContentsTrusted(webContentsId) {
72+
return _trustedWebContents.has(webContentsId);
73+
}
74+
75+
/**
76+
* Assert that IPC event comes from trusted webContents.
77+
* Throws error if not trusted.
78+
*/
79+
function assertTrusted(event) {
80+
if (!_isWebContentsTrusted(event.sender.id)) {
81+
const url = event.senderFrame?.url || event.sender.getURL() || 'unknown';
82+
throw new Error(`Blocked IPC from untrusted origin: ${url}`);
83+
}
84+
}
85+
86+
module.exports = {
87+
updateTrustStatus,
88+
cleanupTrust,
89+
assertTrusted
90+
};

src-electron/main-app-ipc.js

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,8 @@ const { app, ipcMain } = require('electron');
1010
const { spawn } = require('child_process');
1111
const readline = require('readline');
1212
const path = require('path');
13-
const { productName } = require('./package.json');
13+
const { productName } = require('./config');
14+
const { assertTrusted } = require('./ipc-security');
1415

1516
let processInstanceId = 0;
1617

@@ -73,6 +74,7 @@ function registerAppIpcHandlers() {
7374
// Spawn a child process and forward stdio to the calling renderer.
7475
// Returns an instanceId so the renderer can target the correct process.
7576
ipcMain.handle('spawn-process', async (event, command, args) => {
77+
assertTrusted(event);
7678
const instanceId = ++processInstanceId;
7779
const sender = event.sender;
7880
console.log(`Spawning: ${command} ${args.join(' ')} (instance ${instanceId})`);
@@ -122,35 +124,41 @@ function registerAppIpcHandlers() {
122124

123125
// Write data to a specific spawned process stdin
124126
ipcMain.handle('write-to-process', (event, instanceId, data) => {
127+
assertTrusted(event);
125128
const instance = spawnedProcesses.get(instanceId);
126129
if (instance && !instance.terminated) {
127130
instance.process.stdin.write(data);
128131
}
129132
});
130133

131134
ipcMain.handle('quit-app', (event, exitCode) => {
135+
assertTrusted(event);
132136
console.log('Quit requested with exit code:', exitCode);
133137
// This will be handled by the main module's gracefulShutdown
134138
app.emit('quit-requested', exitCode);
135139
});
136140

137141
ipcMain.on('console-log', (event, message) => {
142+
assertTrusted(event);
138143
console.log('Renderer:', message);
139144
});
140145

141146
// CLI args (mirrors Tauri's cli.getMatches for --quit-when-done / -q)
142147
// Filter out internal Electron args (main.js in dev mode)
143-
ipcMain.handle('get-cli-args', () => {
148+
ipcMain.handle('get-cli-args', (event) => {
149+
assertTrusted(event);
144150
return filterCliArgs(process.argv);
145151
});
146152

147153
// App path (repo root when running from source)
148-
ipcMain.handle('get-app-path', () => {
154+
ipcMain.handle('get-app-path', (event) => {
155+
assertTrusted(event);
149156
return app.getAppPath();
150157
});
151158

152159
// App name from package.json
153-
ipcMain.handle('get-app-name', () => {
160+
ipcMain.handle('get-app-name', (event) => {
161+
assertTrusted(event);
154162
return productName;
155163
});
156164
}

src-electron/main-cred-ipc.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
const { ipcMain } = require('electron');
22
const crypto = require('crypto');
3+
const { assertTrusted } = require('./ipc-security');
34

45
// Per-window AES trust map (mirrors Tauri's WindowAesTrust)
56
// Uses webContents.id which persists across page reloads but changes when window is destroyed
@@ -18,6 +19,7 @@ const PHOENIX_CRED_PREFIX = 'phcode_electron_';
1819
function registerCredIpcHandlers() {
1920
// Trust window AES key - can only be called once per window
2021
ipcMain.handle('trust-window-aes-key', (event, key, iv) => {
22+
assertTrusted(event);
2123
const webContentsId = event.sender.id;
2224

2325
if (windowTrustMap.has(webContentsId)) {
@@ -39,6 +41,7 @@ function registerCredIpcHandlers() {
3941

4042
// Remove trust - requires matching key/iv
4143
ipcMain.handle('remove-trust-window-aes-key', (event, key, iv) => {
44+
assertTrusted(event);
4245
const webContentsId = event.sender.id;
4346
const stored = windowTrustMap.get(webContentsId);
4447

@@ -55,6 +58,7 @@ function registerCredIpcHandlers() {
5558

5659
// Store credential in system keychain
5760
ipcMain.handle('store-credential', async (event, scopeName, secretVal) => {
61+
assertTrusted(event);
5862
if (!keytar) {
5963
throw new Error('keytar module not available.');
6064
}
@@ -64,6 +68,7 @@ function registerCredIpcHandlers() {
6468

6569
// Get credential (encrypted with window's AES key)
6670
ipcMain.handle('get-credential', async (event, scopeName) => {
71+
assertTrusted(event);
6772
if (!keytar) {
6873
throw new Error('keytar module not available.');
6974
}
@@ -93,6 +98,7 @@ function registerCredIpcHandlers() {
9398

9499
// Delete credential from system keychain
95100
ipcMain.handle('delete-credential', async (event, scopeName) => {
101+
assertTrusted(event);
96102
if (!keytar) {
97103
throw new Error('keytar module not available.');
98104
}

src-electron/main-fs-ipc.js

Lines changed: 42 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,8 @@ const { ipcMain, dialog, BrowserWindow } = require('electron');
1010
const path = require('path');
1111
const fsp = require('fs/promises');
1212
const os = require('os');
13-
const { identifier: APP_IDENTIFIER } = require('./package.json');
13+
const { identifier: APP_IDENTIFIER } = require('./config');
14+
const { assertTrusted } = require('./ipc-security');
1415

1516
// Electron IPC only preserves Error.message when errors cross the IPC boundary (see
1617
// https://github.com/electron/electron/issues/24427). To preserve error.code for FS
@@ -47,25 +48,32 @@ function getAppDataDir() {
4748

4849
function registerFsIpcHandlers() {
4950
// Directory APIs
50-
ipcMain.handle('get-documents-dir', () => {
51+
ipcMain.handle('get-documents-dir', (event) => {
52+
assertTrusted(event);
5153
// Match Tauri's documentDir which ends with a trailing slash
5254
return path.join(os.homedir(), 'Documents') + path.sep;
5355
});
5456

55-
ipcMain.handle('get-home-dir', () => {
57+
ipcMain.handle('get-home-dir', (event) => {
58+
assertTrusted(event);
5659
// Match Tauri's homeDir which ends with a trailing slash
5760
const home = os.homedir();
5861
return home.endsWith(path.sep) ? home : home + path.sep;
5962
});
6063

61-
ipcMain.handle('get-temp-dir', () => {
64+
ipcMain.handle('get-temp-dir', (event) => {
65+
assertTrusted(event);
6266
return os.tmpdir();
6367
});
6468

65-
ipcMain.handle('get-app-data-dir', () => getAppDataDir());
69+
ipcMain.handle('get-app-data-dir', (event) => {
70+
assertTrusted(event);
71+
return getAppDataDir();
72+
});
6673

6774
// Get Windows drive letters (returns null on non-Windows platforms)
68-
ipcMain.handle('get-windows-drives', async () => {
75+
ipcMain.handle('get-windows-drives', async (event) => {
76+
assertTrusted(event);
6977
if (process.platform !== 'win32') {
7078
return null;
7179
}
@@ -86,26 +94,30 @@ function registerFsIpcHandlers() {
8694

8795
// Dialogs
8896
ipcMain.handle('show-open-dialog', async (event, options) => {
97+
assertTrusted(event);
8998
const win = BrowserWindow.fromWebContents(event.sender);
9099
const result = await dialog.showOpenDialog(win, options);
91100
return result.filePaths;
92101
});
93102

94103
ipcMain.handle('show-save-dialog', async (event, options) => {
104+
assertTrusted(event);
95105
const win = BrowserWindow.fromWebContents(event.sender);
96106
const result = await dialog.showSaveDialog(win, options);
97107
return result.filePath;
98108
});
99109

100110
// FS operations
101111
ipcMain.handle('fs-readdir', async (event, dirPath) => {
112+
assertTrusted(event);
102113
return fsResult(
103114
fsp.readdir(dirPath, { withFileTypes: true })
104115
.then(entries => entries.map(e => ({ name: e.name, isDirectory: e.isDirectory() })))
105116
);
106117
});
107118

108119
ipcMain.handle('fs-stat', async (event, filePath) => {
120+
assertTrusted(event);
109121
return fsResult(
110122
fsp.stat(filePath).then(stats => ({
111123
isFile: stats.isFile(),
@@ -122,12 +134,30 @@ function registerFsIpcHandlers() {
122134
);
123135
});
124136

125-
ipcMain.handle('fs-mkdir', (event, dirPath, options) => fsResult(fsp.mkdir(dirPath, options)));
126-
ipcMain.handle('fs-unlink', (event, filePath) => fsResult(fsp.unlink(filePath)));
127-
ipcMain.handle('fs-rmdir', (event, dirPath, options) => fsResult(fsp.rm(dirPath, options)));
128-
ipcMain.handle('fs-rename', (event, oldPath, newPath) => fsResult(fsp.rename(oldPath, newPath)));
129-
ipcMain.handle('fs-read-file', (event, filePath) => fsResult(fsp.readFile(filePath)));
130-
ipcMain.handle('fs-write-file', (event, filePath, data) => fsResult(fsp.writeFile(filePath, Buffer.from(data))));
137+
ipcMain.handle('fs-mkdir', (event, dirPath, options) => {
138+
assertTrusted(event);
139+
return fsResult(fsp.mkdir(dirPath, options));
140+
});
141+
ipcMain.handle('fs-unlink', (event, filePath) => {
142+
assertTrusted(event);
143+
return fsResult(fsp.unlink(filePath));
144+
});
145+
ipcMain.handle('fs-rmdir', (event, dirPath, options) => {
146+
assertTrusted(event);
147+
return fsResult(fsp.rm(dirPath, options));
148+
});
149+
ipcMain.handle('fs-rename', (event, oldPath, newPath) => {
150+
assertTrusted(event);
151+
return fsResult(fsp.rename(oldPath, newPath));
152+
});
153+
ipcMain.handle('fs-read-file', (event, filePath) => {
154+
assertTrusted(event);
155+
return fsResult(fsp.readFile(filePath));
156+
});
157+
ipcMain.handle('fs-write-file', (event, filePath, data) => {
158+
assertTrusted(event);
159+
return fsResult(fsp.writeFile(filePath, Buffer.from(data)));
160+
});
131161
}
132162

133163
module.exports = {

0 commit comments

Comments
 (0)