← Back to Research

The newline that escaped the escaper: CVE-2026-9277 in shell-quote

The shape that matters. A library whose entire job is to make strings safe for a shell trusted a regex that silently ignores newlines. CVE-2026-9277 (CVSS 8.1) is the result: a line terminator passes through quote() unescaped, and a literal newline is a command separator in sh. The escaper became the injection.

The shell-quote npm package exists to do one job well: take a string and make it safe to drop into a shell command. Thousands of tools depend on it transitively to build command lines without opening a command-injection hole. CVE-2026-9277 is a reminder that a security-critical primitive can be undone by a single mismatched assumption about how a regular expression behaves, and that the most dangerous bugs are often three lines long.

What actually broke

The package's quote() function did not validate object-token inputs against the operator model that its companion parse() uses. When escaping an operator token, the .op field was backslash-escaped character by character with the regex /(.)/g. The trap is in the dot. In JavaScript, . does not match line terminators (not \n, not \r, not the Unicode line/paragraph separators U+2028 and U+2029) unless the s (dotAll) flag is set, which it was not.

So when an .op value contained a line terminator, that character was never matched and therefore never escaped. It passed straight through into the output. And a literal newline in a POSIX shell is not whitespace: it is a command separator, exactly like ;. The output of a function whose entire purpose is to neutralize shell metacharacters now contained an unescaped command separator. Whatever followed the newline executes as a fresh command.

That is the whole vulnerability: a quoting function trusted /(.)/g to mean “every character,” when it actually means “every character except line terminators.” The gap between those two readings is a remote command-injection primitive in any tool that feeds attacker-influenced operator tokens through quote() and into a shell.

Why this class is so persistent

Regex line-terminator semantics are one of the most reliable sources of security bugs in the entire industry, and shell-quote is a textbook instance. The same root cause (a pattern that the author believed was anchored or exhaustive but silently is not across newlines) produces authentication bypasses (a validation regex that an attacker escapes with an embedded newline), log-injection, header-injection, and now command-injection. The fix is almost always “the regex did not account for a character class the input could contain.”

The deeper lesson for anyone writing an escaper or a validator: escaping must be exhaustive against the full input alphabet, and the dangerous characters are the ones you forget exist. A character-by-character escaper is only safe if “every character” genuinely means every code point, including the awkward ones: CR, LF, NEL, U+2028, U+2029, NUL. Allowlisting (define what is permitted, reject everything else) is far safer than denylisting (try to neutralize the bad characters), precisely because you cannot forget a character you never had to enumerate.

The defensible test

For your own codebase, the question is not “is shell-quote installed”: it is “is a vulnerable version resolved in my lockfile, and does attacker-influenced data reach quote() and then a shell.” You confirm version by lockfile, and reachability by tracing the call graph. You do not throw newline payloads at production.

# CVE-2026-9277: is a vulnerable shell-quote resolved, and does tainted data reach it?
npm ls shell-quote --all 2>/dev/null        # surface every resolved version (incl. transitive)
# Cross-reference against the fixed release in the advisory.

# Reachability: does user-influenced input flow into quote() and then a shell?
grep -rn "shell-quote\|require('shell-quote')\|from 'shell-quote'" --include=*.js --include=*.ts .
grep -rnE "quote\(.*\b(req|body|query|params|input|user)\b" --include=*.js --include=*.ts .
# FINDING = vulnerable version resolved AND tainted operator tokens reach quote()
# upstream of a child_process / exec call. A pinned fixed version is a PASS.

The fix

How Celvex Sentry tests for this

Our continuous-monitoring suite carries a lockfile-and-reachability check for the dependency-injection class: it resolves the exact version graph against known-bad ranges and traces whether attacker-influenceable input reaches the dangerous sink. For shell-quote specifically, the finding requires both a vulnerable resolved version and a tainted-data path into quote() ahead of a shell. A clean family or a pinned fixed version is a PASS, not a noise finding. When both conditions hold, we mint a Proof Capsule with the resolved-version evidence, the reachable call path, and the upgrade-and-argv-array fix attached.

Sources

Get your exposure check: full report in 4-24 hours

Real assessment on production-grade infrastructure. We prove what is exploitable and attach the fix. Paying customers get priority capacity.

Queue My Assessment