fix: bypass shell entirely for cross-platform Docker commands#64
fix: bypass shell entirely for cross-platform Docker commands#64
Conversation
…cker command shellEscape blindly wraps everything in POSIX single quotes. That's fine on macOS and Linux where execSync spawns /bin/sh. On Windows it spawns cmd.exe, which doesn't treat single quotes as quoting characters at all — they get passed through literally as part of the argument. So `docker pull 'ghcr.io/kryptsec/sqli-auth-bypass:latest'` becomes an image reference that literally starts and ends with an apostrophe, and Docker rightfully tells you that's an invalid reference format. Every single docker and docker exec call in the CLI was broken on Windows. Now shellEscape checks process.platform and uses double-quote wrapping with proper cmd.exe escaping (percent doubling, backslash-escaped double quotes) on win32. POSIX behavior is unchanged. cc @pi3-code for additional testing on this. Closes #63
pi3-code
left a comment
There was a problem hiding this comment.
Review: Windows cmd.exe Shell Escaping
Hey, thanks for tackling this — Windows shell quoting is genuinely one of the trickiest areas to get right, and the core idea here (platform-aware escaping) is the right direction. I dug into cmd.exe's parsing behavior and found a few things worth addressing before this ships.
cmd.exe Quote Escaping
The main thing: cmd.exe doesn't recognize \" as an escaped quote the way POSIX shells or the C runtime do. It treats \ as a literal character, so \" actually closes the quoted region from cmd.exe's perspective. The standard cmd.exe convention is "" (doubled quote).
This matters because input containing a double quote could unintentionally break out of the quoting:
Input: hello" & whoami
Escaped: "hello\" & whoami"
cmd.exe interprets the \" as closing the quote, then runs & whoami as a separate command.
Swapping to "" would fix this.
A few other cmd.exe quirks to handle
%%for percent escaping — this works in.bat/.cmdfiles but not reliably at thecmd.exe /ccommand line (which is what Node'sexecSyncuses). The BatBadBut research has a good writeup on safer alternatives.- Caret (
^) — it's cmd.exe's escape character and can interfere with parsing even inside quotes. Worth escaping as^^. - Newlines — a
\nor\r\nin the input would terminate the command line. Probably safest to throw on these since there's no clean way to embed them. - Trailing backslash —
shellEscape('C:\\')would produce"C:\\", and the C runtime parser can misread\"as an escaped quote, leaving the string unterminated. Edge case but worth covering. !(delayed expansion) — ifcmd /V:ONis active,!var!gets expanded. Lower risk but easy to handle.
Test coverage
The existing tests cover the happy path well. A few additional cases that would strengthen confidence: caret ^, trailing backslash, newlines, and a double-quote-plus-metacharacter combo (the real attack surface).
Bigger picture suggestion
cmd.exe escaping is notoriously fragile — you might want to consider using execFileSync/spawnSync with argument arrays for the Windows path, which bypasses cmd.exe entirely and sidesteps the whole escaping problem:
import { execFileSync } from 'child_process';
execFileSync('docker', ['pull', imageName]); // no shell involvedAlternatively, a library like cross-spawn handles all these edge cases.
Also worth noting: some callers in docker.ts and env-check.ts use POSIX-isms like 2>/dev/null and sleep that would need separate fixes for full Windows support — but that can be a follow-up.
Again, appreciate you taking this on — Windows compat is important and this is a solid start. Happy to chat through any of these if it would help!
The original approach tried to make shellEscape() work on Windows by adding cmd.exe double-quote escaping. Pie's review correctly identified that cmd.exe escaping is a minefield — \" doesn't actually escape quotes in cmd.exe, %% only works in .bat files, and there's a real injection vector with the current implementation. The right fix is to not use a shell at all. execFileSync with argument arrays bypasses cmd.exe (and bash) entirely — arguments are passed directly to the process without any shell interpretation. No shell means no shell escaping means no injection surface. docker.ts: every execSync call replaced with execFileSync argument arrays. The POSIX `sleep 2` call replaced with Atomics.wait-based sleepSync() that works cross-platform without a shell. env-check.ts: same treatment. The `|| echo "FAIL"` pattern in checkTargetReachable replaced with try/catch. The `2>/dev/null` stderr suppression replaced by stdio: 'pipe'. shell.ts: reverted to POSIX-only, added doc comment explaining that execFileSync should be used for cross-platform instead. All tests updated to mock execFileSync and assert against argument arrays instead of command strings. 410/410 passing.
Summary
The original commit tried to fix Windows by adding cmd.exe escaping to
shellEscape(). Pie's review correctly identified that cmd.exe escaping is fundamentally broken —\"doesn't escape quotes in cmd.exe,%%only works in .bat files, and the implementation had a real injection vector.The right fix is to not use a shell at all.
execFileSyncwith argument arrays bypasses cmd.exe (and bash) entirely — arguments go directly to the process. No shell means no escaping means no injection surface.docker.ts: Every
execSynccall replaced withexecFileSyncargument arrays. The POSIXsleep 2replaced withAtomics.wait-basedsleepSync()that works cross-platform.env-check.ts: Same treatment.
|| echo "FAIL"replaced with try/catch.2>/dev/nullreplaced bystdio: 'pipe'.shell.ts: Reverted to POSIX-only with doc comment explaining to use
execFileSyncfor cross-platform.Closes #63
Test plan
npm run build— cleannpx vitest run— 410/410 pass