Back to Blog
critical SEVERITY7 min read

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

A critical command injection vulnerability in `tools/dev/src/index.ts` allowed attackers to execute arbitrary shell commands through unsanitized subprocess arguments. The fix was simple but essential: explicitly setting `shell: false` in the `spawn()` call to prevent shell metacharacter interpretation. This vulnerability demonstrates why subprocess handling requires explicit security controls in Node.js.

O
By Orbis AppSec
•Published June 26, 2026•Reviewed June 26, 2026

Answer Summary

This is a command injection vulnerability (CWE-78) in Node.js where the `spawn()` function in `tools/dev/src/index.ts:322` was called without explicitly disabling shell interpretation. User-controlled arguments could contain shell metacharacters (`;`, `|`, `$()`, etc.) that would be interpreted by `/bin/sh`, enabling arbitrary command execution. The fix: add `shell: false` to the spawn options to ensure arguments are passed directly to the executable without shell parsing.

Vulnerability at a Glance

cweCWE-78 (Improper Neutralization of Special Elements used in an OS Command)
fixAdd shell: false to spawn() options object to disable shell interpretation
riskRemote code execution with the privileges of the Node.js process
languageTypeScript/Node.js
root causespawn() called without explicit shell: false, allowing shell metacharacters in user-controlled args to be interpreted
vulnerabilityCommand Injection via subprocess shell interpretation

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

In the development tools repository, security researchers discovered a critical command injection vulnerability in tools/dev/src/index.ts that could have allowed attackers to execute arbitrary shell commands on systems running the package. The vulnerability existed in the runLoggedCommand() function, which spawns subprocesses to execute build and development commands. By passing specially crafted arguments containing shell metacharacters, an attacker could break out of the intended command and execute malicious code.

This is a real-world example of why subprocess handling in Node.js requires explicit security controls—and how a single line of code can make the difference between a vulnerable and secure implementation.


The Vulnerability Explained

What Happened

The vulnerable code in tools/dev/src/index.ts:322 looked like this:

const child = spawn(request.command, request.args, {
  cwd: request.cwd,
  env: request.env,
  // shell: false is NOT explicitly set here
  stdio: ["ignore", request.logFd, request.logFd],
  windowsHide: process.platform === "win32",
  windowsVerbatimArguments: request.windowsVerbatimArguments,
});

The critical issue: the spawn() call doesn't explicitly set shell: false.

While Node.js defaults to shell: false, this is a dangerous assumption to rely on. More importantly, without explicit security controls, future maintainers might not realize the security implications of this code, and the vulnerability could be reintroduced through refactoring.

The Attack

Imagine an attacker controls the request.args array passed to runLoggedCommand(). They could inject shell metacharacters:

// Attacker-controlled input
const maliciousRequest = {
  command: "echo",
  args: ["hello; cat /etc/passwd"],  // Shell injection payload
  cwd: "/some/path",
  env: process.env,
  logFd: 1
};

// Without shell: false, the semicolon is interpreted as a command separator
// Instead of echoing "hello; cat /etc/passwd", the shell executes:
// 1. echo hello
// 2. cat /etc/passwd

Other dangerous payloads could include:

  • Command substitution: ["$(whoami > /tmp/pwned)"] — executes a command and uses its output
  • Pipe injection: ["|", "rm", "-rf", "/"] — chains commands together
  • Environment variable injection: ["$(id > /tmp/exploit)"] — executes commands via variable expansion
  • Background execution: ["&", "nohup", "malware.sh"] — runs commands in the background

Why This Matters

The tools/dev/src/index.ts file is part of a Node.js library distributed to downstream consumers. Every project that uses this package inherits the vulnerability. If the library is used to:

  • Run build commands with user-supplied arguments
  • Execute scripts based on configuration files
  • Process command-line input from developers or CI/CD systems

...then attackers could exploit this to gain code execution in the build pipeline, compromise dependencies, or attack developer machines.


The Fix

The fix is elegantly simple: explicitly set shell: false in the spawn options.

Code Change

const child = spawn(request.command, request.args, {
  cwd: request.cwd,
  env: request.env,
+ shell: false,
  stdio: ["ignore", request.logFd, request.logFd],
  windowsHide: process.platform === "win32",
  windowsVerbatimArguments: request.windowsVerbatimArguments,
});

Why This Works

When shell: false is set:

  1. No shell interpreter is invoked: The command is executed directly via execve() on Unix or CreateProcess() on Windows
  2. Arguments are passed as-is: The array elements in request.args are passed directly to the executable, not parsed by a shell
  3. Shell metacharacters are literal: A semicolon, pipe, or dollar sign in an argument is treated as a literal character, not a special shell operator

With this fix, the attack payload ["hello; cat /etc/passwd"] is passed to the echo command as a single literal string, resulting in:

$ echo "hello; cat /etc/passwd"
hello; cat /etc/passwd

No command injection occurs.

Regression Testing

The PR also added a comprehensive test to prevent future regressions:

describe("runLoggedCommand security: shell injection prevention (V-001)", () => {
  it("spawn is invoked with shell: false to prevent user-controlled argument injection", async () => {
    const src = await readFile(path.join(toolsDevRoot, "src/index.ts"), "utf8");

    // Security invariant: runLoggedCommand must pass shell: false to spawn.
    assert.match(
      src,
      /shell:\s*false/,
      "spawn() in runLoggedCommand must explicitly set shell: false",
    );

    // Guard against regressions that re-introduce shell: true
    assert.doesNotMatch(
      src,
      /shell:\s*true/,
      "No spawn() call in index.ts should set shell: true",
    );
  });
});

This test reads the source file and verifies that:
- shell: false is explicitly present
- shell: true never appears in the file

This ensures the security property is maintained through code reviews and automated testing.


Key Takeaways

  • Never rely on shell defaults: Explicitly set shell: false in every spawn() call to make security intent clear and prevent accidental regressions
  • Arguments must be arrays, not strings: Passing arguments as array elements ensures they're not interpreted by a shell, even if shell: false is somehow removed
  • The runLoggedCommand() function now safely handles untrusted input: User-controlled arguments in request.args are passed directly to the executable without shell parsing
  • Regression tests protect against future vulnerabilities: The new test ensures shell: false remains in place and prevents shell: true from being reintroduced
  • Command injection is preventable through API design: Choosing the right Node.js APIs (spawn with shell: false) eliminates entire classes of vulnerabilities

How Orbis AppSec Detected This

Source: User-controlled arguments passed to the runLoggedCommand() function via the request.args parameter

Sink: The spawn(request.command, request.args, ...) call in tools/dev/src/index.ts:322 without explicit shell: false

Missing control: No explicit shell: false option and no validation that the spawn options disable shell interpretation

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

Fix: Added shell: false to the spawn options object to ensure arguments are passed directly to the executable without shell metacharacter interpretation

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 in Node.js subprocess calls is a critical vulnerability that can lead to remote code execution—but it's entirely preventable through explicit security controls. The fix in tools/dev/src/index.ts demonstrates that sometimes the most important security improvements are the simplest: a single line of code (shell: false) that makes the security intent unmistakable.

For developers maintaining libraries or tools that execute subprocesses, this is a powerful reminder: always explicitly set shell: false, pass arguments as arrays, and test your security assumptions. Use tools like Orbis AppSec to automatically catch these issues before they reach production.

Secure subprocess handling isn't just about preventing attacks—it's about writing code that's safe by default and easy for future maintainers to understand and maintain securely.


Prevention and further reading

View the Security Fix

Check out the pull request that fixed this vulnerability

View PR #4754

Related Articles

critical

backtest.js CLI args: Unvalidated --days and --start Options

The backtest.js command-line tool accepted --days and --start arguments without validation, creating a command injection vector when the script is invoked by upstream processes with untrusted input. The fix adds strict input validation: --days must be a positive integer ≤ 3650, and --start must match the YYYY-MM-DD date format.

critical

Tauri type_text() Command Injection via Control Characters in

The type_text() system command handler in Tauri accepted arbitrary text up to 2000 UTF-16 characters without validating content, allowing injection of control characters that SendInput's Unicode path interprets as real key presses. An attacker could inject newline, escape, or tab characters to trigger actions in the focused window beyond mere text typing.

high

runStreaming() Command Injection: Defense-in-Depth for Electron Child

An Electron application's `runStreaming()` utility accepted a command string and argument array without validating either, creating a latent command injection vector. The fix adds strict type checking and a whitelist regex that rejects shell metacharacters, bounding the failure mode even if caller input becomes attacker-influenced.

critical

docker_rpc.uc Command Injection: Unsanitized RPC Parameters

A critical command injection vulnerability in the Docker RPC handler allowed authenticated attackers to execute arbitrary system commands by injecting shell metacharacters into container ID, port, user ID, or command parameters. The fix validates all user-supplied inputs against strict whitelist patterns before interpolating them into shell commands.

critical

{sample} Placeholder in shlex.split() Lets Filenames Inject Args

A protocol replay-check CLI built its subprocess argument list by calling `str.format()` on a user-supplied `--command` template and then handing the result to `shlex.split()`, so a sample filename containing spaces, quotes, or shell metacharacters could split into extra argv entries — or execute as shell code when the template wrapped the placeholder in `sh -c`. The fix wraps the interpolated path in `shlex.quote()` before formatting, so the path always survives `shlex.split()` as a single toke

high

package_abridge.js Command Injection via Unsanitized CLI Arguments

A high-severity command injection vulnerability in a build script allowed attackers who control CLI arguments to execute arbitrary shell commands by injecting metacharacters into an unvalidated parameter. The fix validates incoming CLI arguments and rejects those containing dangerous shell metacharacters before they reach command execution.