diff --git a/common/changes/@rushstack/node-core-library/mojazayeri-prevent-merge-prototype-pollution_2026-08-28-23-45.json b/common/changes/@rushstack/node-core-library/mojazayeri-prevent-merge-prototype-pollution_2026-08-28-23-45.json new file mode 100644 index 0000000000..ee2855218a --- /dev/null +++ b/common/changes/@rushstack/node-core-library/mojazayeri-prevent-merge-prototype-pollution_2026-08-28-23-45.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@rushstack/node-core-library", + "comment": "Prevent prototype pollution when Objects.mergeWith() processes attacker-controlled properties.", + "type": "patch" + } + ], + "packageName": "@rushstack/node-core-library", + "email": "mojazayeri@users.noreply.github.com" +} diff --git a/libraries/node-core-library/src/objects/mergeWith.ts b/libraries/node-core-library/src/objects/mergeWith.ts index c31d8fcdde..cfb611847c 100644 --- a/libraries/node-core-library/src/objects/mergeWith.ts +++ b/libraries/node-core-library/src/objects/mergeWith.ts @@ -30,16 +30,31 @@ export function mergeWith( const targetRecord: Record = target as unknown as Record; const sourceRecord: Record = source as unknown as Record; for (const [key, srcValue] of Object.entries(sourceRecord)) { - const objValue: unknown = targetRecord[key]; + const objValue: unknown = Object.hasOwnProperty.call(targetRecord, key) + ? targetRecord[key] + : undefined; const customized: unknown = customizer?.(objValue, srcValue, key); if (customized !== undefined) { - targetRecord[key] = customized; + _setProperty(targetRecord, key, customized); } else if (isRecord(srcValue) && isRecord(objValue)) { mergeWith(objValue, srcValue, customizer); } else { - targetRecord[key] = srcValue; + _setProperty(targetRecord, key, srcValue); } } return target; } + +function _setProperty(target: Record, key: string, value: unknown): void { + if (key === '__proto__') { + Object.defineProperty(target, key, { + configurable: true, + enumerable: true, + value, + writable: true + }); + } else { + target[key] = value; + } +} diff --git a/libraries/node-core-library/src/objects/test/mergeWith.test.ts b/libraries/node-core-library/src/objects/test/mergeWith.test.ts index 333e2a4615..dd1a43e854 100644 --- a/libraries/node-core-library/src/objects/test/mergeWith.test.ts +++ b/libraries/node-core-library/src/objects/test/mergeWith.test.ts @@ -72,6 +72,28 @@ describe(mergeWith.name, () => { mergeWith(target, { a: 1 }); expect(target).toEqual({ a: 1 }); }); + + it('does not merge into inherited target properties', () => { + const prototype: { settings: Record } = { settings: { inherited: true } }; + const target: Record = Object.create(prototype); + + mergeWith(target, { settings: { own: true } }); + + expect(prototype.settings).toEqual({ inherited: true }); + expect(target.settings).toEqual({ own: true }); + expect(Object.hasOwnProperty.call(target, 'settings')).toBe(true); + }); + + it('does not pollute Object.prototype through __proto__', () => { + const source: Record = JSON.parse('{"__proto__":{"polluted":true}}'); + const target: Record = {}; + + mergeWith(target, source); + + expect((Object.prototype as Record).polluted).toBeUndefined(); + expect(Object.hasOwnProperty.call(target, '__proto__')).toBe(true); + expect(target.__proto__).toEqual({ polluted: true }); + }); }); describe('customizer behavior', () => {