Skip to content

fix: prevent prototype pollution in mergeDeep#985

Open
gistrec wants to merge 1 commit into
github:main-enterprisefrom
gistrec:hardening/mergedeep-prototype-pollution-guard
Open

fix: prevent prototype pollution in mergeDeep#985
gistrec wants to merge 1 commit into
github:main-enterprisefrom
gistrec:hardening/mergedeep-prototype-pollution-guard

Conversation

@gistrec
Copy link
Copy Markdown

@gistrec gistrec commented May 20, 2026

Summary

PR #712 added a __proto__ / constructor guard to MergeDeep.compareDeep (lib/mergeDeep.js:95-97). The sibling function MergeDeep.mergeDeep — which performs the same kind of recursive object merging and is the one that actually applies the parsed YAML / JSON config into the running target — did not receive the matching guard. This PR closes that gap.

Where the gap was

MergeDeep.mergeDeep had two places where a __proto__ key on a parsed config object could redirect the merged target's prototype chain:

  1. mergeEmptyTarget (lib/mergeDeep.js:312 pre-PR): used target = Object.assign({}, source). When source is the result of JSON.parse or js-yaml's loader and contains __proto__ as an own enumerable property, Object.assign invokes the __proto__ setter on the new target and rewrites its prototype. Replaced with a manual copy that skips both reserved keys.

  2. mergeDeep main loop (lib/mergeDeep.js:327 pre-PR): iterated every key on source, including __proto__ and constructor, before recursing into this.mergeDeep(target[key], source[key]). Added the same guard that already exists in compareDeep.

Test

Added mergeDeep does not allow prototype pollution in test/unit/lib/mergeDeep.test.js. It feeds a JSON-parsed __proto__ / constructor payload through mergeDeep, then asserts:

  • Object.prototype is not modified;
  • a fresh {} does not carry the payload;
  • the returned merged object does not carry the payload.

I confirmed the regression test fails on main-enterprise with only the second fix applied (the first hop pollutes via mergeEmptyTarget before the loop guard runs), and passes once both fixes are in place.

Verification

npm ci
npx jest test/unit/lib/mergeDeep.test.js      # 26 / 26 passed (was 25)
npx jest --roots=lib --roots=test/unit         # 117 / 117 passed, 14 skipped

Both fixes preserve existing behavior for all non-__proto__ / non-constructor keys, so the rest of the suite is unchanged.

Notes

I treated this as a defense-in-depth follow-up to PR #712 rather than as a separately reportable issue: configs are sourced from the admin repo, and the realistic write paths into them are already privileged. The patch matches the pattern the project already chose for compareDeep, so the two recursive merge functions behave consistently.

PR github#712 added a `__proto__` / `constructor` guard to `MergeDeep.compareDeep`
(see lib/mergeDeep.js:95-97). The sibling function `MergeDeep.mergeDeep`,
which performs the same kind of recursive object merging and is the one
that actually applies the parsed YAML / JSON config into the running
target, did not receive the matching guard.

Two paths still allowed an attacker-controlled `__proto__` key in a parsed
config object to redirect the merged target's prototype chain:

1. `mergeEmptyTarget` (line 312) used `Object.assign({}, source)`. When
   `source` has `__proto__` as an own enumerable property (which is what
   `JSON.parse` and `js-yaml` produce), `Object.assign` invokes the
   `__proto__` setter on the new target and rewrites its prototype.
   Replaced with a manual copy that skips both reserved keys.

2. `mergeDeep`'s main `for..in` loop (line 327) iterated every key on
   `source`, including `__proto__` and `constructor`, before recursing
   into `this.mergeDeep(target[key], source[key])`. Added the same guard
   as `compareDeep`.

Added a regression test in `test/unit/lib/mergeDeep.test.js` that calls
`mergeDeep({}, JSON.parse('{"__proto__":{"polluted":"yes"}, ...}'))` and
asserts that `Object.prototype` and the returned object are both clean.

`npm run test:unit` passes (117 tests, +1 from this change).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant