Skip to content

Commit 7f2a27a

Browse files
committed
feat: electon security hardern and impl parity with phoenix edge
1 parent d135eb2 commit 7f2a27a

6 files changed

Lines changed: 259 additions & 22 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: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
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+
isTrustedOrigin,
88+
updateTrustStatus,
89+
cleanupTrust,
90+
assertTrusted
91+
};

src-electron/main-app-ipc.js

Lines changed: 48 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,42 @@
1+
/**
2+
* IPC handlers for electronAppAPI
3+
* Preload location: contextBridge.exposeInMainWorld('electronAppAPI', { ... })
4+
*
5+
* NOTE: This file is copied from phoenix-fs library. Do not modify without
6+
* updating the source library. Only add new Phoenix-specific handlers to main-window-ipc.js.
7+
*/
8+
19
const { app, ipcMain } = require('electron');
210
const { spawn } = require('child_process');
311
const readline = require('readline');
4-
const { productName } = require('./package.json');
12+
const path = require('path');
13+
const { productName } = require('./config');
14+
const { assertTrusted } = require('./ipc-security');
515

616
let processInstanceId = 0;
17+
18+
// Path to main.js - used to filter it out from CLI args in dev mode
19+
const mainScriptPath = path.resolve(__dirname, 'main.js');
20+
21+
/**
22+
* Filter CLI args to remove internal Electron arguments.
23+
* In dev mode, process.argv includes: [electron, main.js, ...userArgs]
24+
* In production, it includes: [app, ...userArgs]
25+
* This function filters out the main.js entry point in dev mode.
26+
*/
27+
function filterCliArgs(args) {
28+
if (!args || args.length === 0) {
29+
return args;
30+
}
31+
32+
const normalizedMainScript = mainScriptPath.toLowerCase();
33+
34+
return args.filter(arg => {
35+
// Resolve to handle both absolute and relative paths
36+
const resolvedArg = path.resolve(arg).toLowerCase();
37+
return resolvedArg !== normalizedMainScript;
38+
});
39+
}
740
// Map of instanceId -> { process, terminated }
841
const spawnedProcesses = new Map();
942

@@ -41,6 +74,7 @@ function registerAppIpcHandlers() {
4174
// Spawn a child process and forward stdio to the calling renderer.
4275
// Returns an instanceId so the renderer can target the correct process.
4376
ipcMain.handle('spawn-process', async (event, command, args) => {
77+
assertTrusted(event);
4478
const instanceId = ++processInstanceId;
4579
const sender = event.sender;
4680
console.log(`Spawning: ${command} ${args.join(' ')} (instance ${instanceId})`);
@@ -90,39 +124,47 @@ function registerAppIpcHandlers() {
90124

91125
// Write data to a specific spawned process stdin
92126
ipcMain.handle('write-to-process', (event, instanceId, data) => {
127+
assertTrusted(event);
93128
const instance = spawnedProcesses.get(instanceId);
94129
if (instance && !instance.terminated) {
95130
instance.process.stdin.write(data);
96131
}
97132
});
98133

99134
ipcMain.handle('quit-app', (event, exitCode) => {
135+
assertTrusted(event);
100136
console.log('Quit requested with exit code:', exitCode);
101137
// This will be handled by the main module's gracefulShutdown
102138
app.emit('quit-requested', exitCode);
103139
});
104140

105141
ipcMain.on('console-log', (event, message) => {
142+
assertTrusted(event);
106143
console.log('Renderer:', message);
107144
});
108145

109146
// CLI args (mirrors Tauri's cli.getMatches for --quit-when-done / -q)
110-
ipcMain.handle('get-cli-args', () => {
111-
return process.argv;
147+
// Filter out internal Electron args (main.js in dev mode)
148+
ipcMain.handle('get-cli-args', (event) => {
149+
assertTrusted(event);
150+
return filterCliArgs(process.argv);
112151
});
113152

114153
// App path (repo root when running from source)
115-
ipcMain.handle('get-app-path', () => {
154+
ipcMain.handle('get-app-path', (event) => {
155+
assertTrusted(event);
116156
return app.getAppPath();
117157
});
118158

119159
// App name from package.json
120-
ipcMain.handle('get-app-name', () => {
160+
ipcMain.handle('get-app-name', (event) => {
161+
assertTrusted(event);
121162
return productName;
122163
});
123164
}
124165

125166
module.exports = {
126167
registerAppIpcHandlers,
127-
terminateAllProcesses
168+
terminateAllProcesses,
169+
filterCliArgs
128170
};

src-electron/main-fs-ipc.js

Lines changed: 50 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,17 @@
1+
/**
2+
* IPC handlers for electronFSAPI
3+
* Preload location: contextBridge.exposeInMainWorld('electronFSAPI', { ... })
4+
*
5+
* NOTE: This file is copied from phoenix-fs library. Do not modify without
6+
* updating the source library. Only add new Phoenix-specific handlers to main-window-ipc.js.
7+
*/
8+
19
const { ipcMain, dialog, BrowserWindow } = require('electron');
210
const path = require('path');
311
const fsp = require('fs/promises');
412
const os = require('os');
5-
const { identifier: APP_IDENTIFIER } = require('./package.json');
13+
const { identifier: APP_IDENTIFIER } = require('./config');
14+
const { assertTrusted } = require('./ipc-security');
615

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

4049
function registerFsIpcHandlers() {
4150
// Directory APIs
42-
ipcMain.handle('get-documents-dir', () => {
51+
ipcMain.handle('get-documents-dir', (event) => {
52+
assertTrusted(event);
4353
// Match Tauri's documentDir which ends with a trailing slash
4454
return path.join(os.homedir(), 'Documents') + path.sep;
4555
});
4656

47-
ipcMain.handle('get-home-dir', () => {
57+
ipcMain.handle('get-home-dir', (event) => {
58+
assertTrusted(event);
4859
// Match Tauri's homeDir which ends with a trailing slash
4960
const home = os.homedir();
5061
return home.endsWith(path.sep) ? home : home + path.sep;
5162
});
5263

53-
ipcMain.handle('get-temp-dir', () => {
64+
ipcMain.handle('get-temp-dir', (event) => {
65+
assertTrusted(event);
5466
return os.tmpdir();
5567
});
5668

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

5974
// Get Windows drive letters (returns null on non-Windows platforms)
60-
ipcMain.handle('get-windows-drives', async () => {
75+
ipcMain.handle('get-windows-drives', async (event) => {
76+
assertTrusted(event);
6177
if (process.platform !== 'win32') {
6278
return null;
6379
}
@@ -78,26 +94,30 @@ function registerFsIpcHandlers() {
7894

7995
// Dialogs
8096
ipcMain.handle('show-open-dialog', async (event, options) => {
97+
assertTrusted(event);
8198
const win = BrowserWindow.fromWebContents(event.sender);
8299
const result = await dialog.showOpenDialog(win, options);
83100
return result.filePaths;
84101
});
85102

86103
ipcMain.handle('show-save-dialog', async (event, options) => {
104+
assertTrusted(event);
87105
const win = BrowserWindow.fromWebContents(event.sender);
88106
const result = await dialog.showSaveDialog(win, options);
89107
return result.filePath;
90108
});
91109

92110
// FS operations
93111
ipcMain.handle('fs-readdir', async (event, dirPath) => {
112+
assertTrusted(event);
94113
return fsResult(
95114
fsp.readdir(dirPath, { withFileTypes: true })
96115
.then(entries => entries.map(e => ({ name: e.name, isDirectory: e.isDirectory() })))
97116
);
98117
});
99118

100119
ipcMain.handle('fs-stat', async (event, filePath) => {
120+
assertTrusted(event);
101121
return fsResult(
102122
fsp.stat(filePath).then(stats => ({
103123
isFile: stats.isFile(),
@@ -114,12 +134,30 @@ function registerFsIpcHandlers() {
114134
);
115135
});
116136

117-
ipcMain.handle('fs-mkdir', (event, dirPath, options) => fsResult(fsp.mkdir(dirPath, options)));
118-
ipcMain.handle('fs-unlink', (event, filePath) => fsResult(fsp.unlink(filePath)));
119-
ipcMain.handle('fs-rmdir', (event, dirPath, options) => fsResult(fsp.rm(dirPath, options)));
120-
ipcMain.handle('fs-rename', (event, oldPath, newPath) => fsResult(fsp.rename(oldPath, newPath)));
121-
ipcMain.handle('fs-read-file', (event, filePath) => fsResult(fsp.readFile(filePath)));
122-
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+
});
123161
}
124162

125163
module.exports = {

0 commit comments

Comments
 (0)