fix(security): harden Windows child process spawning

This commit is contained in:
Peter Steinberger
2026-02-15 03:24:21 +01:00
parent 7b697d6128
commit a7eb0dd9a5
7 changed files with 29 additions and 9 deletions

View File

@@ -14,6 +14,7 @@ Docs: https://docs.openclaw.ai
### Fixes
- Security/Windows: avoid shell invocation when spawning child processes to prevent cmd.exe metacharacter injection via untrusted CLI arguments (e.g. agent prompt text).
- Agents: deliver tool result media (screenshots, images, audio) to channels regardless of verbose level. (#11735) Thanks @strelov1.
- Telegram: when `channels.telegram.commands.native` is `false`, exclude plugin commands from `setMyCommands` menu registration while keeping plugin slash handlers callable. (#15132) Thanks @Glucksberg.
- LINE: return 200 OK for Developers Console "Verify" requests (`{"events":[]}`) without `X-Line-Signature`, while still requiring signatures for real deliveries. (#16582) Thanks @arosstale.

View File

@@ -223,7 +223,6 @@ const runOnce = (entry, extraArgs = []) =>
const child = spawn(pnpm, args, {
stdio: "inherit",
env: { ...process.env, VITEST_GROUP: entry.name, NODE_OPTIONS: nextNodeOptions },
shell: process.platform === "win32",
});
children.add(child);
child.on("exit", (code, signal) => {
@@ -277,7 +276,6 @@ if (passthroughArgs.length > 0) {
const child = spawn(pnpm, args, {
stdio: "inherit",
env: { ...process.env, NODE_OPTIONS: nextNodeOptions },
shell: process.platform === "win32",
});
children.add(child);
child.on("exit", (exitCode, signal) => {

View File

@@ -55,7 +55,6 @@ function run(cmd, args) {
cwd: uiDir,
stdio: "inherit",
env: process.env,
shell: process.platform === "win32",
});
child.on("exit", (code, signal) => {
if (signal) {
@@ -70,7 +69,6 @@ function runSync(cmd, args, envOverride) {
cwd: uiDir,
stdio: "inherit",
env: envOverride ?? process.env,
shell: process.platform === "win32",
});
if (result.signal) {
process.exit(1);

View File

@@ -107,7 +107,6 @@ async function execLaunchctl(
try {
const { stdout, stderr } = await execFileAsync("launchctl", args, {
encoding: "utf8",
shell: process.platform === "win32",
});
return {
stdout: String(stdout ?? ""),

View File

@@ -1,7 +1,16 @@
import { describe, expect, it } from "vitest";
import { runCommandWithTimeout } from "./exec.js";
import { runCommandWithTimeout, shouldSpawnWithShell } from "./exec.js";
describe("runCommandWithTimeout", () => {
it("never enables shell execution (Windows cmd.exe injection hardening)", () => {
expect(
shouldSpawnWithShell({
resolvedCommand: "npm.cmd",
platform: "win32",
}),
).toBe(false);
});
it("passes env overrides to child", async () => {
const result = await runCommandWithTimeout(
[process.execPath, "-e", 'process.stdout.write(process.env.OPENCLAW_TEST_ENV ?? "")'],

View File

@@ -29,6 +29,19 @@ function resolveCommand(command: string): string {
return command;
}
export function shouldSpawnWithShell(params: {
resolvedCommand: string;
platform: NodeJS.Platform;
}): boolean {
// SECURITY: never enable `shell` for argv-based execution.
// `shell` routes through cmd.exe on Windows, which turns untrusted argv values
// (like chat prompts passed as CLI args) into command-injection primitives.
// If you need a shell, use an explicit shell-wrapper argv (e.g. `cmd.exe /c ...`)
// and validate/escape at the call site.
void params;
return false;
}
// Simple promise-wrapped execFile with optional verbosity logging.
export async function runExec(
command: string,
@@ -117,14 +130,14 @@ export async function runCommandWithTimeout(
const stdio = resolveCommandStdio({ hasInput, preferInherit: true });
const resolvedCommand = resolveCommand(argv[0] ?? "");
const commandExt = path.extname(resolvedCommand).toLowerCase();
const useShell = process.platform === "win32" && commandExt !== ".exe";
const child = spawn(resolvedCommand, argv.slice(1), {
stdio,
cwd,
env: resolvedEnv,
windowsVerbatimArguments,
shell: useShell,
...(shouldSpawnWithShell({ resolvedCommand, platform: process.platform })
? { shell: true }
: {}),
});
// Spawn with inherited stdin (TTY) so tools like `pi` stay interactive when needed.
return await new Promise((resolve, reject) => {

View File

@@ -107,6 +107,8 @@ export function createLocalShellRunner(deps: LocalShellDeps) {
await new Promise<void>((resolve) => {
const child = spawnCommand(cmd, {
// Intentionally a shell: this is an operator-only local TUI feature (prefixed with `!`)
// and is gated behind an explicit in-session approval prompt.
shell: true,
cwd: getCwd(),
env,