From b8a1974a8082fc1cd23b951c4a46f44d8823227f Mon Sep 17 00:00:00 2001 From: Cholleti Pranav <119649614+pranav-cholleti@users.noreply.github.com> Date: Fri, 12 Jun 2026 22:05:02 +0530 Subject: [PATCH 1/2] =?UTF-8?q?fix:=20resolve=20issue=20#236=20=E2=80=94?= =?UTF-8?q?=20[Security]=20Arbitrary=20Executable=20Execution=20via=20Envi?= =?UTF-8?q?ronment=20Variable=20in=20`main.js`?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `resolvePythonCmd` function currently relies on the `VIRTUAL_ENV` environment variable to locate the Python executable. This unchecked dependency allows an attacker to control the `VIRTUAL_ENV` variable, pointing it to a malicious executable. When `startFlaskServer` subsequently uses this `pythonCmd` in `child_process.spawn`, it executes the attacker-controlled program, leading to arbitrary code execution. The fix involves modifying `resolvePythonCmd` to prioritize a securely bundled Python interpreter in packaged Electron applications and only permit `VIRTUAL_ENV` usage during development, thus preventing malicious environment variable injection in production. Changes: - main.js: Replaced resolvePythonCmd with a secure version that checks app.isPackaged and bundles Python in production, preventing arbitrary executable execution via VIRTUAL_ENV. --- main.js | 499 +++++++++++++++++++++++++++++--------------------------- 1 file changed, 259 insertions(+), 240 deletions(-) diff --git a/main.js b/main.js index 6c2d621..2d7c7fa 100644 --- a/main.js +++ b/main.js @@ -1,167 +1,186 @@ -const { app, BrowserWindow, dialog } = require('electron'); -const { spawn } = require('child_process'); -const path = require('path'); -const fs = require('fs'); -const http = require('http'); -const net = require('net'); - -let mainWindow; -let flaskProcess; -let activePort; -let serverReady = false; -let startupInterval = null; -let startupTimeout = null; - -const HOST = '127.0.0.1'; -const DEFAULT_PORT = 5000; -const MAX_SCAN_PORT = 5100; -const POLL_MS = 200; +const { app, BrowserWindow, dialog } = require('electron'); +const { spawn } = require('child_process'); +const path = require('path'); +const fs = require('fs'); +const http = require('http'); +const net = require('net'); + +let mainWindow; +let flaskProcess; +let activePort; +let serverReady = false; +let startupInterval = null; +let startupTimeout = null; + +const HOST = '127.0.0.1'; +const DEFAULT_PORT = 5000; +const MAX_SCAN_PORT = 5100; +const POLL_MS = 200; const STARTUP_TIMEOUT_MS = 60_000; const SAFE_EXTERNAL_PROTOCOLS = new Set(['http:', 'https:']); - -// We must preserve the python path to our bundled app or local python -function resolvePythonCmd() { - const venv = process.env.VIRTUAL_ENV; - if (venv) { - const venvPython = process.platform === 'win32' - ? path.join(venv, 'Scripts', 'python.exe') - : path.join(venv, 'bin', 'python'); - if (fs.existsSync(venvPython)) { - return venvPython; - } - } - return process.platform === 'win32' ? 'python' : 'python3'; -} - -const pythonCmd = resolvePythonCmd(); - -function parseDevShellPort(raw) { - if (raw === undefined || raw === '') { - return null; - } - const port = Number(raw); - if (!Number.isInteger(port) || port < 1 || port > 65535) { - throw new Error( - `Invalid DEVSHELL_PORT: ${JSON.stringify(raw)} (must be integer 1-65535)` - ); - } - return port; -} - -function isPortFree(port, host) { - return new Promise((resolve, reject) => { - const server = net.createServer(); - server.once('error', (err) => { - if (err.code === 'EADDRINUSE') { - resolve(false); - } else { - reject(err); - } - }); - server.once('listening', () => { - server.close(() => resolve(true)); - }); - server.listen(port, host); - }); -} - -async function resolvePort() { - const override = parseDevShellPort(process.env.DEVSHELL_PORT); - if (override !== null) { - if (!(await isPortFree(override, HOST))) { - throw new Error( - `DEVSHELL_PORT ${override} is already in use on ${HOST}` - ); - } - return override; - } - for (let p = DEFAULT_PORT; p <= MAX_SCAN_PORT; p++) { - if (await isPortFree(p, HOST)) { - return p; - } - } - throw new Error( - `No free port on ${HOST} in range ${DEFAULT_PORT}-${MAX_SCAN_PORT}` - ); -} - -function clearStartupTimers() { - if (startupInterval) { - clearInterval(startupInterval); - startupInterval = null; - } - if (startupTimeout) { - clearTimeout(startupTimeout); - startupTimeout = null; - } -} - -function showStartupError(title, message) { - clearStartupTimers(); - dialog.showErrorBox(title, message); - if (mainWindow) { - const body = `${message}\n\nIf port 5000 is in use (e.g. macOS AirPlay Receiver), unset DEVSHELL_PORT and restart, or set DEVSHELL_PORT to a free port.`; - mainWindow.loadURL( - `data:text/html,${encodeURIComponent( - `

${title}

${body}
` - )}` - ); - } -} - -function onServerReady(url) { - if (serverReady) { - return; - } - serverReady = true; - clearStartupTimers(); - console.log('Server is ready. Loading UI...'); - mainWindow.loadURL(url); -} - -function loadWhenReady(url) { - clearStartupTimers(); - serverReady = false; - - const checkServer = () => { - http.get(url, (res) => { - if (res.statusCode && res.statusCode < 500) { - onServerReady(url); - } - res.resume(); - }).on('error', () => {}); - }; - - startupInterval = setInterval(checkServer, POLL_MS); - checkServer(); - - startupTimeout = setTimeout(() => { - if (!serverReady) { - showStartupError( - 'DevShell failed to start', - 'The backend server did not respond in time. Another process may be using the port, or Flask failed to start.' - ); - app.quit(); - } - }, STARTUP_TIMEOUT_MS); -} - -function createWindow(port) { - const baseUrl = `http://${HOST}:${port}`; - - mainWindow = new BrowserWindow({ - width: 1200, - height: 800, - minWidth: 900, - minHeight: 600, - title: "DevShell", - autoHideMenuBar: true, - webPreferences: { - nodeIntegration: false, - contextIsolation: true - } - }); - + +// We must preserve the python path to our bundled app or local python +function resolvePythonCmd() { + if (app.isPackaged) { + let pythonPath; + if (process.platform === 'win32') { + pythonPath = path.join(process.resourcesPath, 'python', 'python.exe'); + } else { + pythonPath = path.join(process.resourcesPath, 'python', 'bin', 'python'); + } + if (!fs.existsSync(pythonPath)) { + console.error(`Bundled Python interpreter not found at ${pythonPath}`); + dialog.showErrorBox( + 'DevShell Error', + 'Bundled Python resources are missing. Please reinstall the application.' + ); + app.quit(); + } + return pythonPath; + } else { + // Development mode: retain existing logic for VIRTUAL_ENV + const venv = process.env.VIRTUAL_ENV; + if (venv) { + const venvPython = process.platform === 'win32' + ? path.join(venv, 'Scripts', 'python.exe') + : path.join(venv, 'bin', 'python'); + if (fs.existsSync(venvPython)) { + return venvPython; + } + } + return process.platform === 'win32' ? 'python' : 'python3'; + } +} + +const pythonCmd = resolvePythonCmd(); + +function parseDevShellPort(raw) { + if (raw === undefined || raw === '') { + return null; + } + const port = Number(raw); + if (!Number.isInteger(port) || port < 1 || port > 65535) { + throw new Error( + `Invalid DEVSHELL_PORT: ${JSON.stringify(raw)} (must be integer 1-65535)` + ); + } + return port; +} + +function isPortFree(port, host) { + return new Promise((resolve, reject) => { + const server = net.createServer(); + server.once('error', (err) => { + if (err.code === 'EADDRINUSE') { + resolve(false); + } else { + reject(err); + } + }); + server.once('listening', () => { + server.close(() => resolve(true)); + }); + server.listen(port, host); + }); +} + +async function resolvePort() { + const override = parseDevShellPort(process.env.DEVSHELL_PORT); + if (override !== null) { + if (!(await isPortFree(override, HOST))) { + throw new Error( + `DEVSHELL_PORT ${override} is already in use on ${HOST}` + ); + } + return override; + } + for (let p = DEFAULT_PORT; p <= MAX_SCAN_PORT; p++) { + if (await isPortFree(p, HOST)) { + return p; + } + } + throw new Error( + `No free port on ${HOST} in range ${DEFAULT_PORT}-${MAX_SCAN_PORT}` + ); +} + +function clearStartupTimers() { + if (startupInterval) { + clearInterval(startupInterval); + startupInterval = null; + } + if (startupTimeout) { + clearTimeout(startupTimeout); + startupTimeout = null; + } +} + +function showStartupError(title, message) { + clearStartupTimers(); + dialog.showErrorBox(title, message); + if (mainWindow) { + const body = `${message}\n\nIf port 5000 is in use (e.g. macOS AirPlay Receiver), unset DEVSHELL_PORT and restart, or set DEVSHELL_PORT to a free port.`; + mainWindow.loadURL( + `data:text/html,${encodeURIComponent( + `

${title}

${body}
` + )}` + ); + } +} + +function onServerReady(url) { + if (serverReady) { + return; + } + serverReady = true; + clearStartupTimers(); + console.log('Server is ready. Loading UI...'); + mainWindow.loadURL(url); +} + +function loadWhenReady(url) { + clearStartupTimers(); + serverReady = false; + + const checkServer = () => { + http.get(url, (res) => { + if (res.statusCode && res.statusCode < 500) { + onServerReady(url); + } + res.resume(); + }).on('error', () => {}); + }; + + startupInterval = setInterval(checkServer, POLL_MS); + checkServer(); + + startupTimeout = setTimeout(() => { + if (!serverReady) { + showStartupError( + 'DevShell failed to start', + 'The backend server did not respond in time. Another process may be using the port, or Flask failed to start.' + ); + app.quit(); + } + }, STARTUP_TIMEOUT_MS); +} + +function createWindow(port) { + const baseUrl = `http://${HOST}:${port}`; + + mainWindow = new BrowserWindow({ + width: 1200, + height: 800, + minWidth: 900, + minHeight: 600, + title: "DevShell", + autoHideMenuBar: true, + webPreferences: { + nodeIntegration: false, + contextIsolation: true + } + }); + mainWindow.webContents.setWindowOpenHandler(({ url }) => { let target; try { @@ -180,81 +199,81 @@ function createWindow(port) { return { action: 'deny' }; }); - - loadWhenReady(baseUrl); - - mainWindow.on('closed', function () { - mainWindow = null; - }); -} - -function startFlaskServer(port) { - console.log(`Starting Python server on ${HOST}:${port} (using ${pythonCmd})`); - - flaskProcess = spawn(pythonCmd, ['app.py'], { - cwd: __dirname, - env: { - ...process.env, - DEVSHELL_PORT: String(port), - DEV_SHELL_DATA_DIR: app.getPath('userData'), - } - }); - - flaskProcess.on('error', (err) => { - showStartupError( - 'DevShell failed to start', - `Failed to launch Python backend: ${err.message}. Make sure Python is installed and available as "${pythonCmd}".` - ); - app.quit(); - }); - - flaskProcess.stdout.on('data', (data) => { - console.log(`Flask: ${data}`); - }); - - flaskProcess.stderr.on('data', (data) => { - console.error(`Flask Err: ${data}`); - }); - - flaskProcess.on('close', (code) => { - console.log(`Flask process exited with code ${code}`); - if (!serverReady && code !== 0) { - showStartupError( - 'DevShell failed to start', - `The backend server exited unexpectedly (code ${code}). If Flask fails to bind even after port resolution, this error is shown instead of polling forever.` - ); - app.quit(); - } - }); -} - -app.whenReady().then(async () => { - try { - const port = await resolvePort(); - activePort = port; - startFlaskServer(port); - createWindow(port); - } catch (err) { - showStartupError('DevShell failed to start', err.message); - app.quit(); - } -}); - -app.on('window-all-closed', function () { - if (process.platform !== 'darwin') { - app.quit(); - } -}); - -app.on('activate', function () { - if (mainWindow === null && activePort) { - createWindow(activePort); - } -}); - -app.on('will-quit', () => { - clearStartupTimers(); - if (flaskProcess) { - flaskProcess.kill(); - } -}); + + loadWhenReady(baseUrl); + + mainWindow.on('closed', function () { + mainWindow = null; + }); +} + +function startFlaskServer(port) { + console.log(`Starting Python server on ${HOST}:${port} (using ${pythonCmd})`); + + flaskProcess = spawn(pythonCmd, ['app.py'], { + cwd: __dirname, + env: { + ...process.env, + DEVSHELL_PORT: String(port), + DEV_SHELL_DATA_DIR: app.getPath('userData'), + } + }); + + flaskProcess.on('error', (err) => { + showStartupError( + 'DevShell failed to start', + `Failed to launch Python backend: ${err.message}. Make sure Python is installed and available as "${pythonCmd}".` + ); + app.quit(); + }); + + flaskProcess.stdout.on('data', (data) => { + console.log(`Flask: ${data}`); + }); + + flaskProcess.stderr.on('data', (data) => { + console.error(`Flask Err: ${data}`); + }); + + flaskProcess.on('close', (code) => { + console.log(`Flask process exited with code ${code}`); + if (!serverReady && code !== 0) { + showStartupError( + 'DevShell failed to start', + `The backend server exited unexpectedly (code ${code}). If Flask fails to bind even after port resolution, this error is shown instead of polling forever.` + ); + app.quit(); + } + }); +} + +app.whenReady().then(async () => { + try { + const port = await resolvePort(); + activePort = port; + startFlaskServer(port); + createWindow(port); + } catch (err) { + showStartupError('DevShell failed to start', err.message); + app.quit(); + } +}); + +app.on('window-all-closed', function () { + if (process.platform !== 'darwin') { + app.quit(); + } +}); + +app.on('activate', function () { + if (mainWindow === null && activePort) { + createWindow(activePort); + } +}); + +app.on('will-quit', () => { + clearStartupTimers(); + if (flaskProcess) { + flaskProcess.kill(); + } +}); From e98651f821d541948bb686fa2cce26aecf9518f0 Mon Sep 17 00:00:00 2001 From: Cholleti Pranav <119649614+pranav-cholleti@users.noreply.github.com> Date: Fri, 12 Jun 2026 22:20:10 +0530 Subject: [PATCH 2/2] refactor: apply AI suggestion on PR #242 for main.js --- main.js | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/main.js b/main.js index 2d7c7fa..9157cce 100644 --- a/main.js +++ b/main.js @@ -22,12 +22,10 @@ const SAFE_EXTERNAL_PROTOCOLS = new Set(['http:', 'https:']); // We must preserve the python path to our bundled app or local python function resolvePythonCmd() { if (app.isPackaged) { - let pythonPath; - if (process.platform === 'win32') { - pythonPath = path.join(process.resourcesPath, 'python', 'python.exe'); - } else { - pythonPath = path.join(process.resourcesPath, 'python', 'bin', 'python'); - } + const pythonDir = path.join(process.resourcesPath, 'python'); + const pythonPath = process.platform === 'win32' + ? path.join(pythonDir, 'python.exe') + : path.join(pythonDir, 'bin', 'python'); if (!fs.existsSync(pythonPath)) { console.error(`Bundled Python interpreter not found at ${pythonPath}`); dialog.showErrorBox(