From 420525e97d3f04617d284ec39e26835047854bde Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20Beteg=C3=B3n?= Date: Mon, 6 Apr 2026 16:18:48 +0200 Subject: [PATCH] fix(init): run commands without shell to eliminate injection surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace spawn({ shell: true }) with direct executable invocation by splitting the command string into [executable, ...args]. This eliminates shell injection as an attack vector entirely — metacharacters become harmless literal arguments. The existing validateCommand() blocklist is retained as defense-in-depth. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/lib/init/local-ops.ts | 9 +++++---- test/lib/init/local-ops.test.ts | 12 +++++++----- 2 files changed, 12 insertions(+), 9 deletions(-) diff --git a/src/lib/init/local-ops.ts b/src/lib/init/local-ops.ts index fa15e83f9..92760e692 100644 --- a/src/lib/init/local-ops.ts +++ b/src/lib/init/local-ops.ts @@ -532,8 +532,9 @@ async function runCommands( return { ok: true, data: { results } }; } -// Note: shell: true targets Unix shells. Windows cmd.exe metacharacters -// (%, ^) are not blocked; the CLI assumes a Unix Node.js environment. +// Runs the executable directly (no shell) to eliminate shell injection as an +// attack vector. The command string is split on whitespace into [exe, ...args]. +// validateCommand() still blocks metacharacters as defense-in-depth. function runSingleCommand( command: string, cwd: string, @@ -545,8 +546,8 @@ function runSingleCommand( stderr: string; }> { return new Promise((resolve) => { - const child = spawn(command, [], { - shell: true, + const [executable = "", ...args] = command.trim().split(WHITESPACE_RE); + const child = spawn(executable, args, { cwd, stdio: ["ignore", "pipe", "pipe"], timeout: timeoutMs, diff --git a/test/lib/init/local-ops.test.ts b/test/lib/init/local-ops.test.ts index f80e6e5b1..5d8d0313c 100644 --- a/test/lib/init/local-ops.test.ts +++ b/test/lib/init/local-ops.test.ts @@ -616,7 +616,7 @@ describe("handleLocalOp", () => { type: "local-op", operation: "run-commands", cwd: testDir, - params: { commands: ["echo hello"] }, + params: { commands: ["/bin/echo hello"] }, }; const result = await handleLocalOp(payload, options); @@ -664,7 +664,9 @@ describe("handleLocalOp", () => { type: "local-op", operation: "run-commands", cwd: testDir, - params: { commands: ["false", "echo should_not_run"] }, + params: { + commands: ["/usr/bin/false", "/bin/echo should_not_run"], + }, }; const result = await handleLocalOp(payload, options); @@ -675,7 +677,7 @@ describe("handleLocalOp", () => { } ).results; expect(results).toHaveLength(1); - expect(results[0].command).toBe("false"); + expect(results[0].command).toBe("/usr/bin/false"); }); test("dry-run validates commands but skips execution", async () => { @@ -697,7 +699,7 @@ describe("handleLocalOp", () => { type: "local-op", operation: "run-commands", cwd: testDir, - params: { commands: ["npm install @sentry/node", "echo hello"] }, + params: { commands: ["npm install @sentry/node", "/bin/echo hello"] }, }; const dryRunOptions = makeOptions({ dryRun: true, directory: testDir }); @@ -718,7 +720,7 @@ describe("handleLocalOp", () => { type: "local-op", operation: "run-commands", cwd: testDir, - params: { commands: ["echo hello", "rm -rf /"] }, + params: { commands: ["/bin/echo hello", "rm -rf /"] }, }; const result = await handleLocalOp(payload, options);