diff --git a/packages/core/src/ide/ide-installer.ts b/packages/core/src/ide/ide-installer.ts index c34695b30ba..db91c4cf6f6 100644 --- a/packages/core/src/ide/ide-installer.ts +++ b/packages/core/src/ide/ide-installer.ts @@ -25,23 +25,44 @@ async function findCommand( command: string, platform: NodeJS.Platform = process.platform, ): Promise { - // 1. Check PATH first. + // Security: Validate the command name to prevent injection attacks. + // Only allow alphanumeric characters, hyphens, underscores, dots, and path separators. + const safeCommandPattern = /^[a-zA-Z0-9.\-_/\\]+$/; + if (!safeCommandPattern.test(command)) { + return null; + } + + // 1. Check PATH first using safe argument passing (no shell interpolation). try { if (platform === 'win32') { const result = child_process - .execSync(`where.exe ${command}`) - .toString() - .trim(); - // `where.exe` can return multiple paths. Return the first one. - const firstPath = result.split(/\r?\n/)[0]; - if (firstPath) { - return firstPath; + .spawnSync('where.exe', [command], { + encoding: 'utf-8', + stdio: ['pipe', 'pipe', 'pipe'], + shell: false, + }); + if (result.status === 0 && result.stdout) { + const firstPath = result.stdout.trim().split(/\r?\n/)[0]; + if (firstPath) { + // Sanitize: resolve to absolute path and reject null bytes + // to prevent path traversal or null byte injection from + // untrusted command output. + const resolved = path.resolve(firstPath); + if (resolved.includes('\0')) { + return null; + } + return resolved; + } } } else { - child_process.execSync(`command -v ${command}`, { - stdio: 'ignore', + const result = child_process.spawnSync('which', [command], { + encoding: 'utf-8', + stdio: ['pipe', 'pipe', 'pipe'], + shell: false, }); - return command; + if (result.status === 0) { + return command; + } } } catch { // Not in PATH, continue to check common locations. diff --git a/packages/core/src/utils/editor.ts b/packages/core/src/utils/editor.ts index 0269bd708ba..4d4cd209f46 100644 --- a/packages/core/src/utils/editor.ts +++ b/packages/core/src/utils/editor.ts @@ -4,8 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { exec, execSync, spawn, spawnSync } from 'node:child_process'; -import { promisify } from 'node:util'; +import { spawn, spawnSync } from 'node:child_process'; import { once } from 'node:events'; import { debugLogger } from './debugLogger.js'; import { coreEvents, CoreEvent, type EditorSelectedPayload } from './events.js'; @@ -102,30 +101,39 @@ interface DiffCommand { args: string[]; } -const execAsync = promisify(exec); - -function getCommandExistsCmd(cmd: string): string { - return process.platform === 'win32' - ? `where.exe ${cmd}` - : `command -v ${cmd}`; -} - function commandExists(cmd: string): boolean { try { - execSync(getCommandExistsCmd(cmd), { stdio: 'ignore' }); - return true; + // Security: Use spawnSync with argument arrays (shell: false) to prevent + // command injection via shell metacharacters in the cmd parameter. + if (process.platform === 'win32') { + const result = spawnSync('where.exe', [cmd], { + stdio: ['pipe', 'pipe', 'pipe'], + shell: false, + }); + return result.status === 0; + } else { + const result = spawnSync('which', [cmd], { + stdio: ['pipe', 'pipe', 'pipe'], + shell: false, + }); + return result.status === 0; + } } catch { return false; } } async function commandExistsAsync(cmd: string): Promise { - try { - await execAsync(getCommandExistsCmd(cmd)); - return true; - } catch { - return false; - } + return new Promise((resolve) => { + // Security: Use spawn with argument arrays (shell: false) to prevent + // command injection via shell metacharacters in the cmd parameter. + const bin = process.platform === 'win32' ? 'where.exe' : 'which'; + const child = spawn(bin, [cmd], { + stdio: ['pipe', 'pipe', 'pipe'], + }); + child.on('close', (code) => resolve(code === 0)); + child.on('error', () => resolve(false)); + }); } /**