Back to Blog
high SEVERITY8 min read

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

A high-severity command injection vulnerability was discovered in `bin/index.js` of a Node.js application, where the `createWindowsShortcut` function passed a user-controllable `destDir` argument directly into a dynamically constructed PowerShell command string. The fix eliminates the string interpolation entirely by moving all path data into environment variables and using `execFileSync` with a static script, removing any possibility of shell metacharacter injection. This is a textbook example

O
By Orbis AppSec
Published August 26, 2026Reviewed August 26, 2026

Answer Summary

This is a command injection vulnerability (CWE-78) in Node.js, found in the `createWindowsShortcut` function in `bin/index.js`. The `destDir` argument was interpolated directly into a PowerShell command string passed to `execSync`, allowing an attacker who controls that path value to inject arbitrary shell commands. The fix replaces string interpolation with a static PowerShell script that reads all path values from environment variables, and switches from `execSync` to `execFileSync('powershell', args, ...)` — eliminating the shell interpretation layer entirely.

Vulnerability at a Glance

cweCWE-78
fixReplace string interpolation with environment variables and switch to execFileSync with a static script
riskArbitrary command execution on Windows systems when destDir is attacker-controlled
languageJavaScript (Node.js)
root causeUser-controlled path string interpolated directly into a PowerShell command passed to execSync
vulnerabilityCommand Injection via unsanitized child_process argument

The Setup: A Shortcut Function with a Hidden Flaw

The bin/index.js file in this Node.js application handles several platform-specific tasks, including creating a Windows desktop shortcut after installation. The createWindowsShortcut(exePath, destDir) function, defined around line 219, is responsible for generating a .lnk file on the user's Desktop. It does this by constructing a PowerShell one-liner and running it with Node's execSync.

On the surface, this looks like a reasonable approach — PowerShell is the natural tool for creating Windows shortcuts via the WScript.Shell COM object. But the implementation contained a critical flaw: the destDir argument (and its siblings exePath and shortcutPath) were interpolated directly into the command string, with only a naïve single-quote escape standing between the application and arbitrary command execution.

Semgrep flagged this pattern with rule javascript.lang.security.detect-child-process.detect-child-process at line 232, and the fix that followed is a masterclass in how to properly eliminate this class of vulnerability.


The Vulnerability Explained

Here is the exact vulnerable code that was removed:

// Normalize paths for Windows shells
const normShortcutPath = shortcutPath.replace(/\//g, '\\');
const normExePath = exePath.replace(/\//g, '\\');
const normDestDir = destDir.replace(/\//g, '\\');

const script = `$WshShell = New-Object -ComObject WScript.Shell; $Shortcut = $WshShell.CreateShortcut('${normShortcutPath}'); $Shortcut.TargetPath = '${normExePath}'; $Shortcut.WorkingDirectory = '${normDestDir}'; $Shortcut.Save();`;
// Escape single quotes for PowerShell
const escapedScript = script.replace(/'/g, "''");

execSync(`powershell -Command "${escapedScript}"`, { stdio: 'ignore' });

There are two compounding problems here:

Problem 1: String Interpolation into a Shell Command

The values of normShortcutPath, normExePath, and normDestDir are embedded directly into a PowerShell script string using template literals. This script is then passed as a single string argument to execSync, which invokes a shell to interpret it. Any shell metacharacter — a semicolon, a backtick, a $(...) expression — present in destDir would be interpreted by PowerShell as part of the command, not as a literal path.

Problem 2: The Escape Is Insufficient

The code attempts to mitigate this by escaping single quotes ('''), which is the PowerShell string-doubling convention. But this protection is incomplete for several reasons:

  • The outer command is wrapped in double quotes at the execSync level: powershell -Command "${escapedScript}". Double-quote contexts in PowerShell have different escaping rules than single-quote contexts.
  • Backtick (`) is PowerShell's escape character and is not neutralized.
  • Subexpression operators like $(...) and &{...} are active inside double-quoted strings.
  • On some Windows configurations, the command passes through cmd.exe first before reaching PowerShell, adding another layer of shell interpretation with its own metacharacters (&, |, %, ^).

A Concrete Attack Scenario

Suppose this library is used as a dependency in a larger tool that accepts a user-supplied installation directory. If an attacker can influence the value of destDir — for example, by passing a crafted path like:

C:\legit\path'; Start-Process calc.exe; #

After the single-quote escape, this becomes:

C:\legit\path''; Start-Process calc.exe; #

The doubled quote closes the PowerShell string, the semicolon ends the statement, and Start-Process calc.exe (or any other command) executes with the privileges of the Node.js process. On a developer's machine or a CI/CD runner, that's often elevated privilege.

Because this is a library, the attack surface is every downstream consumer who passes any externally influenced path into this function.


The Fix

The fix is elegant and thorough. It makes two coordinated changes across two files.

Before (bin/index.js, ~line 219–232)

const normShortcutPath = shortcutPath.replace(/\//g, '\\');
const normExePath = exePath.replace(/\//g, '\\');
const normDestDir = destDir.replace(/\//g, '\\');

const script = `$WshShell = New-Object -ComObject WScript.Shell; $Shortcut = $WshShell.CreateShortcut('${normShortcutPath}'); $Shortcut.TargetPath = '${normExePath}'; $Shortcut.WorkingDirectory = '${normDestDir}'; $Shortcut.Save();`;
const escapedScript = script.replace(/'/g, "''");

execSync(`powershell -Command "${escapedScript}"`, { stdio: 'ignore' });

After (bin/index.js)

import { buildShortcutInvocation } from './windows-shortcut.cjs';

// ...

const { args, env } = buildShortcutInvocation(exePath, destDir, shortcutPath);
execFileSync('powershell', args, { stdio: 'ignore', env });

The New Helper: bin/windows-shortcut.cjs

const STATIC_SCRIPT = `
$WshShell = New-Object -ComObject WScript.Shell
$Shortcut = $WshShell.CreateShortcut($env:CODEX_SHORTCUT_PATH)
$Shortcut.TargetPath = $env:CODEX_TARGET_PATH
$Shortcut.WorkingDirectory = $env:CODEX_WORKING_DIR
$Shortcut.Save()
`.trim();

function normWin(p) {
    return p // [normalizes path separators]
}

Why This Fix Works

1. execFileSync instead of execSync

execSync spawns a shell (cmd.exe on Windows) and passes the entire command as a string for the shell to parse. execFileSync bypasses the shell entirely — it invokes the named executable (powershell) directly with an array of arguments. There is no shell metacharacter interpretation because there is no shell.

2. Static script, no interpolation

The PowerShell script stored in STATIC_SCRIPT is a compile-time constant. It contains no template literals, no string concatenation, and no user data whatsoever. PowerShell reads the path values from environment variables ($env:CODEX_SHORTCUT_PATH, $env:CODEX_TARGET_PATH, $env:CODEX_WORKING_DIR), not from the script text itself.

3. Data travels via environment variables

The buildShortcutInvocation function constructs an env object containing the path values. These are passed to execFileSync as process environment variables — a channel that is completely separate from the command/argument channel. No matter what characters destDir contains, they cannot affect the structure of the PowerShell script being executed.

4. Separation of code and data

This is the fundamental principle behind all injection-prevention: code and data must travel through separate channels. The old code mixed them (data embedded in code string). The new code keeps them strictly separated (static code + data via environment).


Prevention & Best Practices

1. Prefer execFileSync / execFile over execSync / exec

When you must invoke an external process, always prefer the execFile family. These functions accept the executable path and arguments as separate parameters and never invoke a shell:

// Dangerous — shell interprets the entire string
execSync(`convert ${userFile} output.png`);

// Safe — no shell, arguments are passed directly to the process
execFileSync('convert', [userFile, 'output.png']);

2. Never interpolate untrusted data into command strings

If you find yourself using template literals or string concatenation to build a command, stop. That's a signal to redesign the data flow. Use argument arrays, environment variables, stdin, or temporary files instead.

3. Use environment variables to pass data to subprocesses

As demonstrated in this fix, environment variables are an excellent out-of-band channel for passing data to child processes. They are not subject to shell parsing and can contain arbitrary strings (including newlines and special characters) safely.

4. Validate and normalize paths before use

Even with the new fix, it's good practice to validate that destDir is an absolute path pointing to an expected location before using it. A path validation step (e.g., checking that the resolved path starts with an expected prefix) adds defense in depth.

5. Use static analysis in CI

The Semgrep rule javascript.lang.security.detect-child-process.detect-child-process caught this issue automatically. Add Semgrep or a similar SAST tool to your CI pipeline to catch these patterns before they reach production.

Relevant standards:
- OWASP: OS Command Injection Defense Cheat Sheet
- CWE-78: Improper Neutralization of Special Elements used in an OS Command ('OS Command Injection')


Key Takeaways

  • The createWindowsShortcut function in bin/index.js was the specific risk site — not the entire codebase. Targeted, scoped fixes like this one are easier to review and less likely to introduce regressions.
  • Escaping single quotes in a PowerShell string is not a complete defense — the outer execSync shell layer, double-quote context, and backtick metacharacter all create bypass opportunities.
  • execFileSync('powershell', args, ...) is categorically safer than execSync('powershell -Command "..."') because it eliminates the shell parsing layer entirely.
  • Moving path data into env variables (CODEX_SHORTCUT_PATH, CODEX_TARGET_PATH, CODEX_WORKING_DIR) means the PowerShell script is always static — there is nothing for an attacker to inject into.
  • Library code has a larger attack surface than application code — a vulnerability in a shared package affects every downstream consumer, making proactive hardening especially important.

How Orbis AppSec Detected This

  • Source: The destDir parameter of createWindowsShortcut(exePath, destDir) in bin/index.js — a value that downstream consumers of this library pass in, potentially from user-controlled input.
  • Sink: execSync(\powershell -Command "${escapedScript}"`, { stdio: 'ignore' })at line 232 ofbin/index.js, where the tainteddestDirvalue (vianormDestDir`) is embedded into the executed command string.
  • Missing control: No validation of destDir against an allowlist or expected path prefix; no use of argument arrays or environment variable channels to isolate data from code; reliance on incomplete single-quote escaping.
  • CWE: CWE-78 — Improper Neutralization of Special Elements used in an OS Command ('OS Command Injection')
  • Fix: Replaced dynamic command string interpolation with a static PowerShell script and execFileSync, passing all path values through environment variables to eliminate the injection surface.

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

The createWindowsShortcut vulnerability is a precise illustration of why "sanitize the input" is often the wrong mental model for command injection. The original code tried to sanitize — it normalized path separators and escaped single quotes — but the sanitization was incomplete because the root cause was never addressed: user data was being embedded into a command string that a shell would interpret.

The correct mental model is never mix code and data. The fix achieves this perfectly: a static, compile-time PowerShell script reads its inputs from environment variables at runtime, and execFileSync ensures no shell ever sees the path values as text to parse. The vulnerability surface is reduced to zero, not reduced to "probably small enough."

For Node.js developers building tools that invoke subprocesses: audit every exec and execSync call in your codebase. If any argument to those functions contains a variable, ask whether that variable could ever be influenced by external input — directly or through a chain of function calls. If the answer is "maybe," replace the shell-based call with execFileSync and argument arrays. Your future self, and your users, will thank you.


References

Frequently Asked Questions

What is command injection in Node.js child_process?

Command injection occurs when user-controlled input is embedded into a shell command string executed by child_process functions like execSync, allowing attackers to append or modify the command being run.

How do you prevent command injection in Node.js child_process calls?

Use execFileSync instead of execSync to avoid shell interpretation, pass arguments as arrays rather than interpolated strings, and pass untrusted data through environment variables rather than embedding it in command strings.

What CWE is command injection?

Command injection maps to CWE-78: Improper Neutralization of Special Elements used in an OS Command.

Is escaping single quotes enough to prevent command injection in PowerShell strings?

No. Escaping single quotes (replacing ' with '') is insufficient because other PowerShell metacharacters, encoding tricks, and nested quoting contexts can still be exploited. The only safe approach is to never interpolate untrusted data into command strings at all.

Can static analysis detect command injection in Node.js?

Yes. Tools like Semgrep can detect calls to child_process functions where arguments are derived from function parameters, flagging them for review even before runtime exploitation occurs.

View the Security Fix

Check out the pull request that fixed this vulnerability

View PR #9

Related Articles

high

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

A high-severity command injection vulnerability was discovered in `webhook/src/routes/bid-requests/create.route.js`, where user-controlled values were passed directly to route handlers without any schema validation. Without input validation, attackers could supply malformed or malicious values — including shell metacharacters — that propagate into downstream command construction, enabling arbitrary command execution. The fix adds strict UUID and type validation middleware directly in the route d

high

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

A high-severity command injection vulnerability was discovered in `src/account_manager.js`, where user-controllable input was passed directly to Node.js's `child_process` without sanitization. Alongside this, the companion `src/keyring_helper.py` GNOME Keyring helper lacked any execution guard, meaning any local user could invoke it to read, write, or delete stored OAuth tokens. The fix adds an OS-level ownership check that restricts execution of the keyring helper to the script's owner only.

critical

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

A Node.js CLI script in `scripts/refresh-htv-signature.js` accepted a user-controlled `slug` argument from `process.argv` and interpolated it directly into a URL string without any validation. While the immediate usage was an HTTP request via `axios.get()`, the absence of input sanitization created a pathway for command injection in current and future code paths. The fix adds a strict allowlist regex that rejects any slug not matching `[a-zA-Z0-9_-]+` before it can reach any downstream operation

critical

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

A critical command injection vulnerability (CVE-2026-9277) was discovered in shell-quote 1.8.3, where unescaped line terminators could allow attackers to inject and execute arbitrary shell commands. The fix upgrades the dependency to shell-quote 1.8.4 and pins the version using npm's `overrides` field to ensure no transitive dependency can reintroduce the vulnerable version. This type of vulnerability is particularly dangerous in Node.js toolchains where shell-quote is used to safely construct s

high

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

A high-severity command injection vulnerability was discovered in `js/cu_linux_executor.js`, where `child_process.execSync()` was used to run shell commands with potentially unsanitized input. The fix replaces shell-based execution with `execFileSync()`, which spawns processes directly without invoking a shell, eliminating the possibility of shell metacharacter injection. This change is a critical defensive hardening step that removes an exploit primitive that could be chained with other weaknes

critical

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

A critical command injection vulnerability in `host/beectl-py2.py` allowed attackers to pass arbitrary subprocess arguments through a browser extension's JSON configuration, enabling execution of malicious shell commands on the host machine. The fix introduces two new validation functions — `sanitize_args()` and `sanitize_ext()` — that enforce strict type and content constraints on user-controlled input before it reaches the `subprocess` call. This change closes a direct path from browser extens