PR github-community-projects#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).
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:
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.
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:
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
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.