Skip to content

fix(commons): skip __proto__ / constructor / prototype keys in _.merge#3687

Closed
ridingsa wants to merge 1 commit into
feathersjs:dovefrom
ridingsa:fix/commons-merge-skip-prototype-keys
Closed

fix(commons): skip __proto__ / constructor / prototype keys in _.merge#3687
ridingsa wants to merge 1 commit into
feathersjs:dovefrom
ridingsa:fix/commons-merge-skip-prototype-keys

Conversation

@ridingsa

@ridingsa ridingsa commented Jun 2, 2026

Copy link
Copy Markdown

Summary

The recursive _.merge implementation in @feathersjs/commons iterates Object.keys(source) and calls _.merge(target[key], source[key]) for nested objects. Object.keys returns __proto__ as an own-enumerable key when the source object came from JSON.parse('{"__proto__":...}') — unlike the object-literal {__proto__: ...} form, JSON-parsed __proto__ is an own enumerable property, not the prototype slot.

Without filtering those keys, the recursive call resolves target['__proto__'] to Object.prototype (since it's truthy, the if (!target[key]) branch is skipped) and writes onto it. The same shape applies to constructor.prototype chains.

This PR adds the standard prototype-mutating-key filter (__proto__, constructor, prototype) inside the iteration — the same fix lodash applies in baseSet, cosmiconfig applies in its merge, and several other widely-used libraries have landed for the same bug class.

Diff (3 lines of substantive change)

       merge(target: any, source: any) {
         if (_.isObject(target) && _.isObject(source)) {
           Object.keys(source).forEach((key) => {
+            // Skip prototype-chain-mutating keys.
+            if (key === '__proto__' || key === 'constructor' || key === 'prototype') {
+              return
+            }
             if (_.isObject(source[key])) {
               if (!target[key]) {
                 Object.assign(target, { [key]: {} })
               }
               _.merge(target[key], source[key])
             } else {
               Object.assign(target, { [key]: source[key] })
             }
           })
         }
         return target
       }

Tests

  • All 21 existing packages/commons tests pass.
  • One new test added under the existing _ describe block confirming that passing a {"__proto__":...} JSON-parsed source does not mutate Object.prototype, fresh-{} reads, or the target object. Also covers the constructor.prototype shape.

Run output:

> @feathersjs/commons@5.0.44 test
> mocha --config ../../.mocharc.json --recursive test/**.test.ts test/**/*.test.ts

  .....................

  21 passing (5ms)

Notes

  • No public API change — merge continues to work for all documented input shapes; only the (likely-unintended) prototype mutation is removed.
  • I've separately emailed the package maintainers about the impact context for this change. Happy to discuss either here or there.

The recursive `_.merge` implementation iterates `Object.keys(source)`
and calls `_.merge(target[key], source[key])` for nested objects.
`Object.keys` returns `__proto__` as an own-enumerable key when the
source object came from `JSON.parse('{"__proto__":...}')` (own
enumerable, unlike the object-literal `{__proto__: ...}` form which
sets the prototype slot).

Without filtering those keys, the recursive call resolves
`target['__proto__']` to `Object.prototype` and writes onto it; the
same shape applies to `constructor.prototype` chains. The merge ends
up mutating prototypes rather than the intended target.

Adds the standard prototype-mutating-key filter (`__proto__`,
`constructor`, `prototype`) inside the iteration. Mirrors the filter
already in lodash's `baseSet`, cosmiconfig's merge, and the standard
remediation pattern across recent npm-ecosystem fixes.

Tests:
- All 21 existing commons tests pass.
- Added one new test under `_` describe block confirming a
  `{"__proto__":{"polluted":"X"}}` source does not mutate
  `Object.prototype`, fresh-`{}` reads, or the target object.
@marshallswain

Copy link
Copy Markdown
Member

Hey @ridingsa — thanks for this, and for the email heads-up. I really appreciate you taking the time to dig into it and write it up so clearly.

We're going to get this patched and into a release shortly, and I'll make sure you're credited for the report and attributed on the commits. I'll follow up over email on the timing and the details.

Thanks again 🙏

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.

3 participants