How Command Injection via Unescaped Line Terminators Happens in Node.js and How to Fix It
Introduction
In the docs-site repository, a critical command injection vulnerability (CVE-2026-9277) was discovered lurking in the dependency tree through the shell-quote package. The vulnerability wasn't in custom code—it was in a transitive dependency that handles shell argument escaping, a task that seems simple but has profound security implications.
The issue: shell-quote version 1.8.3 failed to properly escape line terminator characters (newlines and carriage returns) in shell arguments. This seemingly minor oversight created a dangerous window for attackers to inject arbitrary shell commands by embedding these special characters in what should have been safely quoted arguments.
For any Node.js application that uses shell-quote to safely construct shell commands from user-controlled input, this vulnerability represented a direct path to remote code execution. The fix was straightforward but critical: upgrade to version 1.9.0, which implements proper escaping of line terminators.
The Vulnerability Explained
What Made This Dangerous?
The shell-quote package is designed to solve a common problem in Node.js: when you need to pass user-controlled strings as arguments to shell commands, you must properly escape special characters. Without proper escaping, an attacker can inject shell metacharacters (like ;, |, &, $()) to execute arbitrary commands.
However, shell-quote 1.8.3 had a blind spot: it didn't account for line terminator characters (newline \n and carriage return \r). Here's why this matters:
In most shell contexts, when a quoted string contains a newline, the shell interprets it as the end of the current command and the beginning of a new one. An attacker could craft input like:
user input: "safe_arg\nmalicious_command"
When processed by shell-quote 1.8.3 and then passed to a shell, it would be treated as:
safe_arg
malicious_command
Instead of a single safe argument, the attacker has successfully injected a second command that executes with the same privileges as the original process.
The Attack Scenario
Imagine a Node.js application in docs-site that uses shell-quote to safely construct a command for documentation generation:
const shellQuote = require('shell-quote');
const { execSync } = require('child_process');
// User-provided documentation filename
const userFilename = req.query.filename;
// Attempt to safely quote the filename
const quotedFilename = shellQuote.quote([userFilename]);
// Execute a documentation processing command
const command = `process-docs --file ${quotedFilename}`;
execSync(command);
With shell-quote 1.8.3, if an attacker provides:
filename=report.md\nrm -rf /important/data
The resulting command becomes:
process-docs --file report.md
rm -rf /important/data
Both commands execute. The attacker has achieved arbitrary code execution.
Why Line Terminators Were Missed
The vulnerability existed because earlier versions of shell-quote focused on escaping traditional shell metacharacters (;, |, &, $, backticks, etc.) but didn't consider that line terminators could also break the shell context. This is a classic example of incomplete input sanitization—addressing the most obvious attack vectors while missing edge cases.
The scanner (Trivy) flagged this as present in the dependency tree with the assessment "not confirmed reachable," meaning the vulnerability existed in the dependency graph but the actual code path might not have been exercised. However, the principle of defense in depth dictates that you should patch known vulnerabilities regardless—transitive dependencies can be called in unexpected ways, and future code changes might activate the vulnerable path.
The Fix
What Changed?
The fix involved upgrading shell-quote from version 1.8.3 to 1.9.0 and adding an explicit override in package.json to ensure this version is used throughout the dependency tree.
In docs-site/package-lock.json:
"node_modules/shell-quote": {
- "version": "1.8.3",
- "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz",
- "integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==",
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.9.0.tgz",
+ "integrity": "sha512-Iov+JwFv/2HcTpcwNMKd8+IWNb8tboQJNQTkAY/LLVK7gGH9jy+LGkVqPxfekHl+yMmiqXszdGWXgkfml7hjqA==",
In docs-site/package.json:
"engines": {
"node": ">=20.0"
+ },
+ "overrides": {
+ "shell-quote": "1.9.0"
}
How This Solves the Problem
Version 1.9.0 of shell-quote implements proper escaping of line terminator characters. When the package encounters \n or \r in user input, it now escapes them appropriately so they're treated as literal characters within the quoted string, not as command separators.
With the patched version, the attack scenario from earlier is neutralized:
// With shell-quote 1.9.0
const userFilename = "report.md\nrm -rf /important/data";
const quotedFilename = shellQuote.quote([userFilename]);
// Result: properly escapes the newline, preventing command injection
// The entire string is treated as a single filename argument
The overrides field in package.json is particularly important. It ensures that even if another dependency in the tree specifies an older version of shell-quote, npm will use 1.9.0 instead. This prevents the vulnerable version from being installed through transitive dependency resolution.
Why Both Files Were Changed
- package-lock.json: Records the exact version and integrity hash of the installed package, ensuring reproducible builds
- package.json: Adds an explicit override to guarantee that 1.9.0 is used across the entire dependency tree, even if other packages request different versions
This two-pronged approach prevents dependency confusion and ensures the fix persists across team members' installations and CI/CD pipelines.
Prevention & Best Practices
1. Keep Dependencies Updated
Regularly update security-sensitive packages like shell-quote. Enable automated dependency updates through tools like Dependabot or Renovate, which can automatically create PRs for security patches.
2. Use Parameterized APIs
Whenever possible, avoid constructing shell commands from strings. Use APIs that treat arguments as data, not code:
// ❌ Avoid: Command as string
execSync(`process-docs --file ${userFilename}`);
// ✅ Better: Arguments as array (no shell parsing)
execFileSync('process-docs', ['--file', userFilename]);
3. Validate and Sanitize Input
Even with proper escaping, validate that user input matches expected patterns:
// Validate filename format before processing
if (!/^[a-zA-Z0-9_\-\.]+$/.test(userFilename)) {
throw new Error('Invalid filename format');
}
4. Use Security Scanners
Integrate tools like Trivy, npm audit, and Snyk into your CI/CD pipeline to automatically detect vulnerable dependencies:
# Run Trivy to scan for known vulnerabilities
trivy fs docs-site/
# Run npm audit
npm audit --audit-level=moderate
5. Understand CWE-78
Familiarize yourself with CWE-78: Improper Neutralization of Special Elements used in an OS Command. Many command injection vulnerabilities follow similar patterns.
6. Review Transitive Dependencies
Use npm ls to understand your full dependency tree:
npm ls shell-quote
Know what versions of critical security packages are installed, even indirectly.
Key Takeaways
-
Line terminators are special: Command injection isn't limited to traditional shell metacharacters like
;and|. Newlines and carriage returns can be equally dangerous when not properly escaped. -
shell-quote 1.8.3 was incomplete: The package addressed common injection vectors but missed line terminator escaping, demonstrating that security libraries require continuous improvement as attack techniques evolve.
-
Transitive dependencies matter: Even though shell-quote was a transitive dependency in docs-site, the vulnerability posed a real risk. Scanning and patching all layers of the dependency tree is essential.
-
Defense in depth requires override mechanisms: The
overridesfield in package.json is a critical tool for ensuring security patches propagate through complex dependency trees where multiple packages might specify conflicting versions. -
Automated detection is reliable for known CVEs: Trivy and similar scanners can reliably flag vulnerable versions of well-known packages, but human judgment is still required to assess actual exploitability and prioritize fixes.
How Orbis AppSec Detected This
Source: Transitive dependency declaration in docs-site/package.json and docs-site/package-lock.json (shell-quote 1.8.3)
Sink: Any code path that uses shell-quote to escape arguments for shell execution (e.g., in command construction utilities or subprocess wrappers)
Missing control: Absence of line terminator escaping in shell-quote 1.8.3's quoting logic; no explicit version override to enforce patched versions across the dependency tree
CWE: CWE-78 (Improper Neutralization of Special Elements used in an OS Command)
Fix: Upgrade shell-quote from 1.8.3 to 1.9.0 which properly escapes line terminators, and add an explicit npm override to ensure the patched version is used throughout the dependency tree.
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 demonstrates a critical principle in secure coding: security libraries must be comprehensive in their protection. A single missed edge case—in this case, line terminator characters—can completely undermine the security guarantees a library is supposed to provide.
For Node.js developers, this vulnerability reinforces several important practices:
-
Trust but verify: Even widely-used security libraries like shell-quote can have gaps. Keep them updated and monitor security advisories.
-
Defense in depth: Don't rely solely on shell-quote for security. Use parameterized APIs, validate input, and avoid shell=true when possible.
-
Dependency management is security work: Maintaining a secure application means actively managing transitive dependencies, not just direct ones. Tools like npm overrides are your allies.
-
Automation catches what humans miss: Security scanners like Trivy can reliably identify known vulnerable versions. Integrate them into your CI/CD pipeline and act on their findings promptly.
By understanding how this vulnerability worked and why the fix was necessary, you're better equipped to recognize similar issues in your own code and dependencies—and to build systems that are resilient against command injection attacks.