From 24efc38da1f3c243fbd10c287aa91b4c8bc415ef Mon Sep 17 00:00:00 2001 From: ComradeMerlin Date: Tue, 2 Jun 2026 14:30:49 -0500 Subject: [PATCH] fix(commons): skip __proto__ / constructor / prototype keys in _.merge 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. --- packages/commons/src/index.ts | 13 +++++++++++++ packages/commons/test/utils.test.ts | 20 ++++++++++++++++++++ 2 files changed, 33 insertions(+) diff --git a/packages/commons/src/index.ts b/packages/commons/src/index.ts index 9455252384..9d7fecd186 100644 --- a/packages/commons/src/index.ts +++ b/packages/commons/src/index.ts @@ -75,6 +75,19 @@ export const _ = { merge(target: any, source: any) { if (_.isObject(target) && _.isObject(source)) { Object.keys(source).forEach((key) => { + // Skip prototype-chain-mutating keys. `Object.keys` returns + // `__proto__` as an own enumerable when the source object came + // from `JSON.parse('{"__proto__":...}')`; without this filter + // the recursive `_.merge(target[key], source[key])` below + // resolves `target['__proto__']` to `Object.prototype` and + // writes attacker-controlled keys onto it. + if ( + key === '__proto__' || + key === 'constructor' || + key === 'prototype' + ) { + return + } if (_.isObject(source[key])) { if (!target[key]) { Object.assign(target, { [key]: {} }) diff --git a/packages/commons/test/utils.test.ts b/packages/commons/test/utils.test.ts index 53d51978ab..b7724551d0 100644 --- a/packages/commons/test/utils.test.ts +++ b/packages/commons/test/utils.test.ts @@ -212,5 +212,25 @@ describe('@feathersjs/commons utils', () => { assert.equal(_.merge('hello', {}), 'hello') }) + + it('merge does not mutate Object.prototype via __proto__ keys', () => { + // `Object.keys` returns `__proto__` as an own enumerable key when the + // source object came from `JSON.parse('{"__proto__":...}')`. Without + // the filter in the merge implementation, the recursive call resolves + // `target['__proto__']` to `Object.prototype` and writes attacker- + // controlled keys onto it (affecting every plain object in the + // process). + const target = {} as any + _.merge(target, JSON.parse('{"__proto__":{"polluted":"X"}}')) + assert.strictEqual(({} as any).polluted, undefined) + assert.strictEqual((Object.prototype as any).polluted, undefined) + assert.strictEqual(target.polluted, undefined) + + // Same protection for `constructor` and `prototype` keys (the rest + // of the standard prototype-mutating triad). + _.merge(target, JSON.parse('{"constructor":{"prototype":{"polluted2":"Y"}}}')) + assert.strictEqual(({} as any).polluted2, undefined) + assert.strictEqual((Object.prototype as any).polluted2, undefined) + }) }) })