Back to Blog
high SEVERITY6 min read

How Command Injection Happens in Node.js Child Process Calls and How to Fix It

The Spotify CLI contained a command injection vulnerability in its browser-opening functionality, where user-controlled URLs were passed directly to `exec()` with shell interpretation enabled. By switching from `exec()` to `execFile()` and properly structuring command arguments, the fix eliminates the attack surface while maintaining cross-platform compatibility.

O
By Orbis AppSec
Published September 7, 2026Reviewed September 7, 2026

Answer Summary

This is a command injection vulnerability (CWE-78) in Node.js caused by using `exec()` with unsanitized input in the `openBrowser()` function. The `exec()` method spawns a shell that interprets special characters in the URL parameter, allowing attackers to inject arbitrary commands. The fix replaces `exec()` with `execFile()` and passes the URL as a separate argument rather than embedding it in a shell command string, preventing shell metacharacter interpretation.

Vulnerability at a Glance

cweCWE-78 (Improper Neutralization of Special Elements used in an OS Command)
fixReplace exec() with execFile() and pass arguments as array elements
riskRemote Code Execution if URLs are user-controlled; arbitrary command execution
languageJavaScript (Node.js)
root causeUsing exec() with untrusted input embedded in shell command strings
vulnerabilityCommand Injection via Unsafe Child Process Execution

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:

  1. Spawns a shell process (/bin/sh, bash, or cmd.exe)
  2. Passes your entire command string to that shell
  3. 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:

  1. No shell interpretation: execFile() directly spawns the specified executable (cmd, open, or xdg-open) without invoking a shell. It does not parse metacharacters.

  2. 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.

  3. 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() or Function() 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

Frequently Asked Questions

What is command injection in Node.js?

Command injection occurs when user input is passed to shell command execution functions like exec() without proper sanitization, allowing attackers to inject and execute arbitrary OS commands.

How do you prevent command injection in Node.js?

Use execFile() instead of exec() to avoid shell interpretation, pass arguments as array elements rather than string concatenation, validate/whitelist inputs, and never trust user-supplied data in command construction.

What CWE is command injection?

CWE-78: Improper Neutralization of Special Elements used in an OS Command ('OS Command Injection').

Is input validation alone enough to prevent command injection?

No—validation is helpful but incomplete. The root issue is using exec() which invokes a shell. Using execFile() prevents shell interpretation regardless of input, providing defense-in-depth.

Can static analysis detect command injection in Node.js?

Yes, tools like Semgrep can detect patterns like exec() or eval() called with function arguments or external inputs, flagging potentially dangerous code paths for review.

View the Security Fix

Check out the pull request that fixed this vulnerability

View PR #2318

Related Articles

high

How Command Injection Happens in Node.js child_process and How to Fix It

A high-severity command injection vulnerability was discovered in `server.js` where user-controlled file paths were passed directly to shell commands via `exec()`. By migrating from `exec()` to `execFile()` and using argument arrays instead of string concatenation, the fix eliminates the attack surface while preserving the intended trash/delete functionality across macOS, Windows, and Linux.

high

How Command Injection happens in Node.js and how to fix it

A semgrep scan flagged `scripts/postinstall.js` for calling `child_process.execSync` in a way that could become a command injection primitive if the script's execution context ever changed. The fix hardens the script by guarding its side effects behind a `require.main === module` check, introducing the safer `execFileSync` API, and adding automated tests to lock in the safe behavior.

high

How command injection happens in Node.js child_process and how to fix it

A critical command injection vulnerability in `scripts/check-links.js` was fixed by replacing `execSync()` with `execFileSync()`, eliminating shell interpretation of user-controlled repository names. This proactive hardening prevents potential remote code execution in the GitHub CLI integration workflow.

critical

How Command Injection happens in Node.js and how to fix it

A critical command injection vulnerability in `scripts/sync-skill.mjs` allowed attackers to execute arbitrary commands through malicious command-line arguments. The fix implements strict whitelist validation on `process.argv` inputs, ensuring only the `--check` flag is accepted before any shell interaction occurs.

high

How Shell Injection Happens in GitHub Actions and How to Fix It

A high-severity shell injection vulnerability was discovered in `action.yml` where direct variable interpolation with GitHub context data in `run:` steps could allow attackers to inject arbitrary code into the runner. The fix uses environment variables with proper quoting to safely separate untrusted input from shell execution, eliminating the exploit primitive while preserving legitimate functionality.

high

How command injection happens in JavaScript child_process and how to fix it

A high-severity command injection vulnerability in Claude Code's `prepare-native.js` could have allowed attackers to execute arbitrary shell commands through malicious npm package tarball URLs. The fix adds strict URL scheme validation and proper curl argument termination to neutralize injection vectors.