From 7c768087758c5311a27d1b4c434ca0ee414c4205 Mon Sep 17 00:00:00 2001 From: Robert Ing Date: Wed, 26 Nov 2025 16:19:27 -0500 Subject: [PATCH 1/2] fix: Refactor extend() to handle prototype pollution cases in object merging --- src/helpers.js | 15 ++ test/jest/helpers-prototype-pollution.spec.ts | 201 ++++++++++++++++++ 2 files changed, 216 insertions(+) create mode 100644 test/jest/helpers-prototype-pollution.spec.ts diff --git a/src/helpers.js b/src/helpers.js index 3eec10dbf..66b649c20 100644 --- a/src/helpers.js +++ b/src/helpers.js @@ -193,6 +193,21 @@ export default function Helpers(mpInstance) { if ((options = arguments[i]) != null) { // Extend the base object for (name in options) { + // Prevent prototype pollution + // https://github.com/advisories/GHSA-jf85-cpcp-j695 + if ( + name === '__proto__' || + name === 'constructor' || + name === 'prototype' + ) { + continue; + } + + // Only copy own properties + if (!Object.prototype.hasOwnProperty.call(options, name)) { + continue; + } + src = target[name]; copy = options[name]; diff --git a/test/jest/helpers-prototype-pollution.spec.ts b/test/jest/helpers-prototype-pollution.spec.ts new file mode 100644 index 000000000..564f997c6 --- /dev/null +++ b/test/jest/helpers-prototype-pollution.spec.ts @@ -0,0 +1,201 @@ +import Helpers from '../../src/helpers'; + +describe('Helpers - Prototype Pollution Protection', () => { + let helpers: any; + let mockMpInstance: any; + + beforeEach(() => { + // Clear any potential pollution + delete (Object.prototype as any).isAdmin; + delete (Object.prototype as any).polluted; + delete (Object.prototype as any).testProp; + + mockMpInstance = { + _Store: { + SDKConfig: { + flags: {} + } + }, + Logger: { + verbose: jest.fn(), + warning: jest.fn(), + error: jest.fn() + } + }; + + helpers = new Helpers(mockMpInstance); + }); + + afterEach(() => { + // Cleanup + delete (Object.prototype as any).isAdmin; + delete (Object.prototype as any).polluted; + delete (Object.prototype as any).testProp; + }); + + describe('extend() - Prototype Pollution Prevention', () => { + it('should block __proto__ in shallow merge', () => { + const malicious = JSON.parse('{"__proto__": {"isAdmin": true}}'); + const result = helpers.extend({}, malicious); + + const testObj = {}; + expect((testObj as any).isAdmin).toBeUndefined(); + expect((Object.prototype as any).isAdmin).toBeUndefined(); + }); + + it('should block __proto__ in deep merge', () => { + const malicious = JSON.parse('{"__proto__": {"polluted": "yes"}}'); + const result = helpers.extend(true, {}, malicious); + + const testObj = {}; + expect((testObj as any).polluted).toBeUndefined(); + expect((Object.prototype as any).polluted).toBeUndefined(); + }); + + it('should block constructor property', () => { + const malicious = JSON.parse('{"constructor": {"polluted": "constructor"}}'); + const result = helpers.extend({}, malicious); + + const testObj = {}; + expect((testObj as any).polluted).toBeUndefined(); + }); + + it('should block prototype property', () => { + const malicious = JSON.parse('{"prototype": {"polluted": "prototype"}}'); + const result = helpers.extend({}, malicious); + + const testObj = {}; + expect((testObj as any).polluted).toBeUndefined(); + }); + + it('should only copy own properties', () => { + const parent = { inherited: 'value' }; + const child = Object.create(parent); + child.own = 'ownValue'; + + const result = helpers.extend({}, child); + + expect(result.own).toBe('ownValue'); + expect(result.inherited).toBeUndefined(); + }); + + it('should still merge normal properties correctly', () => { + const source = { + name: 'John', + age: 30, + address: { + city: 'NYC', + zip: '10001' + } + }; + + const result = helpers.extend(true, {}, source); + + expect(result.name).toBe('John'); + expect(result.age).toBe(30); + expect(result.address.city).toBe('NYC'); + expect(result.address.zip).toBe('10001'); + }); + + it('should handle nested objects without pollution', () => { + const malicious = { + user: { + name: 'John', + __proto__: { isAdmin: true } + } + }; + + const result = helpers.extend(true, {}, malicious); + + expect(result.user.name).toBe('John'); + + const testObj = {}; + expect((testObj as any).isAdmin).toBeUndefined(); + expect((Object.prototype as any).isAdmin).toBeUndefined(); + }); + + it('should handle multiple source objects', () => { + const obj1 = { a: 1 }; + const obj2 = { b: 2 }; + const malicious = JSON.parse('{"__proto__": {"polluted": true}}'); + + const result = helpers.extend({}, obj1, obj2, malicious); + + expect(result.a).toBe(1); + expect(result.b).toBe(2); + + const testObj = {}; + expect((testObj as any).polluted).toBeUndefined(); + }); + + it('should handle arrays correctly', () => { + const source = { + items: [1, 2, 3], + nested: { + arr: ['a', 'b'] + } + }; + + const result = helpers.extend(true, {}, source); + + expect(Array.isArray(result.items)).toBe(true); + expect(result.items).toEqual([1, 2, 3]); + expect(result.nested.arr).toEqual(['a', 'b']); + }); + }); + + describe('Real-world attack scenarios', () => { + it('should protect against localStorage-based attack', () => { + // Simulate malicious localStorage data + const localStorageData = JSON.parse('{"__proto__": {"isAdmin": true}, "user": {"name": "attacker"}}'); + + const result = helpers.extend(false, {}, localStorageData); + + expect(result.user.name).toBe('attacker'); + + const testObj = {}; + expect((testObj as any).isAdmin).toBeUndefined(); + }); + + it('should protect against nested pollution attempts', () => { + const malicious = { + config: { + settings: { + __proto__: { polluted: true } + } + } + }; + + const result = helpers.extend(true, {}, malicious); + + const testObj = {}; + expect((testObj as any).polluted).toBeUndefined(); + }); + + it('should handle mixed legitimate and malicious data', () => { + const mixed = { + validProp: 'valid', + __proto__: { isAdmin: true }, + anotherValid: 123, + constructor: { polluted: true }, + nested: { + data: 'ok' + } + }; + + const result = helpers.extend(true, {}, mixed); + + // Valid properties should be copied + expect(result.validProp).toBe('valid'); + expect(result.anotherValid).toBe(123); + expect(result.nested.data).toBe('ok'); + + // Pollution should be blocked + const testObj = {}; + expect((testObj as any).isAdmin).toBeUndefined(); + expect((testObj as any).polluted).toBeUndefined(); + }); + }); +}); + + From 211583ac113c37d9c09a2db0a66a2646e837359c Mon Sep 17 00:00:00 2001 From: Robert Ing Date: Thu, 4 Dec 2025 16:16:30 -0500 Subject: [PATCH 2/2] Add additional tests, add assertions to resolve linting issues --- test/jest/helpers-prototype-pollution.spec.ts | 55 +++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/test/jest/helpers-prototype-pollution.spec.ts b/test/jest/helpers-prototype-pollution.spec.ts index 564f997c6..51d410e9c 100644 --- a/test/jest/helpers-prototype-pollution.spec.ts +++ b/test/jest/helpers-prototype-pollution.spec.ts @@ -38,6 +38,7 @@ describe('Helpers - Prototype Pollution Protection', () => { const malicious = JSON.parse('{"__proto__": {"isAdmin": true}}'); const result = helpers.extend({}, malicious); + expect(typeof result).toBe('object'); const testObj = {}; expect((testObj as any).isAdmin).toBeUndefined(); expect((Object.prototype as any).isAdmin).toBeUndefined(); @@ -47,6 +48,7 @@ describe('Helpers - Prototype Pollution Protection', () => { const malicious = JSON.parse('{"__proto__": {"polluted": "yes"}}'); const result = helpers.extend(true, {}, malicious); + expect(typeof result).toBe('object'); const testObj = {}; expect((testObj as any).polluted).toBeUndefined(); expect((Object.prototype as any).polluted).toBeUndefined(); @@ -56,6 +58,7 @@ describe('Helpers - Prototype Pollution Protection', () => { const malicious = JSON.parse('{"constructor": {"polluted": "constructor"}}'); const result = helpers.extend({}, malicious); + expect(typeof result).toBe('object'); const testObj = {}; expect((testObj as any).polluted).toBeUndefined(); }); @@ -64,6 +67,7 @@ describe('Helpers - Prototype Pollution Protection', () => { const malicious = JSON.parse('{"prototype": {"polluted": "prototype"}}'); const result = helpers.extend({}, malicious); + expect(typeof result).toBe('object'); const testObj = {}; expect((testObj as any).polluted).toBeUndefined(); }); @@ -142,6 +146,56 @@ describe('Helpers - Prototype Pollution Protection', () => { expect(result.items).toEqual([1, 2, 3]); expect(result.nested.arr).toEqual(['a', 'b']); }); + + it('should handle objects with null prototype (Object.create(null))', () => { + const nullProtoObj = Object.create(null); + nullProtoObj.name = 'test'; + nullProtoObj.value = 42; + + const result = helpers.extend({}, nullProtoObj); + + expect(result.name).toBe('test'); + expect(result.value).toBe(42); + }); + + it('should handle objects with null prototype in deep merge', () => { + const nullProtoObj = Object.create(null); + nullProtoObj.nested = Object.create(null); + nullProtoObj.nested.deep = 'value'; + + const result = helpers.extend(true, {}, nullProtoObj); + + expect(result.nested.deep).toBe('value'); + }); + + it('should handle null/undefined source arguments gracefully', () => { + const obj1 = { a: 1 }; + const obj2 = { b: 2 }; + + const result = helpers.extend({}, obj1, null, obj2, undefined); + + expect(result.a).toBe(1); + expect(result.b).toBe(2); + }); + + it('should handle null/undefined source arguments in deep merge', () => { + const obj1 = { a: { nested: 1 } }; + const obj2 = { b: { nested: 2 } }; + + const result = helpers.extend(true, {}, obj1, null, obj2, undefined); + + expect(result.a.nested).toBe(1); + expect(result.b.nested).toBe(2); + }); + + it('should handle all null/undefined sources', () => { + const target = { existing: 'value' }; + + const result = helpers.extend(target, null, undefined, null); + + expect(result.existing).toBe('value'); + expect(result).toBe(target); + }); }); describe('Real-world attack scenarios', () => { @@ -168,6 +222,7 @@ describe('Helpers - Prototype Pollution Protection', () => { const result = helpers.extend(true, {}, malicious); + expect(typeof result).toBe('object'); const testObj = {}; expect((testObj as any).polluted).toBeUndefined(); });