VNX-NODE-026 – Child process spawn with shell:true enables command injection
Overview
This rule detects calls to child_process.spawn() or child_process.spawnSync() that include the option shell: true. When this option is set, Node.js executes the command via /bin/sh -c on Unix or cmd.exe /d /s /c on Windows, meaning the entire command string is interpreted by a shell before being executed. Shell metacharacters such as ;, &&, |, `, $(), >, and < take on special meaning in this context.
The key difference from execFile() is that spawn() by default accepts a separate argument array, giving it a similar safety profile — each argument is passed as a distinct parameter to the OS execve() call, bypassing the shell entirely. Setting shell: true throws away this separation and reintroduces all the risks of string-based shell execution. Any user-controlled data in the command string or argument array can then inject additional shell commands.
This is a subtle but serious footgun: developers often add shell: true to work around quoting issues or to enable shell features like environment variable expansion, without realising it opens a command injection path.
Severity: High | CWE: CWE-78 – Improper Neutralization of Special Elements used in an OS Command
Why This Matters
Command injection via shell: true has the same impact as any other OS command injection — arbitrary code execution with the privileges of the Node.js process. The risk is compounded because spawn() with shell: true is often used in code that is otherwise well-structured (using argument arrays), giving the false impression that it is safe.
In practice, even “safe-looking” argument arrays are not safe when shell: true is set. The shell receives the entire command line as a concatenated string and re-parses it, so a value like ; rm -rf / in the argument array becomes a second shell command. Developers who rely on argument separation for safety are caught off guard when shell: true negates that protection.
CI/CD pipelines, developer tooling, and build systems are the highest-risk areas. These applications frequently invoke external commands and sometimes accept repository names, branch names, or user-provided parameters that are passed to those commands.
What Gets Flagged
const { spawn } = require('child_process');
// FLAGGED: a request value reaches a shell
const file = req.body.file;
spawn('convert', [file, 'out.png'], { shell: true });
// FLAGGED: options across lines, command from a parameter
cp.spawn(command, {
cwd,
shell: true,
});
// FLAGGED: a shell path counts as a shell; template interpolation is not constant
childProcess.execFileSync('grep', ['-r', pattern, dir], { shell: '/bin/bash' });
await execa(`git checkout ${branch}`, { shell: true });
Not flagged: shell: false or no shell option, shell: process.platform === 'win32', and a call whose command and arguments are all literals or constants (spawnSync('npm run build', { shell: true })). exec()/execSync() always use a shell and are covered by VNX-NODE-003.
Remediation
Remove
shell: trueand rely on the defaultshell: falsebehaviour, passing arguments as a separate array.Validate all user-supplied values against a strict allowlist of permitted values before using them in any command.
Use
execFile()instead ofexec()for commands that do not need shell features —execFile()never uses a shell by default.If shell features are genuinely needed (glob expansion, piping), perform those operations in Node.js code rather than delegating to the shell.
// SAFE: shell:false (default) — arguments passed directly to the OS, no shell involved
const { spawn } = require('child_process');
app.post('/clone', (req, res) => {
const repoName = req.body.repoName;
// Validate against allowlist
if (!/^[a-zA-Z0-9_-]+\/[a-zA-Z0-9_.-]+$/.test(repoName)) {
return res.status(400).json({ error: 'Invalid repo name' });
}
const proc = spawn('git', ['clone', '--', `https://github.com/${repoName}.git`], {
shell: false, // explicit and safe — this is also the default
stdio: 'pipe',
});
proc.on('close', (code) => {
res.json({ exitCode: code });
});
});