GHSA-395F-4HP3-45GV
Vulnerability from github – Published: 2026-07-20 21:49 – Updated: 2026-07-20 21:49Summary
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:
}).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
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.
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+, butshell-quotedeclaresengines: node >= 0.4, so.flat()would silently drop support for older runtimes. The fix instead flattens one-level array tokens withforEach/push— and deliberately notpush.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,undefinedholes, 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.
{
"affected": [
{
"database_specific": {
"last_known_affected_version_range": "\u003c= 1.8.4"
},
"package": {
"ecosystem": "npm",
"name": "shell-quote"
},
"ranges": [
{
"events": [
{
"introduced": "0"
},
{
"fixed": "1.9.0"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [
"CVE-2026-13311"
],
"database_specific": {
"cwe_ids": [
"CWE-407"
],
"github_reviewed": true,
"github_reviewed_at": "2026-07-20T21:49:34Z",
"nvd_published_at": "2026-06-25T05:16:52Z",
"severity": "HIGH"
},
"details": "### Summary\n`shell-quote`\u0027s `parse()` finalizes its token list with a `reduce` that uses\n`Array.prototype.concat` as the accumulator. Each `prev.concat(arg)` copies the entire growing\narray, so `parse()` runs in **O(n\u00b2)** in the number of tokens. An unauthenticated attacker who\ncan submit a string to any code path that calls `parse()` on it can block the single-threaded\nNode.js event loop for tens of seconds with a small input \u2014 a denial of service. The trigger\nneeds **no shell metacharacters** (plain space-separated words suffice), so input filters that\nonly screen for `;`, `|`, `$`, or backticks do not help.\n\n### Root cause\n`parse.js` (lines 200\u2013203), in `parseInternal` \u2014 this path runs on **every** `parse()` call:\n\n```js\n}).reduce(function (prev, arg) { // finalize parsed arguments\n // TODO: replace this whole reduce with a concat\n return typeof arg === \u0027undefined\u0027 ? prev : prev.concat(arg);\n}, []);\n```\n\n`prev.concat(arg)` allocates a new array and copies all of `prev` on every iteration, so\nproducing an N-token result costs `1 + 2 + \u2026 + N = O(N\u00b2)` copies. A second `acc.concat(s)`\nreduce in the `module.exports` wrapper (lines 211\u2013224, reached only when `env` is a function)\nhas the same shape. The maintainer\u0027s own `// TODO: replace this whole reduce with a concat`\nalready flags the construct.\n\n### Proof of Concept\n```js\nconst { parse } = require(\u0027shell-quote\u0027);\nconst ms = fn =\u003e { const t = process.hrtime.bigint(); fn(); return Number(process.hrtime.bigint()-t)/1e6; };\nfor (const N of [16000, 32000, 64000, 128000]) {\n console.log(N, \u0027tokens -\u003e\u0027, ms(() =\u003e parse(\u0027x \u0027.repeat(N))).toFixed(0), \u0027ms\u0027);\n}\n```\n\nMeasured on `shell-quote@1.8.4`, Node v24:\n\n| input (N tokens) | bytes | `parse()` | ratio vs prev (2\u00d7 input) |\n|-----------------:|-------:|----------:|:------------------------:|\n| 16 000 | 32 KB | 678 ms | \u2014 |\n| 32 000 | 64 KB | 4 169 ms | \u00d76.2 |\n| 64 000 | 128 KB | 14 914 ms | \u00d73.6 |\n| 128 000 | 256 KB | **57 319 ms** | \u00d73.8 |\n\nTime grows ~\u00d74 per 2\u00d7 input \u2192 confirmed O(n\u00b2). A ~128 KB input blocks the event loop ~15 s;\n~256 KB \u2192 ~57 s; a few hundred KB more \u2192 minutes.\n\u003cimg width=\"656\" height=\"214\" alt=\"image\" src=\"https://github.com/user-attachments/assets/e8955b0e-0527-45ca-94b7-c3a2d8c0c82e\" /\u003e\n[poc.js](https://github.com/user-attachments/files/29255995/poc.js)\n\n### Impact\n`parse()` is synchronous on the main thread; while it copies arrays quadratically the entire\nevent loop is blocked and the process serves no other requests. Any service that calls `parse()`\non attacker-influenced input (command parsers, chat-ops / bot command handlers, REPLs,\nbuild-script / arg-string splitters) can be driven to a sustained DoS with a single small\nrequest. No code execution and no data disclosure \u2014 availability only.\n\nEnd-to-end confirmation: a minimal HTTP server that calls `parse()` on the request body, hit\nwith **one** `POST` of `\u0027x \u0027.repeat(32000)` (~63 KB), froze for ~4.5 s. An out-of-process probe\nclient issuing harmless `GET /ping` requests (normally ~1 ms) observed **27 consecutive pings\nstalled by up to 4374 ms** during that single request \u2014 i.e. every concurrent client was denied\nservice for the whole parse. Scaling the body to a few hundred KB extends the outage to minutes.\n\nThis is the same class as several accepted 2026 advisories for quadratic-parser DoS on\nuntrusted input (e.g. markdown-it CVE-2026-48988, js-yaml CVE-2026-53550,\npython-multipart CVE-2026-53539). It is **distinct** from the known `shell-quote`\ncommand-injection issues (CVE-2021-42740, CVE-2016-10541, CVE-2026-9277), which are all in\n`quote()`, not `parse()`.\n\n### Suggested remediation\nReplace the O(n\u00b2) concat-in-reduce with a linear flatten that **pushes into the\naccumulator** instead of reallocating and copying it on every iteration. Apply the\nsame shape to the wrapper\u0027s `acc.concat(s)` reduce. A defensive input-length cap on\n`parse()` is a cheap additional stop-gap.\n\n\u003e **Maintainer note (edit):** the originally-suggested `Array.prototype.flat()` is\n\u003e ES2019 / Node 11+, but `shell-quote` declares `engines: node \u003e= 0.4`, so `.flat()`\n\u003e would silently drop support for older runtimes. The fix instead flattens one-level\n\u003e array tokens with `forEach`/`push` \u2014 and deliberately not `push.apply(...)`, since\n\u003e spreading a large array into function arguments can exceed the engine\u0027s argument\n\u003e count limit. Output is byte-identical to the current code across strings, `undefined`\n\u003e holes, one-level array tokens, and `{op}`/`{comment}`/`{op:\u0027glob\u0027}` objects, and\n\u003e finalizing is now linear (1,024,000 tokens in ~150 ms vs ~57 s for 128,000 before).\n\u003e Thanks for the clear report and PoC \u2014 the analysis and reproduction were spot on.\n\n### Disclosure\nFound by source audit + wall-clock confirmation against 1.8.4 (and verified the same code is\npresent on `main`). Reported privately here; no public disclosure until a fix is available.",
"id": "GHSA-395f-4hp3-45gv",
"modified": "2026-07-20T21:49:34Z",
"published": "2026-07-20T21:49:34Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/ljharb/shell-quote/security/advisories/GHSA-395f-4hp3-45gv"
},
{
"type": "ADVISORY",
"url": "https://nvd.nist.gov/vuln/detail/CVE-2026-13311"
},
{
"type": "WEB",
"url": "https://github.com/ljharb/shell-quote/commit/7ff5488599d01c323514f02f5efb74088dd134ec"
},
{
"type": "PACKAGE",
"url": "https://github.com/ljharb/shell-quote"
},
{
"type": "WEB",
"url": "https://github.com/ljharb/shell-quote/releases/tag/v1.9.0"
},
{
"type": "WEB",
"url": "https://www.npmjs.com/package/shell-quote"
}
],
"schema_version": "1.4.0",
"severity": [
{
"score": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H",
"type": "CVSS_V3"
},
{
"score": "CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:N/VA:H/SC:N/SI:N/SA:N",
"type": "CVSS_V4"
}
],
"summary": "shell-quote: Quadratic-complexity Denial of Service in `parse()` (CWE-407)"
}
Sightings
| Author | Source | Type | Date | Other |
|---|
Nomenclature
- Seen: The vulnerability was mentioned, discussed, or observed by the user.
- Confirmed: The vulnerability has been validated from an analyst's perspective.
- Published Proof of Concept: A public proof of concept is available for this vulnerability.
- Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
- Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
- Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
- Not confirmed: The user expressed doubt about the validity of the vulnerability.
- Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.