Where
-Infinity
0
Severity
9.2
EPSS
0.85%
OS Command Injection, Command Injection
AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:H

Summary

shell-quote's quote() function did not validate object-token inputs against the operator model used by parse(). The .op field was backslash-escaped character by character using /(.)/g, which in JavaScript does not match line terminators (\n, \r, U+2028, U+2029). A line terminator in .op therefore passed through unescaped into the output; POSIX shells treat a literal \n as a command separator, so any content after it would execute as a second command.

The vulnerable code path is reachable in two ways. Neither requires the parser to misbehave — parse() only emits ops from a fixed control set — but both are documented API surface:

1. Direct construction. A caller builds { op: '...\n...' } from external input (e.g. a deserialized argument array) and passes it to quote(). 2. envFn return. parse(cmd, envFn) is documented to splice the return value of envFn into the result array when it is an object. An attacker-influenced data source consulted by envFn can introduce an object token whose .op reaches quote().

Impact

Shell command injection in callers that pass object tokens with attacker-influenced .op values to quote() and then hand the result to a shell. The preconditions are narrower than ordinary string injection — they require the caller to feed object tokens into quote() — but object tokens are a public, documented part of the API surface, and quote() is intended to be a shell-safety boundary.

PoC

js const { parse, quote } = require('shell-quote');

// Direct construction quote([{ op: ';\nid' }]); // → "\;\n\\i\\d" ← literal newline; second line executes as a command

// Via parse() with an envFn returning attacker-shaped objects const tokens = parse('echo $X', () => ({ op: ';\nid' })); require('childprocess').execSync(quote(tokens), { shell: true }); // Executes id after echo \;.

Confirmed under sh, bash, dash, and zsh.

Patch

Fixed by replacing the per-character escape with strict shape validation in quote(). The object-token branch now:

- { op } — .op must be a string from the same allowlist the parser emits (||, &&, ;;, |&, <(, <<<, >>, >&, <&, &, ;, (, ), |, <, >). Anything else throws TypeError. This is the direct fix for the reported issue and removes the entire class of .op injection. - { op: 'glob', pattern } — .pattern must be a string with no line terminators. Glob metacharacters (, ?, [, ], {, }, ,) pass through; all other shell-special characters are backslash-escaped. (Previously the pattern field was discarded entirely and the literal string \g\l\o\b was emitted — a latent bug, not security-relevant.) - { comment } — .comment must be a string with no line terminators (line terminators would end the shell comment and resume command parsing — same injection shape). - Any other object shape — TypeError.

The fix is allowlist-based rather than a targeted regex tweak, so it closes the reported vector and forecloses adjacent ones (U+2028 / U+2029 line separators in .op, line terminators in comments, unknown-shape objects coerced through .replace).

Workarounds

Prior to upgrading, callers that build object tokens from untrusted input should validate .op against the parser's operator set themselves, and never construct { op } from attacker-controlled strings.

Credits

Reported by Akshat Sinha

1 / 3
Source: GitHub
First published (updated )
Severity
8.7
AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H

Summary shell-quote's parse() finalizes its token list with a reduce that uses Array.prototype.concat as the accumulator. Each prev.concat(arg) copies the entire growing array, so parse() runs in O(n²) in the number of tokens. An unauthenticated attacker who can submit a string to any code path that calls parse() on it can block the single-threaded Node.js event loop for tens of seconds with a small input — a denial of service. The trigger needs no shell metacharacters (plain space-separated words suffice), so input filters that only screen for ;, |, $, or backticks do not help.

Root cause parse.js (lines 200–203), in parseInternal — this path runs on every parse() call:

js }).reduce(function (prev, arg) { // finalize parsed arguments // TODO: replace this whole reduce with a concat return typeof arg === 'undefined' ? prev : prev.concat(arg); }, []);

prev.concat(arg) allocates a new array and copies all of prev on every iteration, so producing an N-token result costs 1 + 2 + … + N = O(N²) copies. A second acc.concat(s) reduce in the module.exports wrapper (lines 211–224, reached only when env is a function) has the same shape. The maintainer's own // TODO: replace this whole reduce with a concat already flags the construct.

Proof of Concept js const { parse } = require('shell-quote'); const ms = fn => { const t = process.hrtime.bigint(); fn(); return Number(process.hrtime.bigint()-t)/1e6; }; for (const N of [16000, 32000, 64000, 128000]) { console.log(N, 'tokens ->', ms(() => parse('x '.repeat(N))).toFixed(0), 'ms'); }

Measured on shell-quote@1.8.4, Node v24:

| input (N tokens) | bytes | parse() | ratio vs prev (2× input) | |-----------------:|-------:|----------:|:------------------------:| | 16 000 | 32 KB | 678 ms | — | | 32 000 | 64 KB | 4 169 ms | ×6.2 | | 64 000 | 128 KB | 14 914 ms | ×3.6 | | 128 000 | 256 KB | 57 319 ms | ×3.8 |

Time grows ~×4 per 2× input → confirmed O(n²). A ~128 KB input blocks the event loop ~15 s; ~256 KB → ~57 s; a few hundred KB more → minutes. <img width="656" height="214" alt="image" src="https://github.com/user-attachments/assets/e8955b0e-0527-45ca-94b7-c3a2d8c0c82e" /> poc.js

Impact parse() is synchronous on the main thread; while it copies arrays quadratically the entire event loop is blocked and the process serves no other requests. Any service that calls parse() on attacker-influenced input (command parsers, chat-ops / bot command handlers, REPLs, build-script / arg-string splitters) can be driven to a sustained DoS with a single small request. No code execution and no data disclosure — availability only.

End-to-end confirmation: a minimal HTTP server that calls parse() on the request body, hit with one POST of 'x '.repeat(32000) (~63 KB), froze for ~4.5 s. An out-of-process probe client issuing harmless GET /ping requests (normally ~1 ms) observed 27 consecutive pings stalled by up to 4374 ms during that single request — i.e. every concurrent client was denied service for the whole parse. Scaling the body to a few hundred KB extends the outage to minutes.

This is the same class as several accepted 2026 advisories for quadratic-parser DoS on untrusted input (e.g. markdown-it CVE-2026-48988, js-yaml CVE-2026-53550, python-multipart CVE-2026-53539). It is distinct from the known shell-quote command-injection issues (CVE-2021-42740, CVE-2016-10541, CVE-2026-9277), which are all in quote(), not parse().

Suggested remediation Replace the O(n²) concat-in-reduce with a linear flatten that pushes into the accumulator instead of reallocating and copying it on every iteration. Apply the same shape to the wrapper's acc.concat(s) reduce. A defensive input-length cap on parse() is a cheap additional stop-gap.

Maintainer note (edit): the originally-suggested Array.prototype.flat() is ES2019 / Node 11+, but shell-quote declares engines: node >= 0.4, so .flat() would silently drop support for older runtimes. The fix instead flattens one-level array tokens with forEach/push — and deliberately not push.apply(...), since spreading a large array into function arguments can exceed the engine's argument count limit. Output is byte-identical to the current code across strings, undefined holes, one-level array tokens, and {op}/{comment}/{op:'glob'} objects, and finalizing is now linear (1,024,000 tokens in ~150 ms vs ~57 s for 128,000 before). Thanks for the clear report and PoC — the analysis and reproduction were spot on.

Disclosure Found by source audit + wall-clock confirmation against 1.8.4 (and verified the same code is present on main). Reported privately here; no public disclosure until a fix is available.

1 / 2
Source: GitHub
First published (updated )
Severity
7

shell-quote's quote() function did not validate object-token inputs against the operator model used by parse(). The .op field was backslash-escaped character by character using /(.)/g, which in JavaScript does not match line terminators (\n, \r, U+2028, U+2029). A line terminator in .op therefore passed through unescaped into the output; POSIX shells treat a literal newline as a command separator, so any content after it would execute as a second command. The vulnerable code path is reachable in two ways: (1) direct construction of { op: '...\n...' } from external input, and (2) via parse(cmd, envFn) when envFn returns object tokens whose .op is attacker-influenced. Both are documented API surface. Fixed by replacing the per-character escape with strict shape validation: .op must match the parser's control-operator allowlist; { op: 'glob', pattern } validates pattern and forbids line terminators; { comment } validates comment and forbids line terminators; any other object shape throws TypeError.

First published (updated )

shell-quote before 1.8.4 contains a command injection issue in quote() when applications pass object tokens with attacker-influenced .op values to quote() and then execute the resulting string through a POSIX shell.

Affected versions: <= 1.8.3 Fixed version: 1.8.4 CVSS v3.1: 8.1 (High)

The issue is caused by quote() escaping .op character-by-character using /(.)/g. In JavaScript this does not match line terminators, so a newline in .op can pass through unescaped. In POSIX shells, a literal newline is a command separator, so attacker-controlled content after it may execute as a second command.

This requires a narrower integration pattern than ordinary string-based shell injection: the application must pass object tokens into quote(). However, object tokens are part of the documented API surface, including via parse(cmd, envFn) when envFn returns an object.

Proof of concept (Node.js, shell-quote 1.8.3):

const { quote } = require('shell-quote'); const s = quote([{ op: ';\nid' }]); console.log(JSON.stringify(s)); require('childprocess').execSync(s, { shell: '/bin/sh' });

In local testing, this produced a quoted string containing a literal newline and caused /bin/sh to execute id as a second command.

The fix in 1.8.4 replaces escaping of arbitrary .op strings with strict validation and allowlisting of valid operator values.

Timeline: - 2026-05-09: issue reported - 2026-05-22: advisory published, CVE assigned, and 1.8.4 released

References: https://github.com/ljharb/shell-quote/security/advisories/GHSA-w7jw-789q-3m8p https://www.npmjs.com/package/shell-quote

Contact

SecAlerts Pty Ltd.
132 Wickham Terrace
Fortitude Valley,
QLD 4006, Australia
info@secalerts.co
By using SecAlerts services, you agree to our services end-user license agreement. This website is safeguarded by reCAPTCHA and governed by the Google Privacy Policy and Terms of Service. All names, logos, and brands of products are owned by their respective owners, and any usage of these names, logos, and brands for identification purposes only does not imply endorsement. If you possess any content that requires removal, please get in touch with us.
© 2026 SecAlerts Pty Ltd.
ABN: 70 645 966 203, ACN: 645 966 203