How Command Injection happens in Node.js shell-quote and how to fix it
Introduction
The FabricExample/package-lock.json file locked a transitive dependency — shell-quote — to version 1.8.3, a version that contains a critical flaw in how it handles line terminator characters. When shell-quote builds a quoted shell string from user-influenced input, it is supposed to neutralize every character that could be interpreted by a shell as a command separator or metacharacter. In version 1.8.3, newline (\n) and carriage-return (\r) characters slipped through without escaping, creating a textbook OS command injection path.
This matters for any Node.js developer who uses shell-quote — directly or as a transitive dependency — to construct shell strings from dynamic input. The package is extremely common in the JavaScript ecosystem; it appears in build tools, CLI helpers, and React Native tooling (hence its presence in a FabricExample project). A single unescaped line terminator is all an attacker needs to turn a quoted argument into two separate shell commands.
The Vulnerability Explained
What shell-quote does
shell-quote is an npm package that provides two utilities:
quote(args)— takes an array of strings and returns a single shell-safe string.parse(str)— parses a shell string back into an array of tokens.
The quote() function is the dangerous surface here. Its job is to wrap each argument in quotes and escape characters that shells treat as special. The problem in 1.8.3 is that it did not treat \n (U+000A LINE FEED) or \r (U+000D CARRIAGE RETURN) as special.
The vulnerable pattern
Consider this representative usage:
const quote = require('shell-quote').quote;
const { execSync } = require('child_process');
// filename comes from user input, e.g., a form field or API parameter
const filename = req.query.filename;
const cmd = `cat ${quote([filename])}`;
execSync(cmd, { shell: true });
In shell-quote 1.8.3, if filename is:
report.txt\nrm -rf /tmp/important
Then quote(['report.txt\nrm -rf /tmp/important']) returns something like:
'report.txt
rm -rf /tmp/important'
The shell sees a newline inside what was meant to be a single-quoted string. Depending on the shell and quoting style, this can terminate the first command and execute rm -rf /tmp/important as a separate command — arbitrary code execution achieved with a single newline character.
Why this is critical
The CVSS score is critical because:
- No authentication barrier — any endpoint that passes user input through
shell-quoteinto a shell command is exposed. - Trivial to exploit — a newline is a single character, easy to inject via URL encoding (
%0A), JSON strings, or form fields. - Full command execution — the attacker controls everything after the injected newline, including reading secrets, exfiltrating data, or establishing persistence.
- Transitive exposure — most projects don't use
shell-quotedirectly; they inherit it from build tools, meaning the vulnerable version can be buried several levels deep in the dependency tree.
Attack scenario in FabricExample
In the React Native FabricExample project, shell-quote appears as a transitive dependency of build and bundling tooling. If any build script or Metro bundler plugin passes a user-influenced path or module name through shell-quote into a shell command, an attacker who controls that input (e.g., via a crafted module name in a monorepo or a malicious package) could inject commands that execute during the build process — a classic build-time supply chain attack.
The Fix
What changed
The fix adds a single overrides block to FabricExample/package.json:
Before (package.json — no overrides):
{
"engines": {
"node": ">=20"
}
}
After (package.json — with override):
{
"engines": {
"node": ">=20"
},
"overrides": {
"shell-quote": "1.9.0"
}
}
Why this specific change solves the problem
npm's overrides field (introduced in npm 8.3) forces every package in the dependency tree — regardless of what version they declare as a dependency — to resolve shell-quote to 1.9.0. This is the correct tool for patching transitive vulnerabilities when you cannot immediately update every intermediate package that depends on the vulnerable one.
Version 1.9.0 of shell-quote adds explicit escaping for \n and \r inside the quote() function. The patched output for the attack string above would be something like:
'report.txt\nrm -rf /tmp/important'
...where \n is now represented as a literal backslash-n sequence (or the newline is otherwise neutralized), preventing the shell from interpreting it as a command separator.
Why package.json and not package-lock.json
The lockfile (package-lock.json) is generated — editing it directly would be overwritten on the next npm install. The correct and durable fix is the overrides directive in package.json, which instructs npm to regenerate the lockfile with the forced version. This is why the PR modifies FabricExample/package.json and the resulting package-lock.json update is implied.
Prevention & Best Practices
1. Audit transitive dependencies regularly
Run npm audit and supplement it with a dedicated scanner like Trivy or Snyk. npm audit only checks direct and some transitive dependencies against the npm advisory database; Trivy cross-references against multiple CVE feeds and catches cases npm audit misses.
# Quick audit
npm audit
# Trivy scan of the project directory
trivy fs --scanners vuln .
2. Use overrides (npm) or resolutions (Yarn) proactively
When a transitive dependency has a known vulnerability and the intermediate package hasn't released an update yet, use the package manager's override mechanism:
// npm (package.json)
"overrides": {
"vulnerable-package": ">=safe-version"
}
// Yarn (package.json)
"resolutions": {
"vulnerable-package": ">=safe-version"
}
3. Never pass untrusted input to shell-constructing functions
Even with a patched shell-quote, the safest pattern is to avoid shell strings entirely. Use child_process APIs that accept argument arrays:
// ❌ Vulnerable pattern — constructs a shell string
const { execSync } = require('child_process');
execSync(`cat ${quote([userInput])}`, { shell: true });
// ✅ Safe pattern — no shell involved, arguments passed directly
const { execFileSync } = require('child_process');
execFileSync('cat', [userInput]); // userInput cannot inject commands
4. Pin dependency versions in CI
Use npm ci instead of npm install in CI pipelines to enforce the lockfile. Combine this with automated PRs (like the one Orbis AppSec generated) to keep the lockfile current.
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 Cheat Sheet — OS Command Injection Defense: https://cheatsheetseries.owasp.org/cheatsheets/OS_Command_Injection_Defense_Cheat_Sheet.html
Key Takeaways
shell-quote1.8.3 does not escape\nor\r— a single newline in user input is enough to inject an arbitrary shell command, making every call toquote()with untrusted data a potential RCE vector.- Transitive dependencies are attack surface too — the vulnerability lived in
FabricExample/package-lock.jsonas an indirect dependency, not a package the project authors consciously chose to use. - npm
overridesis the right tool for forced transitive upgrades — editingpackage-lock.jsondirectly is fragile; theoverridesfield inpackage.jsonis durable and survivesnpm installregeneration. execFileSyncwith an argument array eliminates this entire class of vulnerability — if the codebase can be refactored to avoid shell strings, no amount of malicious input can cause command injection, regardless of whatshell-quotedoes.- Trivy caught what
npm auditmight miss — using multiple scanning tools with different CVE feeds increases the chance of catching newly disclosed vulnerabilities in transitive dependencies before they are exploited.
How Orbis AppSec Detected This
- Source: User-influenced string data flowing into
shell-quote'squote()function — for example, file paths, module names, or CLI arguments derived from external input in build tooling consumed byFabricExample. - Sink: The
quote()call inshell-quote1.8.3 that produces an unescaped shell string, subsequently passed to a shell-executing API (execSync,exec, or equivalent) withshell: true. - Missing control:
shell-quote1.8.3 lacked escaping for line terminator characters (\n,\r), meaning these characters passed throughquote()verbatim and were interpreted by the shell as command separators. - CWE: CWE-78 — Improper Neutralization of Special Elements used in an OS Command ('OS Command Injection').
- Fix: Added
"overrides": { "shell-quote": "1.9.0" }toFabricExample/package.json, forcing all transitive resolutions ofshell-quoteto the patched version that correctly escapes line terminators.
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 command injection doesn't require exotic techniques — a single unescaped newline character in a widely-used quoting library is enough to achieve arbitrary code execution. The vulnerability in shell-quote 1.8.3 was subtle: the library correctly escaped most shell metacharacters but overlooked line terminators, a gap that attackers can trivially exploit.
The fix applied here — pinning shell-quote to 1.9.0 via npm overrides in FabricExample/package.json — is minimal, targeted, and durable. It addresses the root cause without touching application logic and without risk of breaking valid inputs. More broadly, this incident highlights the importance of treating transitive dependencies as first-class security concerns: scanning them continuously, using override mechanisms to patch them quickly, and designing application code so that user input never reaches shell-constructing functions in the first place.