{"repro_id":"REPRO-2026-00135","version":8,"title":"jsondiffpatch: prototype pollution via crafted delta in patch()","repro_type":"security","status":"published","severity":"high","description":"`jsondiffpatch.patch(object, delta)` and the `jsonpatch` formatter's `patch()`\napply a delta / JSON patch to a target object by walking the object along\nproperty names and path segments taken from the delta. Through `0.7.5` they do\n**not** reject dangerous segments such as `__proto__` or\n`constructor.prototype`.\n\nBecause `someObject.__proto__` is the *live* `Object.prototype`, a crafted\ndelta whose path targets `__proto__` lets an attacker steer `patch()` out of\nthe intended object and into the built-in `Object.prototype`, then **assign an\nattacker-chosen value onto it**. The write affects the prototype globally for\nthe rest of the Node.js process, so every plain object inherits the injected\nproperty — a prototype pollution integrity issue.\n\nAny code that forwards an attacker-influenced delta into `jsondiffpatch.patch()`\nor the `jsonpatch` formatter's `patch()` (for example a delta deserialized from\nuntrusted JSON request input) is exploitable.","root_cause":"# RCA Report — CVE-2026-8657\n\n## Summary\n\n`jsondiffpatch` versions before `0.7.6` are vulnerable to prototype pollution in `patch()`, `unpatch()`, `reverse()`, and the JSON Patch formatter's `patch()`. When applying a delta or JSON Patch document, the library traverses the target object using attacker-controlled property names and path segments without rejecting dangerous keys such as `__proto__` or `constructor.prototype`. This allows an attacker to write arbitrary values into `Object.prototype`, affecting every plain object in the running Node.js process.\n\n## Impact\n\n- **Package**: `jsondiffpatch` (npm)\n- **Repository**: https://github.com/benjamine/jsondiffpatch\n- **Affected versions**: `< 0.7.6`\n- **Fixed version**: `0.7.6`\n- **CWE**: CWE-1321 (Prototype Pollution)\n- **Severity**: High (CVSS 3.1 base 8.2)\n\nAny code that forwards an attacker-influenced delta or JSON Patch into `jsondiffpatch.patch()` or the `jsonpatch` formatter's `patch()` (for example, a delta deserialized from untrusted JSON request input) can be exploited to pollute the global prototype.\n\n## Root Cause\n\nThe root cause lies in two places in the vulnerable code (`v0.7.5`):\n\n1. **`packages/jsondiffpatch/src/filters/nested.ts`**: The `patchFilter`, `collectChildrenPatchFilter`, `reverseFilter`, and `collectChildrenReverseFilter` functions iterate over delta keys and patch children using property names taken directly from the delta. They do **not** reject `__proto__` or guard against inherited properties like `constructor.prototype`. For example:\n   ```typescript\n   for (const name in objectDelta) {\n     const child = new PatchContext(\n       (context.left as Record<string, unknown>)[name],\n       objectDelta[name],\n     );\n     context.push(child, name);\n   }\n   ```\n   When `name` is `__proto__`, `context.left.__proto__` resolves to `Object.prototype`, causing writes to the global prototype.\n\n2. **`packages/jsondiffpatch/src/formatters/jsonpatch-apply.ts`**: The `parsePathFromRFC6902()` function splits JSON Pointer paths into segments without validating them. When a segment is `__proto__`, the subsequent `add()` or `replace()` operations write into `Object.prototype`.\n\nThe fix commit `381c0125efab49f6f0dbc08317d01d55717672af` addresses this by:\n- Introducing an `UNSAFE_KEYS` set containing `__proto__` (and `constructor` / `prototype` for the JSON Patch formatter).\n- Skipping delta keys that match unsafe names in all patch/unpatch/reverse filters.\n- Using `Object.prototype.hasOwnProperty.call(left, name)` to prevent traversal into inherited properties (mitigating `constructor.prototype` attacks).\n- Adding validation in `parsePathFromRFC6902()` that throws for unsafe path segments in JSON Patch operations.\n\n## Reproduction Steps\n\nThe reproduction script is `repro/reproduction_steps.sh`. It performs the following steps:\n\n1. Installs `jsondiffpatch@0.7.5` (vulnerable) and `jsondiffpatch@0.7.6` (fixed) in separate scratch directories.\n2. Runs a shared ES module test against each installation.\n3. The test attempts five prototype-pollution vectors:\n   - `jsondiffpatch.patch()` with a delta containing `__proto__`\n   - `jsondiffpatch.patch()` with a delta containing `constructor.prototype`\n   - `jsonpatchFormatter.patch()` with an `add` operation targeting `/__proto__/pp3`\n   - `jsonpatchFormatter.patch()` with a `replace` operation targeting `/__proto__/pp4`\n   - `jsondiffpatch.unpatch()` with a delta containing `constructor.prototype`\n4. After each attempt, the test checks whether a plain `{}` inherits the injected property. If so, it logs the polluted property names.\n5. The script verifies:\n   - **Vulnerable version (0.7.5)**: prints `VULNERABLE` and exits `0`\n   - **Fixed version (0.7.6)**: prints `NOT_VULNERABLE` and exits `1`\n\n## Evidence\n\n- **Vulnerable test log**: `logs/test_vuln.log`\n  ```\n  === Testing vulnerable version 0.7.5 ===\n  POLLUTED patch_proto: pp1\n  POLLUTED patch_constructor: pp2\n  POLLUTED jsonpatch_add: pp3\n  POLLUTED jsonpatch_replace: pp4\n  VULNERABLE\n  ```\n\n- **Fixed test log**: `logs/test_fixed.log`\n  ```\n  === Testing fixed version 0.7.6 ===\n  NOT_VULNERABLE\n  ```\n\n- **Runtime manifest**: `repro/runtime_manifest.json` (written on successful reproduction)\n\n- **Environment**: Node.js v22.22.2, `jsondiffpatch@0.7.5` and `@0.7.6` installed via npm.\n\n## Recommendations / Next Steps\n\n1. **Upgrade immediately** to `jsondiffpatch >= 0.7.6`.\n2. **Input validation**: If you must accept deltas or JSON Patches from untrusted sources, validate them independently before passing them to `jsondiffpatch.patch()` or the JSON Patch formatter.\n3. **Regression testing**: Include the provided test cases in your CI pipeline to guard against future regressions. The upstream project has added 7 regression tests covering all attack vectors.\n4. **Runtime hardening**: Consider using `Object.freeze(Object.prototype)` or libraries like `safe-object-assign` to make prototype pollution harder to exploit in production environments.\n\n## Additional Notes\n\n- **Idempotency**: The reproduction script was run twice consecutively with identical results.\n- **Edge cases**: The fix intentionally preserves legitimate use cases (e.g., objects that have their own `constructor` property). The upstream test suite includes a dedicated test (`should still correctly patch objects with a legitimate 'constructor' own property`) confirming this.\n- **Scope**: While the reproduction focuses on `patch()` and `jsonpatchFormatter.patch()`, the fix also protects `unpatch()` and `reverse()` because they share the same traversal logic.\n","cve_id":"CVE-2026-8657","cwe_id":"CWE-1321 (Prototype Pollution)","package":{"name":"jsondiffpatch","ecosystem":"npm","affected_versions":"< 0.7.6","fixed_version":"0.7.6"},"reproduced_at":"2026-05-22T09:33:23.097448+00:00","duration_secs":1406.2189328670502,"tool_calls":144,"turns":128,"handoffs":3,"total_cost_usd":1.3084686200000002,"agent_costs":{"repro":0.27078932000000006,"support":0.01934837,"vuln_variant":1.01833093},"cost_breakdown":{"repro":{"accounts/fireworks/models/kimi-k2p6":0.27078932000000006},"support":{"accounts/fireworks/models/kimi-k2p6":0.01934837},"vuln_variant":{"accounts/fireworks/models/kimi-k2p6":1.01833093}},"quality":{"idempotent_verified":false,"community_verifications":0},"published_at":"2026-05-22T09:33:25.304026+00:00","retracted":false,"artifacts":[{"path":"repro/rca_report.md","filename":"rca_report.md","size":5571,"category":"analysis"},{"path":"repro/reproduction_steps.sh","filename":"reproduction_steps.sh","size":4787,"category":"reproduction_script"},{"path":"vuln_variant/rca_report.md","filename":"rca_report.md","size":6337,"category":"analysis"},{"path":"vuln_variant/reproduction_steps.sh","filename":"reproduction_steps.sh","size":6971,"category":"reproduction_script"},{"path":"bundle/context.json","filename":"context.json","size":3284,"category":"other"},{"path":"bundle/metadata.json","filename":"metadata.json","size":632,"category":"other"},{"path":"bundle/ticket.md","filename":"ticket.md","size":3780,"category":"ticket"},{"path":"repro/runtime_manifest.json","filename":"runtime_manifest.json","size":516,"category":"other"},{"path":"repro/validation_verdict.json","filename":"validation_verdict.json","size":1534,"category":"other"},{"path":"vuln_variant/patch_analysis.md","filename":"patch_analysis.md","size":6403,"category":"documentation"},{"path":"vuln_variant/variant_manifest.json","filename":"variant_manifest.json","size":3361,"category":"other"},{"path":"vuln_variant/validation_verdict.json","filename":"validation_verdict.json","size":1443,"category":"other"},{"path":"vuln_variant/test_variants.mjs","filename":"test_variants.mjs","size":2733,"category":"other"},{"path":"logs/npm_install_vuln.log","filename":"npm_install_vuln.log","size":0,"category":"log"},{"path":"logs/fixed_variant_test.log","filename":"fixed_variant_test.log","size":15,"category":"log"},{"path":"logs/npm_install_fixed_variant.log","filename":"npm_install_fixed_variant.log","size":0,"category":"log"},{"path":"logs/vuln_variant_test.log","filename":"vuln_variant_test.log","size":205,"category":"log"},{"path":"logs/npm_install_vuln_variant.log","filename":"npm_install_vuln_variant.log","size":0,"category":"log"},{"path":"logs/npm_install_fixed.log","filename":"npm_install_fixed.log","size":0,"category":"log"},{"path":"logs/test_fixed.log","filename":"test_fixed.log","size":51,"category":"log"},{"path":"logs/test_vuln.log","filename":"test_vuln.log","size":170,"category":"log"}]}