How Command Injection Happens in Node.js Child Process Calls and How to Fix It
Introduction
In the Spotify CLI repository, Semgrep flagged a HIGH severity command injection vulnerability in clis/spotify/spotify.js:103. The culprit? The openBrowser() function was using Node.js's exec() method to launch the system's default browser with a user-supplied URL.
Here's the problematic pattern:
import { exec } from 'child_process';
function openBrowser(url) {
const cmd = process.platform === 'win32' ? `start "" "${url}"` :
process.platform === 'darwin' ? `open "${url}"` :
`xdg-open "${url}"`;
exec(cmd); // DANGEROUS: url is embedded in shell command string
}
The function takes a url parameter and directly embeds it into a shell command string. When exec() is called, Node.js spawns a shell process (/bin/sh on Unix, cmd.exe on Windows) that interprets the entire command string, including any special characters in the URL. This is the exact pattern that leads to OS command injection attacks.
The Vulnerability Explained
What makes this dangerous?
The exec() function in Node.js is fundamentally different from execFile(). When you call exec(command_string), it:
- Spawns a shell process (
/bin/sh,bash, orcmd.exe) - Passes your entire command string to that shell
- The shell parses the string, interpreting metacharacters like
;,|,$(), backticks,&, etc.
This means if an attacker controls the URL being passed to openBrowser(), they can inject shell metacharacters to execute arbitrary commands.
Real attack scenario:
Imagine the Spotify CLI accepts a parameter like:
--browser-url "https://example.com; rm -rf /"
The vulnerable code would construct this command:
open "https://example.com; rm -rf /"
The shell would interpret the ; as a command separator and execute rm -rf / on the user's system. In a production environment where this CLI is used by automation tools or configuration systems, this could be catastrophic.
Another attack using command substitution:
--browser-url "https://example.com$(whoami > /tmp/pwned.txt)"
The shell would execute whoami and write the output to a file before attempting to open the browser.
Why this matters for downstream users:
This is a Node.js library—vulnerabilities don't just affect the repository maintainers. Anyone installing this package and using it with untrusted input (from environment variables, API endpoints, user-supplied parameters) could be exploited. Automated exploit tools scanning for these patterns might chain this with other weaknesses to achieve remote code execution.
The Fix
The fix replaces exec() with execFile() and restructures the command invocation to pass arguments as an array rather than a concatenated string:
Before (vulnerable):
import { exec } from 'child_process';
function openBrowser(url) {
const cmd = process.platform === 'win32' ? `start "" "${url}"` :
process.platform === 'darwin' ? `open "${url}"` :
`xdg-open "${url}"`;
exec(cmd);
}
After (hardened):
import { execFile } from 'child_process';
function openBrowser(url) {
if (process.platform === 'win32') {
execFile('cmd', ['/c', 'start', '', url]);
} else if (process.platform === 'darwin') {
execFile('open', [url]);
} else {
execFile('xdg-open', [url]);
}
}
Why this fixes the vulnerability:
-
No shell interpretation:
execFile()directly spawns the specified executable (cmd,open, orxdg-open) without invoking a shell. It does not parse metacharacters. -
Arguments as array elements: The URL is passed as a separate element in the arguments array
[url], not concatenated into a command string. Each array element is treated as a literal argument to the executable, not as shell syntax. -
Defense-in-depth: Even if a URL contains shell metacharacters like
;,|,$(), or backticks, they are passed literally to the executable and have no special meaning.
Cross-platform compatibility preserved:
The fix maintains the same cross-platform behavior:
- Windows: Uses cmd /c start "" <url>
- macOS: Uses open <url>
- Linux: Uses xdg-open <url>
All three commands receive the URL as a separate argument, eliminating shell injection while preserving functionality.
Prevention & Best Practices
1. Prefer execFile() over exec()
In Node.js, whenever you need to spawn a child process, always use execFile() unless you specifically need shell features. The general rule:
- ✅ Use
execFile()when you know the exact executable path and arguments - ✅ Use
spawn()for streaming output or very large payloads - ❌ Avoid
exec()with untrusted input - ❌ Never use
eval()orFunction()constructors with user input
2. Pass arguments as separate array elements
// ❌ BAD: String concatenation (vulnerable)
exec(`ffmpeg -i "${userVideo}" output.mp4`);
// ✅ GOOD: Array arguments (safe)
execFile('ffmpeg', ['-i', userVideo, 'output.mp4']);
3. Validate and whitelist inputs when possible
Even with execFile(), validate URLs or file paths:
import { execFile } from 'child_process';
import { URL } from 'url';
function openBrowser(url) {
// Validate that it's actually a URL
try {
new URL(url);
} catch {
throw new Error('Invalid URL provided');
}
if (process.platform === 'win32') {
execFile('cmd', ['/c', 'start', '', url]);
} else if (process.platform === 'darwin') {
execFile('open', [url]);
} else {
execFile('xdg-open', [url]);
}
}
4. Use static analysis to catch these patterns
Semgrep rules like javascript.lang.security.detect-child-process.detect-child-process catch exec() calls with function arguments that could represent untrusted input. Integrate Semgrep into your CI/CD pipeline:
semgrep --config=p/security-audit clis/spotify/spotify.js
5. Reference security standards
- OWASP A1:2021 - Broken Access Control: Understanding the broader context of injection vulnerabilities
- OWASP Command Injection Cheat Sheet: https://cheatsheetseries.owasp.org/cheatsheets/OS_Command_Injection.html
- CWE-78: The canonical definition of OS Command Injection
Key Takeaways
-
Never embed untrusted URLs or file paths into
exec()command strings—the shell will interpret special characters as commands. -
execFile()is the Node.js API designed for this use case: it directly spawns executables without shell interpretation, making it inherently resistant to command injection. -
Argument arrays are your friend: Passing the URL as
[url]rather than concatenating it into a string is the fundamental protection against injection. -
The Spotify CLI's
openBrowser()function was a common exploit primitive: automatable vulnerability scanners could have chained it with other weaknesses. Removing it proactively raises the bar against automated attacks. -
Static analysis tools catch these patterns: Semgrep flagged this vulnerability before it could be exploited in production, demonstrating the value of continuous security scanning.
How Orbis AppSec Detected This
Source: The url parameter passed to the openBrowser() function in clis/spotify/spotify.js:103, which could originate from user input, environment variables, or other untrusted sources.
Sink: The exec(cmd) call on line 103, where the URL was embedded in a shell command string, triggering shell metacharacter interpretation.
Missing control: No validation of the URL format, and more critically, the use of exec() instead of execFile(), which meant the shell was always invoked regardless of input content.
CWE: CWE-78 (Improper Neutralization of Special Elements used in an OS Command)
Fix: Replaced exec() with execFile() and restructured the invocation to pass the URL as a separate argument array element, preventing shell interpretation while maintaining cross-platform browser-launching functionality.
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 vulnerabilities in Node.js child process calls are subtle but dangerous. The exec() function's convenient string-based interface masks a fundamental security risk: the shell interprets your input as code, not data.
By switching from exec() to execFile() and passing arguments as array elements, the Spotify CLI eliminated this attack surface entirely. This is a clear example of secure-by-design: choosing the right API (one that doesn't invoke a shell) is more effective and reliable than trying to sanitize inputs after the fact.
For developers maintaining Node.js libraries and CLIs, make execFile() your default choice. Your downstream users will thank you—and so will your security team.
References
- CWE-78: Improper Neutralization of Special Elements used in an OS Command ('OS Command Injection')
- OWASP Command Injection Cheat Sheet
- Node.js Child Process Documentation: execFile()
- Node.js Child Process Documentation: exec() (not recommended with untrusted input)
- Semgrep Rule: detect-child-process
- harden: sanitize child_process call in spotify.js...