From 73fd89c705b2bcc6f8d8b53bc07bab685c0cb3a3 Mon Sep 17 00:00:00 2001 From: abose Date: Thu, 29 Jan 2026 11:02:34 +0530 Subject: [PATCH] feat: electon security hardern and impl parity with phoenix edge --- src-electron/config.js | 51 ++++++++++++++++++++ src-electron/ipc-security.js | 91 ++++++++++++++++++++++++++++++++++++ src-electron/main-app-ipc.js | 54 ++++++++++++++++++--- src-electron/main-fs-ipc.js | 62 +++++++++++++++++++----- src-electron/main.js | 21 +++++++-- src-electron/package.json | 2 + 6 files changed, 259 insertions(+), 22 deletions(-) create mode 100644 src-electron/config.js create mode 100644 src-electron/ipc-security.js diff --git a/src-electron/config.js b/src-electron/config.js new file mode 100644 index 0000000..4d3047f --- /dev/null +++ b/src-electron/config.js @@ -0,0 +1,51 @@ +/** + * Centralized Configuration Module + * + * This module provides a single source of truth for all configuration values. + * It reads from package.json and can apply stage-wise transforms as needed. + * + * Usage: + * const { stage, trustedElectronDomains, productName } = require('./config'); + */ + +const packageJson = require('./package.json'); + +// Core package.json values +const name = packageJson.name; +const identifier = packageJson.identifier; +const stage = packageJson.stage; +const version = packageJson.version; +const productName = packageJson.productName; +const description = packageJson.description; + +// Security configuration +const trustedElectronDomains = packageJson.trustedElectronDomains || []; + +/** + * Initialize configuration (call once at app startup if needed). + * Currently a no-op but can be extended for async config loading, + * environment variable overrides, or stage-wise transforms. + */ +function initConfig() { + // Future: Add stage-wise transforms, env overrides, etc. + // Example: + // if (stage === 'prod') { + // // Apply production-specific config + // } +} + +module.exports = { + // Package info + name, + identifier, + stage, + version, + productName, + description, + + // Security + trustedElectronDomains, + + // Initialization + initConfig +}; diff --git a/src-electron/ipc-security.js b/src-electron/ipc-security.js new file mode 100644 index 0000000..6d75afd --- /dev/null +++ b/src-electron/ipc-security.js @@ -0,0 +1,91 @@ +/** + * IPC Security - Trusted Domain Validation + * + * This module implements security measures to ensure Electron APIs are only + * accessible from trusted origins. Trust is evaluated at window load/navigation + * time (not on every IPC call) for optimal performance. + * + * Trust rules: + * - Dev stage: trustedElectronDomains + all localhost URLs + * - Other stages (staging/prod): only trustedElectronDomains + */ + +const { stage, trustedElectronDomains } = require('./config'); + +// Track trusted webContents IDs (Set for O(1) lookup) +const _trustedWebContents = new Set(); + +/** + * Check if a URL is trusted based on stage configuration. + * - Dev stage: trustedElectronDomains + all localhost URLs + * - Other stages: only trustedElectronDomains + */ +function isTrustedOrigin(url) { + if (!url) return false; + + // Check against trustedElectronDomains + for (const domain of trustedElectronDomains) { + if (url.startsWith(domain)) { + return true; + } + } + + // In dev stage, also allow localhost URLs + if (stage === 'dev') { + try { + const parsed = new URL(url); + if (parsed.hostname === 'localhost' || parsed.hostname === '127.0.0.1') { + return true; + } + } catch { + return false; + } + } + + return false; +} + +/** + * Mark a webContents as trusted/untrusted based on its current URL. + * Call this when window loads or navigates. + */ +function updateTrustStatus(webContents) { + const url = webContents.getURL(); + if (isTrustedOrigin(url)) { + _trustedWebContents.add(webContents.id); + } else { + _trustedWebContents.delete(webContents.id); + } +} + +/** + * Remove trust tracking when webContents is destroyed. + */ +function cleanupTrust(webContentsId) { + _trustedWebContents.delete(webContentsId); +} + +/** + * Fast check if webContents is trusted (O(1) lookup). + */ +function _isWebContentsTrusted(webContentsId) { + return _trustedWebContents.has(webContentsId); +} + +/** + * Assert that IPC event comes from trusted webContents. + * Throws error if not trusted. + */ +function assertTrusted(event) { + if (!_isWebContentsTrusted(event.sender.id)) { + const url = event.senderFrame?.url || event.sender.getURL() || 'unknown'; + throw new Error(`Blocked IPC from untrusted origin: ${url}`); + } +} + +module.exports = { + isTrustedOrigin, + updateTrustStatus, + cleanupTrust, + assertTrusted +}; diff --git a/src-electron/main-app-ipc.js b/src-electron/main-app-ipc.js index 8d4ad81..e890e2a 100644 --- a/src-electron/main-app-ipc.js +++ b/src-electron/main-app-ipc.js @@ -1,9 +1,42 @@ +/** + * IPC handlers for electronAppAPI + * Preload location: contextBridge.exposeInMainWorld('electronAppAPI', { ... }) + * + * NOTE: This file is copied from phoenix-fs library. Do not modify without + * updating the source library. Only add new Phoenix-specific handlers to main-window-ipc.js. + */ + const { app, ipcMain } = require('electron'); const { spawn } = require('child_process'); const readline = require('readline'); -const { productName } = require('./package.json'); +const path = require('path'); +const { productName } = require('./config'); +const { assertTrusted } = require('./ipc-security'); let processInstanceId = 0; + +// Path to main.js - used to filter it out from CLI args in dev mode +const mainScriptPath = path.resolve(__dirname, 'main.js'); + +/** + * Filter CLI args to remove internal Electron arguments. + * In dev mode, process.argv includes: [electron, main.js, ...userArgs] + * In production, it includes: [app, ...userArgs] + * This function filters out the main.js entry point in dev mode. + */ +function filterCliArgs(args) { + if (!args || args.length === 0) { + return args; + } + + const normalizedMainScript = mainScriptPath.toLowerCase(); + + return args.filter(arg => { + // Resolve to handle both absolute and relative paths + const resolvedArg = path.resolve(arg).toLowerCase(); + return resolvedArg !== normalizedMainScript; + }); +} // Map of instanceId -> { process, terminated } const spawnedProcesses = new Map(); @@ -41,6 +74,7 @@ function registerAppIpcHandlers() { // Spawn a child process and forward stdio to the calling renderer. // Returns an instanceId so the renderer can target the correct process. ipcMain.handle('spawn-process', async (event, command, args) => { + assertTrusted(event); const instanceId = ++processInstanceId; const sender = event.sender; console.log(`Spawning: ${command} ${args.join(' ')} (instance ${instanceId})`); @@ -90,6 +124,7 @@ function registerAppIpcHandlers() { // Write data to a specific spawned process stdin ipcMain.handle('write-to-process', (event, instanceId, data) => { + assertTrusted(event); const instance = spawnedProcesses.get(instanceId); if (instance && !instance.terminated) { instance.process.stdin.write(data); @@ -97,32 +132,39 @@ function registerAppIpcHandlers() { }); ipcMain.handle('quit-app', (event, exitCode) => { + assertTrusted(event); console.log('Quit requested with exit code:', exitCode); // This will be handled by the main module's gracefulShutdown app.emit('quit-requested', exitCode); }); ipcMain.on('console-log', (event, message) => { + assertTrusted(event); console.log('Renderer:', message); }); // CLI args (mirrors Tauri's cli.getMatches for --quit-when-done / -q) - ipcMain.handle('get-cli-args', () => { - return process.argv; + // Filter out internal Electron args (main.js in dev mode) + ipcMain.handle('get-cli-args', (event) => { + assertTrusted(event); + return filterCliArgs(process.argv); }); // App path (repo root when running from source) - ipcMain.handle('get-app-path', () => { + ipcMain.handle('get-app-path', (event) => { + assertTrusted(event); return app.getAppPath(); }); // App name from package.json - ipcMain.handle('get-app-name', () => { + ipcMain.handle('get-app-name', (event) => { + assertTrusted(event); return productName; }); } module.exports = { registerAppIpcHandlers, - terminateAllProcesses + terminateAllProcesses, + filterCliArgs }; diff --git a/src-electron/main-fs-ipc.js b/src-electron/main-fs-ipc.js index d2eeb6f..39e0861 100644 --- a/src-electron/main-fs-ipc.js +++ b/src-electron/main-fs-ipc.js @@ -1,8 +1,17 @@ +/** + * IPC handlers for electronFSAPI + * Preload location: contextBridge.exposeInMainWorld('electronFSAPI', { ... }) + * + * NOTE: This file is copied from phoenix-fs library. Do not modify without + * updating the source library. Only add new Phoenix-specific handlers to main-window-ipc.js. + */ + const { ipcMain, dialog, BrowserWindow } = require('electron'); const path = require('path'); const fsp = require('fs/promises'); const os = require('os'); -const { identifier: APP_IDENTIFIER } = require('./package.json'); +const { identifier: APP_IDENTIFIER } = require('./config'); +const { assertTrusted } = require('./ipc-security'); // Electron IPC only preserves Error.message when errors cross the IPC boundary (see // https://github.com/electron/electron/issues/24427). To preserve error.code for FS @@ -39,25 +48,32 @@ function getAppDataDir() { function registerFsIpcHandlers() { // Directory APIs - ipcMain.handle('get-documents-dir', () => { + ipcMain.handle('get-documents-dir', (event) => { + assertTrusted(event); // Match Tauri's documentDir which ends with a trailing slash return path.join(os.homedir(), 'Documents') + path.sep; }); - ipcMain.handle('get-home-dir', () => { + ipcMain.handle('get-home-dir', (event) => { + assertTrusted(event); // Match Tauri's homeDir which ends with a trailing slash const home = os.homedir(); return home.endsWith(path.sep) ? home : home + path.sep; }); - ipcMain.handle('get-temp-dir', () => { + ipcMain.handle('get-temp-dir', (event) => { + assertTrusted(event); return os.tmpdir(); }); - ipcMain.handle('get-app-data-dir', () => getAppDataDir()); + ipcMain.handle('get-app-data-dir', (event) => { + assertTrusted(event); + return getAppDataDir(); + }); // Get Windows drive letters (returns null on non-Windows platforms) - ipcMain.handle('get-windows-drives', async () => { + ipcMain.handle('get-windows-drives', async (event) => { + assertTrusted(event); if (process.platform !== 'win32') { return null; } @@ -78,12 +94,14 @@ function registerFsIpcHandlers() { // Dialogs ipcMain.handle('show-open-dialog', async (event, options) => { + assertTrusted(event); const win = BrowserWindow.fromWebContents(event.sender); const result = await dialog.showOpenDialog(win, options); return result.filePaths; }); ipcMain.handle('show-save-dialog', async (event, options) => { + assertTrusted(event); const win = BrowserWindow.fromWebContents(event.sender); const result = await dialog.showSaveDialog(win, options); return result.filePath; @@ -91,6 +109,7 @@ function registerFsIpcHandlers() { // FS operations ipcMain.handle('fs-readdir', async (event, dirPath) => { + assertTrusted(event); return fsResult( fsp.readdir(dirPath, { withFileTypes: true }) .then(entries => entries.map(e => ({ name: e.name, isDirectory: e.isDirectory() }))) @@ -98,6 +117,7 @@ function registerFsIpcHandlers() { }); ipcMain.handle('fs-stat', async (event, filePath) => { + assertTrusted(event); return fsResult( fsp.stat(filePath).then(stats => ({ isFile: stats.isFile(), @@ -114,12 +134,30 @@ function registerFsIpcHandlers() { ); }); - ipcMain.handle('fs-mkdir', (event, dirPath, options) => fsResult(fsp.mkdir(dirPath, options))); - ipcMain.handle('fs-unlink', (event, filePath) => fsResult(fsp.unlink(filePath))); - ipcMain.handle('fs-rmdir', (event, dirPath, options) => fsResult(fsp.rm(dirPath, options))); - ipcMain.handle('fs-rename', (event, oldPath, newPath) => fsResult(fsp.rename(oldPath, newPath))); - ipcMain.handle('fs-read-file', (event, filePath) => fsResult(fsp.readFile(filePath))); - ipcMain.handle('fs-write-file', (event, filePath, data) => fsResult(fsp.writeFile(filePath, Buffer.from(data)))); + ipcMain.handle('fs-mkdir', (event, dirPath, options) => { + assertTrusted(event); + return fsResult(fsp.mkdir(dirPath, options)); + }); + ipcMain.handle('fs-unlink', (event, filePath) => { + assertTrusted(event); + return fsResult(fsp.unlink(filePath)); + }); + ipcMain.handle('fs-rmdir', (event, dirPath, options) => { + assertTrusted(event); + return fsResult(fsp.rm(dirPath, options)); + }); + ipcMain.handle('fs-rename', (event, oldPath, newPath) => { + assertTrusted(event); + return fsResult(fsp.rename(oldPath, newPath)); + }); + ipcMain.handle('fs-read-file', (event, filePath) => { + assertTrusted(event); + return fsResult(fsp.readFile(filePath)); + }); + ipcMain.handle('fs-write-file', (event, filePath, data) => { + assertTrusted(event); + return fsResult(fsp.writeFile(filePath, Buffer.from(data))); + }); } module.exports = { diff --git a/src-electron/main.js b/src-electron/main.js index 2495249..a9f38bd 100644 --- a/src-electron/main.js +++ b/src-electron/main.js @@ -3,6 +3,7 @@ const path = require('path'); const { registerAppIpcHandlers, terminateAllProcesses } = require('./main-app-ipc'); const { registerFsIpcHandlers } = require('./main-fs-ipc'); +const { updateTrustStatus, cleanupTrust } = require('./ipc-security'); let mainWindow; @@ -18,15 +19,27 @@ async function createWindow() { icon: path.join(__dirname, '..', 'src-tauri', 'icons', 'icon.png') }); - // Load the test page from the http-server - mainWindow.loadURL('http://localhost:8081/test/index.html'); + const webContents = mainWindow.webContents; + const webContentsId = webContents.id; - // Open DevTools for debugging - mainWindow.webContents.openDevTools(); + // Set up trust tracking - evaluate on navigation + webContents.on('did-navigate', () => { + updateTrustStatus(webContents); + }); + webContents.on('did-navigate-in-page', () => { + updateTrustStatus(webContents); + }); mainWindow.on('closed', () => { + cleanupTrust(webContentsId); mainWindow = null; }); + + // Load the test page from the http-server + await mainWindow.loadURL('http://localhost:8081/test/index.html'); + + // Open DevTools for debugging + mainWindow.webContents.openDevTools(); } async function gracefulShutdown(exitCode = 0) { diff --git a/src-electron/package.json b/src-electron/package.json index 2cd1ecb..e3cacba 100644 --- a/src-electron/package.json +++ b/src-electron/package.json @@ -3,6 +3,8 @@ "identifier": "fs.phcode", "version": "1.0.0", "productName": "Phoenix FS", + "stage": "dev", + "trustedElectronDomains": [], "description": "Electron development shell for phoenix-fs testing", "main": "main.js", "devDependencies": {