mirror of
https://github.com/affaan-m/everything-claude-code.git
synced 2026-05-13 08:03:04 +08:00
fix: port Windows hook safety fixes (#1719)
This commit is contained in:
@@ -306,126 +306,175 @@ function probeCommandServer(serverName, config) {
|
||||
...(config.env && typeof config.env === 'object' && !Array.isArray(config.env) ? config.env : {})
|
||||
};
|
||||
|
||||
let stderr = '';
|
||||
let done = false;
|
||||
let timer = null;
|
||||
|
||||
function finish(result) {
|
||||
if (done) return;
|
||||
done = true;
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
timer = null;
|
||||
}
|
||||
resolve(result);
|
||||
}
|
||||
|
||||
// On Windows, commands like 'npx' are actually 'npx.cmd' batch files that
|
||||
// require shell expansion to resolve. However, absolute paths (e.g.
|
||||
// 'C:\Program Files\nodejs\node.exe') must NOT use shell mode because
|
||||
// cmd.exe misparses paths containing spaces. Only enable shell for
|
||||
// non-absolute commands that need PATH resolution.
|
||||
//
|
||||
// Security: validate the command for shell metacharacters before enabling
|
||||
// shell mode. cmd.exe treats &, |, <, >, ^, %, !, (, ), ;, and whitespace
|
||||
// as operators/separators. A crafted command value from an MCP config file
|
||||
// could otherwise inject arbitrary shell commands.
|
||||
// On Windows, commands like 'npx' are commonly exposed as npx.cmd.
|
||||
// Probe bare PATH commands through platform-extension fallbacks, but keep
|
||||
// absolute/relative path commands as a single candidate so their existing
|
||||
// ENOENT failure semantics stay intact.
|
||||
const commandIsString = typeof command === 'string' && command.length > 0;
|
||||
const isPathLike = commandIsString && (
|
||||
path.isAbsolute(command)
|
||||
|| command.includes('/')
|
||||
|| command.includes('\\')
|
||||
);
|
||||
const candidates = process.platform === 'win32'
|
||||
&& commandIsString
|
||||
&& !path.extname(command)
|
||||
&& !isPathLike
|
||||
? [command, `${command}.cmd`, `${command}.exe`, `${command}.bat`]
|
||||
: [command];
|
||||
|
||||
// cmd.exe treats these as operators, grouping syntax, expansion markers,
|
||||
// separators, or argument boundaries. Do not route such command strings
|
||||
// through shell mode.
|
||||
const UNSAFE_SHELL_CHARS = /[&|<>^%!()\s;]/;
|
||||
const needsShell =
|
||||
process.platform === 'win32' &&
|
||||
typeof command === 'string' &&
|
||||
!path.isAbsolute(command) &&
|
||||
!UNSAFE_SHELL_CHARS.test(command);
|
||||
let child;
|
||||
try {
|
||||
child = spawn(command, args, {
|
||||
env: mergedEnv,
|
||||
cwd: process.cwd(),
|
||||
stdio: ['pipe', 'ignore', 'pipe'],
|
||||
shell: needsShell
|
||||
});
|
||||
} catch (error) {
|
||||
finish({
|
||||
ok: false,
|
||||
statusCode: null,
|
||||
reason: error.message
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
child.stderr.on('data', chunk => {
|
||||
if (stderr.length < 4000) {
|
||||
const remaining = 4000 - stderr.length;
|
||||
stderr += String(chunk).slice(0, remaining);
|
||||
function attempt(idx) {
|
||||
const tryCommand = candidates[idx];
|
||||
const isLast = idx + 1 >= candidates.length;
|
||||
let stderr = '';
|
||||
let attemptDone = false;
|
||||
let timer = null;
|
||||
|
||||
function retryNext() {
|
||||
if (attemptDone) return;
|
||||
attemptDone = true;
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
timer = null;
|
||||
}
|
||||
attempt(idx + 1);
|
||||
}
|
||||
});
|
||||
|
||||
child.on('error', error => {
|
||||
finish({
|
||||
ok: false,
|
||||
statusCode: null,
|
||||
reason: error.message
|
||||
});
|
||||
});
|
||||
function attemptFinish(result) {
|
||||
if (attemptDone) return;
|
||||
attemptDone = true;
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
timer = null;
|
||||
}
|
||||
finish(result);
|
||||
}
|
||||
|
||||
child.on('exit', (code, signal) => {
|
||||
finish({
|
||||
ok: false,
|
||||
statusCode: code,
|
||||
reason: stderr.trim() || `process exited before handshake (${signal || code || 'unknown'})`
|
||||
});
|
||||
});
|
||||
// Node 18.20+/20.12+ refuse to spawn .cmd/.bat directly on Windows
|
||||
// after the CVE-2024-27980 mitigation. Only those extension candidates
|
||||
// go through cmd.exe, after the command string is shell-character clean.
|
||||
const useShell = process.platform === 'win32'
|
||||
&& typeof tryCommand === 'string'
|
||||
&& /\.(cmd|bat)$/i.test(tryCommand)
|
||||
&& !UNSAFE_SHELL_CHARS.test(tryCommand);
|
||||
|
||||
timer = setTimeout(() => {
|
||||
// A fast-crashing stdio server can finish before the timer callback runs
|
||||
// on a loaded machine. Check the process state again before classifying it
|
||||
// as healthy on timeout.
|
||||
if (child.exitCode !== null || child.signalCode !== null) {
|
||||
finish({
|
||||
let child;
|
||||
try {
|
||||
child = spawn(tryCommand, args, {
|
||||
env: mergedEnv,
|
||||
cwd: process.cwd(),
|
||||
stdio: ['pipe', 'ignore', 'pipe'],
|
||||
shell: useShell
|
||||
});
|
||||
} catch (error) {
|
||||
if ((error.code === 'ENOENT' || error.code === 'EINVAL') && !isLast) {
|
||||
retryNext();
|
||||
return;
|
||||
}
|
||||
attemptFinish({
|
||||
ok: false,
|
||||
statusCode: child.exitCode,
|
||||
reason: stderr.trim() || `process exited before handshake (${child.signalCode || child.exitCode || 'unknown'})`
|
||||
statusCode: null,
|
||||
reason: error.message
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (needsShell && child.pid && process.platform === 'win32') {
|
||||
// When spawned via shell on Windows, child is cmd.exe. kill() only
|
||||
// terminates the shell and leaves the real server process orphaned.
|
||||
// taskkill /T kills the entire process tree rooted at cmd.exe.
|
||||
const killResult = spawnSync('taskkill', ['/PID', String(child.pid), '/T', '/F'], { stdio: 'ignore' });
|
||||
if (killResult.error || (typeof killResult.status === 'number' && killResult.status !== 0)) {
|
||||
// taskkill not on PATH, permission denied, or already exited.
|
||||
// Best-effort fallback: signal the cmd.exe shell directly. The
|
||||
// child tree may still leak if it already detached, but this at
|
||||
// least kills the shell we spawned.
|
||||
try { child.kill('SIGKILL'); } catch { /* ignore */ }
|
||||
}
|
||||
} else {
|
||||
child.kill('SIGTERM');
|
||||
setTimeout(() => {
|
||||
try {
|
||||
child.kill('SIGKILL');
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, 200).unref?.();
|
||||
child.stderr.on('data', chunk => {
|
||||
if (stderr.length < 4000) {
|
||||
const remaining = 4000 - stderr.length;
|
||||
stderr += String(chunk).slice(0, remaining);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
finish({
|
||||
ok: true,
|
||||
statusCode: null,
|
||||
reason: `${serverName} accepted a new stdio process`
|
||||
});
|
||||
}, timeoutMs);
|
||||
|
||||
if (typeof timer.unref === 'function') {
|
||||
timer.unref();
|
||||
child.on('error', error => {
|
||||
if ((error.code === 'ENOENT' || error.code === 'EINVAL') && !isLast) {
|
||||
retryNext();
|
||||
return;
|
||||
}
|
||||
attemptFinish({
|
||||
ok: false,
|
||||
statusCode: null,
|
||||
reason: error.message
|
||||
});
|
||||
});
|
||||
|
||||
child.on('exit', (code, signal) => {
|
||||
attemptFinish({
|
||||
ok: false,
|
||||
statusCode: code,
|
||||
reason: stderr.trim() || `process exited before handshake (${signal || code || 'unknown'})`
|
||||
});
|
||||
});
|
||||
|
||||
timer = setTimeout(() => {
|
||||
// A fast-crashing stdio server can finish before the timer callback runs
|
||||
// on a loaded machine. Check the process state again before classifying it
|
||||
// as healthy on timeout.
|
||||
if (child.exitCode !== null || child.signalCode !== null) {
|
||||
attemptFinish({
|
||||
ok: false,
|
||||
statusCode: child.exitCode,
|
||||
reason: stderr.trim() || `process exited before handshake (${child.signalCode || child.exitCode || 'unknown'})`
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (useShell && child.pid && process.platform === 'win32') {
|
||||
// When spawned via shell on Windows, child is cmd.exe. kill() only
|
||||
// terminates the shell and leaves the real server process orphaned.
|
||||
// taskkill /T kills the entire process tree rooted at cmd.exe.
|
||||
const killResult = spawnSync('taskkill', ['/PID', String(child.pid), '/T', '/F'], {
|
||||
stdio: 'ignore',
|
||||
windowsHide: true
|
||||
});
|
||||
if (killResult.error || (typeof killResult.status === 'number' && killResult.status !== 0)) {
|
||||
// taskkill not on PATH, permission denied, or already exited.
|
||||
// Best-effort fallback: signal the cmd.exe shell directly. The
|
||||
// child tree may still leak if it already detached, but this at
|
||||
// least kills the shell we spawned.
|
||||
try { child.kill('SIGKILL'); } catch { /* ignore */ }
|
||||
}
|
||||
} else {
|
||||
child.kill('SIGTERM');
|
||||
setTimeout(() => {
|
||||
try {
|
||||
child.kill('SIGKILL');
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, 200).unref?.();
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
attemptFinish({
|
||||
ok: true,
|
||||
statusCode: null,
|
||||
reason: `${serverName} accepted a new stdio process`
|
||||
});
|
||||
}, timeoutMs);
|
||||
|
||||
if (typeof timer.unref === 'function') {
|
||||
timer.unref();
|
||||
}
|
||||
}
|
||||
|
||||
attempt(0);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ const { execFileSync, spawnSync } = require('child_process');
|
||||
const path = require('path');
|
||||
|
||||
// Shell metacharacters that cmd.exe interprets as command separators/operators
|
||||
const UNSAFE_PATH_CHARS = /[&|<>^%!]/;
|
||||
const UNSAFE_PATH_CHARS = /[&|<>^%!;`()$]/;
|
||||
|
||||
const { findProjectRoot, detectFormatter, resolveFormatterBin } = require('../lib/resolve-formatter');
|
||||
|
||||
|
||||
Reference in New Issue
Block a user