How Command Injection Happens in Node.js shell-quote and How to Fix It
The Vulnerability at a Glance
| Field | Detail |
|---|---|
| CVE | CVE-2026-9277 |
| Severity | Critical |
| Package | shell-quote |
| Vulnerable version | 1.8.1 (and earlier) |
| Fixed version | 1.8.4 |
| CWE | CWE-78 — OS Command Injection |
| Scanner | Trivy |
Introduction
The shell-quote library is one of those quiet workhorses of the Node.js ecosystem — it sits deep in dependency trees, doing the unglamorous job of turning arrays of strings into properly quoted shell commands. Thousands of tools rely on it: build scripts, linters, test runners, and CI utilities all pass arguments through it before handing them to a shell. That trust makes CVE-2026-9277 particularly dangerous.
In this project's package-lock.json, Trivy flagged shell-quote pinned at version 1.8.1 with a critical severity finding. The root cause: version 1.8.1 does not escape line terminator characters (\n, \r) when quoting shell arguments. An attacker who can influence any string that eventually passes through shell-quote and into a shell can use a bare newline to escape the quoted context and inject arbitrary commands — no exotic bypass required.
The Vulnerability Explained
What shell-quote Does
shell-quote exposes two main functions:
quote(args)— takes an array of strings and returns a single, safely-quoted shell command string.parse(cmd)— parses a shell command string back into tokens.
The intended guarantee of quote() is that even if an argument contains shell metacharacters ($, `, ", ', ;, &, |, etc.), they will be escaped so the shell treats the entire value as a single literal argument. Version 1.8.1 upholds this guarantee for most metacharacters — but not for newlines.
The Unescaped Line Terminator Bug
Consider this simplified but representative usage pattern:
const { quote } = require('shell-quote'); // version 1.8.1
const { execSync } = require('child_process');
// userInput comes from an HTTP request parameter, CLI argument, etc.
function processFile(userInput) {
const cmd = `cat ${quote([userInput])}`;
execSync(cmd, { shell: true });
}
In shell-quote 1.8.1, the quote() function wraps strings in single quotes and escapes embedded single quotes, but it does not strip or escape \n (newline, 0x0A) or \r (carriage return, 0x0D).
A shell interprets a newline as a command separator — functionally identical to a semicolon. So if userInput is:
report.txt\nrm -rf /tmp/important
Then quote(['report.txt\nrm -rf /tmp/important']) in version 1.8.1 produces something like:
'report.txt
rm -rf /tmp/important'
When the shell evaluates this, the newline inside the single-quoted string terminates the first command and begins a new one. The result is two commands executing:
cat 'report.txt← malformed but executedrm -rf /tmp/important'← the injected command (with a trailing quote that most shells tolerate or ignore)
This is a textbook CWE-78 injection: user-controlled data containing a special character (\n) that the sanitizer failed to neutralize reaches a shell execution sink.
Real-World Attack Surface
The severity is amplified by how shell-quote is consumed. It is a transitive dependency of tools like jest, webpack, and various ESLint plugins. Any build or test pipeline that:
- Accepts user-supplied filenames, branch names, commit messages, or environment variables, and
- Passes those values through
shell-quoteintochild_process.exec(),execSync(), or any{ shell: true }variant
…is exploitable. In CI/CD environments where build scripts run with elevated permissions, successful exploitation could mean arbitrary code execution on the build host, secret exfiltration, or supply-chain compromise.
The Fix
What Changed in the Dependency Files
The fix consists of three coordinated changes across the lock files and package manifest:
1. package-lock.json — Version bump
"node_modules/shell-quote": {
- "version": "1.8.1",
- "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.1.tgz",
- "integrity": "sha512-6j1W9l1iAs/4xYBI1SYOVZyFcCis9b4KCLQ8fgAGG07QvzaRLVVRQvAy85yNmmZSjYjg4MWh4gNvlPujU/5LpA==",
+ "version": "1.8.4",
+ "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.4.tgz",
+ "integrity": "sha512-VsC6n6vz1ihYYyZZwX7YZSF5l5x36ca17OC+a69h94YqB7X6XLwf+5MOgynYir2SLFUbl8gIYvBo8K8RoNQ6bQ==",
"license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
The integrity hash changes from the 1.8.1 tarball to the 1.8.4 tarball. npm verifies this hash on install, so any tampered package would be rejected. The addition of the engines field is also a signal that 1.8.4 includes updated metadata.
2. package.json — Overrides and resolutions pinning
+ "shell-quote": "1.8.4"
},
"engines": {
"node": ">=18"
+ },
+ "resolutions": {
+ "shell-quote": "1.8.4"
}
This is the critical defense-in-depth step. Without pinning, any transitive dependency that declares shell-quote: "^1.8.1" in its own package.json could still resolve to the vulnerable version when the lock file is regenerated or when running npm install in a fresh environment. The overrides field (npm 8.3+) and the resolutions field (Yarn) force all resolutions of shell-quote anywhere in the dependency tree to use exactly 1.8.4.
3. yarn.lock — Lock file update
The yarn.lock entry for shell-quote is updated to match the new resolved URL and integrity hash, ensuring Yarn-based installs are equally protected.
Why Version 1.8.4 Fixes It
In shell-quote 1.8.4, the quote() function's internal character-escaping logic was updated to treat \n and \r as characters requiring neutralization — either by escaping them or by encoding the argument in a way the shell cannot misinterpret as a command boundary. This closes the injection vector at the library level.
Prevention & Best Practices
1. Prefer Argument Arrays Over Shell Strings
The safest approach is to never construct a shell string at all:
// ❌ Vulnerable pattern — shell string construction
const { execSync } = require('child_process');
execSync(`cat ${quote([userInput])}`, { shell: true });
// ✅ Safe pattern — argument array, no shell interpolation
const { execFileSync } = require('child_process');
execFileSync('cat', [userInput]); // shell metacharacters are irrelevant
execFile / execFileSync / spawn with an explicit argument array bypass the shell entirely. No quoting library is needed, and no quoting library bug can affect you.
2. Validate Inputs Before They Reach Shell Code
If you must use a shell string, validate that inputs do not contain line terminators or other shell metacharacters before passing them to quote():
function sanitizeShellArg(input) {
if (/[\n\r]/.test(input)) {
throw new Error('Input contains illegal line terminator characters');
}
return input;
}
This is defense-in-depth: even with a patched library, rejecting obviously malicious input early reduces blast radius.
3. Pin Transitive Dependencies
As demonstrated in this fix, use overrides (npm) and resolutions (Yarn) to pin security-sensitive transitive dependencies:
// package.json
{
"overrides": {
"shell-quote": "1.8.4"
},
"resolutions": {
"shell-quote": "1.8.4"
}
}
This prevents a future npm install or lock file regeneration from silently downgrading to a vulnerable version.
4. Run Dependency Audits in CI
Add npm audit --audit-level=high or a Trivy scan to your CI pipeline. CVE-2026-9277 was detected by Trivy scanning package-lock.json — a step that takes seconds and would have caught this before it reached production.
5. Relevant Standards
- OWASP: Command Injection Prevention Cheat Sheet
- CWE-78: Improper Neutralization of Special Elements used in an OS Command
- OWASP Top 10 2021: A03 — Injection
Key Takeaways
shell-quote1.8.1'squote()function did not escape\nor\r, meaning any user-controlled string containing a newline could inject a second shell command regardless of other escaping.- The
package-lock.jsonintegrity hash is your last line of defense against a tampered package — the hash change from 1.8.1 to 1.8.4 is cryptographically verifiable proof that a different, patched tarball is now installed. - Pinning with
overridesandresolutionsinpackage.jsonis essential — without it, transitive dependencies can silently re-introduce the vulnerable version on the nextnpm install. execFile/spawnwith argument arrays is categorically safer thanexec/execSyncwith shell strings, because it eliminates the shell parsing step entirely.- Trivy scanning
package-lock.jsoncaught this — static SCA (Software Composition Analysis) on lock files is a practical, low-overhead control that surfaces vulnerabilities in transitive dependencies that developers rarely inspect manually.
How Orbis AppSec Detected This
- Source: User-influenced string values (filenames, branch names, environment variables, CLI arguments) passed as arguments to
shell-quote'squote()function. - Sink: The quoted string returned by
quote()consumed bychild_process.exec(),execSync(), or any Node.js API invoked with{ shell: true }— where the shell interprets the unescaped\nas a command separator. - Missing control:
shell-quote1.8.1 performed no escaping or rejection of line terminator characters (\n,\r) inside quoted argument strings, leaving a complete bypass of its own quoting guarantee. - CWE: CWE-78 — Improper Neutralization of Special Elements used in an OS Command ('OS Command Injection').
- Fix: Upgraded
shell-quotefrom1.8.1to1.8.4inpackage-lock.json, added a version pin underoverridesinpackage.json, and updatedyarn.lockto enforce the patched version across all dependency resolution paths.
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 guarantees are only as strong as their most obscure edge case. shell-quote correctly escaped dozens of shell metacharacters — but missing just two (\n and \r) was enough to invalidate the entire safety contract and open the door to arbitrary code execution. The fix is straightforward: upgrade to 1.8.4 and pin the version so transitive dependency resolution cannot undo the upgrade. More broadly, prefer execFile with argument arrays over shell string construction whenever possible, and integrate SCA scanning into CI so vulnerabilities in the dependency tree are caught before they reach production.