fix(scripts): harden Windows UI spawn behavior

This commit is contained in:
Sebastian
2026-02-16 20:48:55 -05:00
parent 742e6543c7
commit bbb5fbc71f
3 changed files with 136 additions and 39 deletions

View File

@@ -9,6 +9,9 @@ const here = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = path.resolve(here, "..");
const uiDir = path.join(repoRoot, "ui");
const WINDOWS_SHELL_EXTENSIONS = new Set([".cmd", ".bat", ".com"]);
const WINDOWS_UNSAFE_SHELL_ARG_PATTERN = /[\r\n"&|<>^%!]/;
function usage() {
// keep this tiny; it's invoked from npm scripts too
process.stderr.write("Usage: node scripts/ui.js <install|dev|build|test> [...args]\n");
@@ -50,14 +53,52 @@ function resolveRunner() {
return null;
}
function run(cmd, args) {
const isWindows = process.platform === "win32"; // Windows support
const child = spawn(cmd, args, {
export function shouldUseShellForCommand(cmd, platform = process.platform) {
if (platform !== "win32") {
return false;
}
const extension = path.extname(cmd).toLowerCase();
return WINDOWS_SHELL_EXTENSIONS.has(extension);
}
export function assertSafeWindowsShellArgs(args, platform = process.platform) {
if (platform !== "win32") {
return;
}
const unsafeArg = args.find((arg) => WINDOWS_UNSAFE_SHELL_ARG_PATTERN.test(arg));
if (!unsafeArg) {
return;
}
// SECURITY: `shell: true` routes through cmd.exe; reject risky metacharacters
// in forwarded args to prevent shell control-flow/env-expansion injection.
throw new Error(
`Unsafe Windows shell argument: ${unsafeArg}. Remove shell metacharacters (" & | < > ^ % !).`,
);
}
function createSpawnOptions(cmd, args, envOverride) {
const useShell = shouldUseShellForCommand(cmd);
if (useShell) {
assertSafeWindowsShellArgs(args);
}
return {
cwd: uiDir,
stdio: "inherit",
env: process.env,
shell: isWindows,
});
env: envOverride ?? process.env,
...(useShell ? { shell: true } : {}),
};
}
function run(cmd, args) {
let child;
try {
child = spawn(cmd, args, createSpawnOptions(cmd, args));
} catch (err) {
console.error(`Failed to launch ${cmd}:`, err);
process.exit(1);
return;
}
child.on("error", (err) => {
console.error(`Failed to launch ${cmd}:`, err);
process.exit(1);
@@ -70,13 +111,14 @@ function run(cmd, args) {
}
function runSync(cmd, args, envOverride) {
const isWindows = process.platform === "win32"; // Windows support
const result = spawnSync(cmd, args, {
cwd: uiDir,
stdio: "inherit",
env: envOverride ?? process.env,
shell: isWindows,
});
let result;
try {
result = spawnSync(cmd, args, createSpawnOptions(cmd, args, envOverride));
} catch (err) {
console.error(`Failed to launch ${cmd}:`, err);
process.exit(1);
return;
}
if (result.signal) {
process.exit(1);
}
@@ -101,42 +143,61 @@ function depsInstalled(kind) {
}
}
const [, , action, ...rest] = process.argv;
if (!action) {
usage();
process.exit(2);
function resolveScriptAction(action) {
if (action === "install") {
return null;
}
if (action === "dev") {
return "dev";
}
if (action === "build") {
return "build";
}
if (action === "test") {
return "test";
}
return null;
}
const runner = resolveRunner();
if (!runner) {
process.stderr.write("Missing UI runner: install pnpm, then retry.\n");
process.exit(1);
}
export function main(argv = process.argv.slice(2)) {
const [action, ...rest] = argv;
if (!action) {
usage();
process.exit(2);
}
const script =
action === "install"
? null
: action === "dev"
? "dev"
: action === "build"
? "build"
: action === "test"
? "test"
: null;
const runner = resolveRunner();
if (!runner) {
process.stderr.write("Missing UI runner: install pnpm, then retry.\n");
process.exit(1);
}
if (action !== "install" && !script) {
usage();
process.exit(2);
}
const script = resolveScriptAction(action);
if (action !== "install" && !script) {
usage();
process.exit(2);
}
if (action === "install") {
run(runner.cmd, ["install", ...rest]);
return;
}
if (action === "install") {
run(runner.cmd, ["install", ...rest]);
} else {
if (!depsInstalled(action === "test" ? "test" : "build")) {
const installEnv =
action === "build" ? { ...process.env, NODE_ENV: "production" } : process.env;
const installArgs = action === "build" ? ["install", "--prod"] : ["install"];
runSync(runner.cmd, installArgs, installEnv);
}
run(runner.cmd, ["run", script, ...rest]);
}
const isDirectExecution = (() => {
const entry = process.argv[1];
return Boolean(entry && path.resolve(entry) === fileURLToPath(import.meta.url));
})();
if (isDirectExecution) {
main();
}