diff --git a/src/ObjectStateMutations.ts b/src/ObjectStateMutations.ts index f7caf6711..5b41b9f22 100644 --- a/src/ObjectStateMutations.ts +++ b/src/ObjectStateMutations.ts @@ -6,6 +6,7 @@ import TaskQueue from './TaskQueue'; import { RelationOp } from './ParseOp'; import type { Op } from './ParseOp'; import type ParseObject from './ParseObject'; +import { isDangerousKey } from "./isDangerousKey"; export type AttributeMap = Record; export type OpsMap = Record; @@ -21,9 +22,9 @@ export interface State { export function defaultState(): State { return { - serverData: {}, - pendingOps: [{}], - objectCache: {}, + serverData: Object.create(null), + pendingOps: [Object.create(null)], + objectCache: Object.create(null), tasks: new TaskQueue(), existed: false, }; @@ -31,7 +32,15 @@ export function defaultState(): State { export function setServerData(serverData: AttributeMap, attributes: AttributeMap) { for (const attr in attributes) { - if (typeof attributes[attr] !== 'undefined') { + // Skip properties from prototype chain + if (!Object.prototype.hasOwnProperty.call(attributes, attr)) { + continue; + } + // Skip dangerous keys that could pollute prototypes + if (isDangerousKey(attr)) { + continue; + } + if (typeof attributes[attr] !== "undefined") { serverData[attr] = attributes[attr]; } else { delete serverData[attr]; @@ -40,6 +49,10 @@ export function setServerData(serverData: AttributeMap, attributes: AttributeMap } export function setPendingOp(pendingOps: OpsMap[], attr: string, op?: Op) { + // Skip dangerous keys that could pollute prototypes + if (isDangerousKey(attr)) { + return; + } const last = pendingOps.length - 1; if (op) { pendingOps[last][attr] = op; @@ -49,13 +62,13 @@ export function setPendingOp(pendingOps: OpsMap[], attr: string, op?: Op) { } export function pushPendingState(pendingOps: OpsMap[]) { - pendingOps.push({}); + pendingOps.push(Object.create(null)); } export function popPendingState(pendingOps: OpsMap[]): OpsMap { const first = pendingOps.shift(); if (!pendingOps.length) { - pendingOps[0] = {}; + pendingOps[0] = Object.create(null); } return first; } @@ -64,6 +77,14 @@ export function mergeFirstPendingState(pendingOps: OpsMap[]) { const first = popPendingState(pendingOps); const next = pendingOps[0]; for (const attr in first) { + // Skip properties from prototype chain + if (!Object.prototype.hasOwnProperty.call(first, attr)) { + continue; + } + // Skip dangerous keys that could pollute prototypes + if (isDangerousKey(attr)) { + continue; + } if (next[attr] && first[attr]) { const merged = next[attr].mergeWith(first[attr]); if (merged) { @@ -81,6 +102,10 @@ export function estimateAttribute( object: ParseObject, attr: string ): any { + // Skip dangerous keys that could pollute prototypes + if (isDangerousKey(attr)) { + return undefined; + } let value = serverData[attr]; for (let i = 0; i < pendingOps.length; i++) { if (pendingOps[i][attr]) { @@ -101,13 +126,21 @@ export function estimateAttributes( pendingOps: OpsMap[], object: ParseObject ): AttributeMap { - const data = {}; + const data = Object.create(null); let attr; for (attr in serverData) { data[attr] = serverData[attr]; } for (let i = 0; i < pendingOps.length; i++) { for (attr in pendingOps[i]) { + // Skip properties from prototype chain + if (!Object.prototype.hasOwnProperty.call(pendingOps[i], attr)) { + continue; + } + // Skip dangerous keys that could pollute prototypes + if (isDangerousKey(attr)) { + continue; + } if (pendingOps[i][attr] instanceof RelationOp) { if (object.id) { data[attr] = (pendingOps[i][attr] as RelationOp).applyTo(data[attr], object, attr); @@ -125,7 +158,7 @@ export function estimateAttributes( if (!isNaN(nextKey)) { object[key] = []; } else { - object[key] = {}; + object[key] = Object.create(null); } } else { if (Array.isArray(object[key])) { @@ -165,7 +198,7 @@ function nestedSet(obj, key, value) { if (!isNaN(nextPath)) { obj[path] = []; } else { - obj[path] = {}; + obj[path] = Object.create(null); } } obj = obj[path]; @@ -184,6 +217,14 @@ export function commitServerChanges( ) { const ParseObject = CoreManager.getParseObject(); for (const attr in changes) { + // Skip properties from prototype chain + if (!Object.prototype.hasOwnProperty.call(changes, attr)) { + continue; + } + // Skip dangerous keys that could pollute prototypes + if (isDangerousKey(attr)) { + continue; + } const val = changes[attr]; nestedSet(serverData, attr, val); if ( diff --git a/src/ParseObject.ts b/src/ParseObject.ts index e99f61f79..8564aa6e0 100644 --- a/src/ParseObject.ts +++ b/src/ParseObject.ts @@ -102,7 +102,7 @@ type ToJSON = { // Mapping of class names to constructors, so we can populate objects from the // server with appropriate subclasses of ParseObject -const classMap: AttributeMap = {}; +const classMap: AttributeMap = Object.create(null); // Global counter for generating unique Ids for non-single-instance objects let objectCount = 0; diff --git a/src/__tests__/ObjectStateMutations-test.js b/src/__tests__/ObjectStateMutations-test.js index e6b118948..51135bd38 100644 --- a/src/__tests__/ObjectStateMutations-test.js +++ b/src/__tests__/ObjectStateMutations-test.js @@ -1,6 +1,7 @@ jest.dontMock('../decode'); jest.dontMock('../encode'); jest.dontMock('../CoreManager'); +jest.dontMock('../isDangerousKey'); jest.dontMock('../ObjectStateMutations'); jest.dontMock('../ParseFile'); jest.dontMock('../ParseGeoPoint'); @@ -11,7 +12,7 @@ jest.dontMock('../TaskQueue'); const mockObject = function (className) { this.className = className; }; -mockObject.registerSubclass = function () {}; +mockObject.registerSubclass = function () { }; jest.setMock('../ParseObject', mockObject); const CoreManager = require('../CoreManager').default; CoreManager.setParseObject(mockObject); @@ -351,4 +352,56 @@ describe('ObjectStateMutations', () => { existed: false, }); }); + + describe('Prototype Pollution Protection', () => { + beforeEach(() => { + // Clear any pollution before each test + delete Object.prototype.polluted; + delete Object.prototype.malicious; + }); + + afterEach(() => { + // Clean up after tests + delete Object.prototype.polluted; + delete Object.prototype.malicious; + }); + + it('should not pollute Object.prototype in estimateAttributes with malicious attribute names', () => { + const testObj = {}; + + const serverData = {}; + const pendingOps = [ + { + __proto__: new ParseOps.SetOp({ polluted: 'yes' }), + constructor: new ParseOps.SetOp({ malicious: 'data' }), + }, + ]; + + ObjectStateMutations.estimateAttributes(serverData, pendingOps, { + className: 'TestClass', + id: 'test123', + }); + + // Verify Object.prototype was not polluted + expect(testObj.polluted).toBeUndefined(); + expect(testObj.malicious).toBeUndefined(); + expect({}.polluted).toBeUndefined(); + expect({}.malicious).toBeUndefined(); + }); + + it('should not pollute Object.prototype in commitServerChanges with nested __proto__ path', () => { + const testObj = {}; + + const serverData = {}; + const objectCache = {}; + ObjectStateMutations.commitServerChanges(serverData, objectCache, { + '__proto__.polluted': 'exploited', + }); + + // Verify Object.prototype was not polluted + expect(testObj.polluted).toBeUndefined(); + expect({}.polluted).toBeUndefined(); + expect(Object.prototype.polluted).toBeUndefined(); + }); + }); }); diff --git a/src/__tests__/ParseObject-test.js b/src/__tests__/ParseObject-test.js index 60f7cecab..f2e9c2290 100644 --- a/src/__tests__/ParseObject-test.js +++ b/src/__tests__/ParseObject-test.js @@ -166,8 +166,8 @@ CoreManager.setInstallationController({ currentInstallationId() { return Promise.resolve('iid'); }, - currentInstallation() {}, - updateInstallationOnDisk() {}, + currentInstallation() { }, + updateInstallationOnDisk() { }, }); CoreManager.set('APPLICATION_ID', 'A'); CoreManager.set('JAVASCRIPT_KEY', 'B'); @@ -1413,9 +1413,9 @@ describe('ParseObject', () => { const objectController = CoreManager.getObjectController(); const spy = jest .spyOn(objectController, 'fetch') - .mockImplementationOnce(() => {}) - .mockImplementationOnce(() => {}) - .mockImplementationOnce(() => {}); + .mockImplementationOnce(() => { }) + .mockImplementationOnce(() => { }) + .mockImplementationOnce(() => { }); const parent = new ParseObject('Person'); await parent.fetchWithInclude('child', { @@ -1517,9 +1517,9 @@ describe('ParseObject', () => { const objectController = CoreManager.getObjectController(); const spy = jest .spyOn(objectController, 'fetch') - .mockImplementationOnce(() => {}) - .mockImplementationOnce(() => {}) - .mockImplementationOnce(() => {}); + .mockImplementationOnce(() => { }) + .mockImplementationOnce(() => { }) + .mockImplementationOnce(() => { }); const parent = new ParseObject('Person'); await ParseObject.fetchAllWithInclude([parent], 'child', { @@ -1545,9 +1545,9 @@ describe('ParseObject', () => { const objectController = CoreManager.getObjectController(); const spy = jest .spyOn(objectController, 'fetch') - .mockImplementationOnce(() => {}) - .mockImplementationOnce(() => {}) - .mockImplementationOnce(() => {}); + .mockImplementationOnce(() => { }) + .mockImplementationOnce(() => { }) + .mockImplementationOnce(() => { }); const parent = new ParseObject('Person'); await ParseObject.fetchAllIfNeededWithInclude([parent], 'child', { @@ -1610,7 +1610,7 @@ describe('ParseObject', () => { }); it('can save the object eventually', async () => { - mockFetch([{ status: 200, response: {objectId: 'PFEventually' } }]); + mockFetch([{ status: 200, response: { objectId: 'PFEventually' } }]); const p = new ParseObject('Person'); p.set('age', 38); const obj = await p.saveEventually(); @@ -1623,7 +1623,7 @@ describe('ParseObject', () => { it('can save the object eventually on network failure', async () => { const p = new ParseObject('Person'); jest.spyOn(EventuallyQueue, 'save').mockImplementationOnce(() => Promise.resolve()); - jest.spyOn(EventuallyQueue, 'poll').mockImplementationOnce(() => {}); + jest.spyOn(EventuallyQueue, 'poll').mockImplementationOnce(() => { }); jest.spyOn(p, 'save').mockImplementationOnce(() => { throw new ParseError( ParseError.CONNECTION_FAILED, @@ -1638,7 +1638,7 @@ describe('ParseObject', () => { it('should not save the object eventually on error', async () => { const p = new ParseObject('Person'); jest.spyOn(EventuallyQueue, 'save').mockImplementationOnce(() => Promise.resolve()); - jest.spyOn(EventuallyQueue, 'poll').mockImplementationOnce(() => {}); + jest.spyOn(EventuallyQueue, 'poll').mockImplementationOnce(() => { }); jest.spyOn(p, 'save').mockImplementationOnce(() => { throw new ParseError(ParseError.OTHER_CAUSE, 'Tried to save a batch with a cycle.'); }); @@ -1751,12 +1751,12 @@ describe('ParseObject', () => { const p = new ParseObject('Per$on'); expect(p._getPendingOps().length).toBe(1); p.increment('updates'); - p.save().catch(() => {}); + p.save().catch(() => { }); jest.runAllTicks(); await flushPromises(); expect(p._getPendingOps().length).toBe(1); p.set('updates', 12); - p.save().catch(() => {}); + p.save().catch(() => { }); jest.runAllTicks(); await flushPromises(); expect(p._getPendingOps().length).toBe(1); @@ -2491,7 +2491,7 @@ describe('ObjectController', () => { it('can fetch a single object', async () => { const objectController = CoreManager.getObjectController(); - mockFetch([{ status: 200, response: { objectId: 'pid'} }]); + mockFetch([{ status: 200, response: { objectId: 'pid' } }]); const o = new ParseObject('Person'); o.id = 'pid'; @@ -2535,7 +2535,7 @@ describe('ObjectController', () => { it('can fetch a single object with include', async () => { expect.assertions(2); const objectController = CoreManager.getObjectController(); - mockFetch([{ status: 200, response: { objectId: 'pid'} }]); + mockFetch([{ status: 200, response: { objectId: 'pid' } }]); const o = new ParseObject('Person'); o.id = 'pid'; @@ -2706,7 +2706,7 @@ describe('ObjectController', () => { it('can destroy the object eventually on network failure', async () => { const p = new ParseObject('Person'); jest.spyOn(EventuallyQueue, 'destroy').mockImplementationOnce(() => Promise.resolve()); - jest.spyOn(EventuallyQueue, 'poll').mockImplementationOnce(() => {}); + jest.spyOn(EventuallyQueue, 'poll').mockImplementationOnce(() => { }); jest.spyOn(p, 'destroy').mockImplementationOnce(() => { throw new ParseError( ParseError.CONNECTION_FAILED, @@ -2721,7 +2721,7 @@ describe('ObjectController', () => { it('should not destroy object eventually on error', async () => { const p = new ParseObject('Person'); jest.spyOn(EventuallyQueue, 'destroy').mockImplementationOnce(() => Promise.resolve()); - jest.spyOn(EventuallyQueue, 'poll').mockImplementationOnce(() => {}); + jest.spyOn(EventuallyQueue, 'poll').mockImplementationOnce(() => { }); jest.spyOn(p, 'destroy').mockImplementationOnce(() => { throw new ParseError(ParseError.OTHER_CAUSE, 'Unable to delete.'); }); @@ -2758,7 +2758,7 @@ describe('ObjectController', () => { for (let i = 0; i < 3; i++) { responses.push({ status: 200, - response:{ + response: { name: names[i], url: 'http://files.parsetfss.com/a/' + names[i], }, @@ -3117,7 +3117,7 @@ describe('ParseObject Subclasses', () => { }); it('can use on ParseObject subclass for multiple Parse.Object class names', () => { - class MyParseObjects extends ParseObject {} + class MyParseObjects extends ParseObject { } ParseObject.registerSubclass('TestObject', MyParseObjects); ParseObject.registerSubclass('TestObject1', MyParseObjects); ParseObject.registerSubclass('TestObject2', MyParseObjects); @@ -3542,4 +3542,119 @@ describe('ParseObject pin', () => { ); CoreManager.set('NODE_LOGGING', false); }); + + describe('Prototype Pollution Protection', () => { + beforeEach(() => { + // Clear any pollution before each test + delete Object.prototype.polluted; + delete Object.prototype.malicious; + delete Object.prototype.exploit; + }); + + afterEach(() => { + // Clean up after tests + delete Object.prototype.polluted; + delete Object.prototype.malicious; + delete Object.prototype.exploit; + }); + + it('should not pollute Object.prototype via prototype className in registerSubclass', () => { + const testObj = {}; + + class MaliciousClass extends ParseObject { + constructor() { + super('prototype'); + } + } + + ParseObject.registerSubclass('prototype', MaliciousClass); + + // Verify Object.prototype was not polluted + expect(testObj.polluted).toBeUndefined(); + expect({}.polluted).toBeUndefined(); + }); + + it('should not pollute Object.prototype when parsing JSON with __proto__ className', () => { + const testObj = {}; + + const maliciousJSON = { + className: '__proto__', + objectId: 'test123', + polluted: 'yes', + }; + + ParseObject.fromJSON(maliciousJSON); + + // Verify Object.prototype was not polluted + expect(testObj.polluted).toBeUndefined(); + expect({}.polluted).toBeUndefined(); + }); + + it('should not pollute Object.prototype when parsing JSON with constructor className', () => { + const testObj = {}; + + const maliciousJSON = { + className: 'constructor', + objectId: 'test456', + malicious: 'data', + }; + + ParseObject.fromJSON(maliciousJSON); + + // Verify Object.prototype was not polluted + expect(testObj.malicious).toBeUndefined(); + expect({}.malicious).toBeUndefined(); + }); + + it('should not pollute Object.prototype when parsing JSON with prototype className', () => { + const testObj = {}; + + const maliciousJSON = { + className: 'prototype', + objectId: 'test789', + exploit: 'here', + }; + + ParseObject.fromJSON(maliciousJSON); + + // Verify Object.prototype was not polluted + expect(testObj.exploit).toBeUndefined(); + expect({}.exploit).toBeUndefined(); + }); + + it('should not pollute when creating objects with malicious class names', () => { + const testObj = {}; + + const maliciousClasses = ['__proto__', 'constructor', 'prototype']; + + maliciousClasses.forEach(className => { + const obj = new ParseObject(className); + obj.set('polluted', 'yes'); + }); + + // Verify Object.prototype was not polluted + expect(testObj.polluted).toBeUndefined(); + expect({}.polluted).toBeUndefined(); + }); + + it('should not pollute when fromJSON is called multiple times with malicious classNames', () => { + const testObj = {}; + + const maliciousObjects = [ + { className: '__proto__', objectId: '1', polluted: 'yes' }, + { className: 'constructor', objectId: '2', malicious: 'data' }, + { className: 'prototype', objectId: '3', exploit: 'here' }, + ]; + + maliciousObjects.forEach(json => ParseObject.fromJSON(json)); + + // Verify Object.prototype was not polluted + expect(testObj.polluted).toBeUndefined(); + expect(testObj.malicious).toBeUndefined(); + expect(testObj.exploit).toBeUndefined(); + expect({}.polluted).toBeUndefined(); + expect({}.malicious).toBeUndefined(); + expect({}.exploit).toBeUndefined(); + }); + }); }); diff --git a/src/__tests__/decode-test.js b/src/__tests__/decode-test.js index 8cc886e9d..048bb22c7 100644 --- a/src/__tests__/decode-test.js +++ b/src/__tests__/decode-test.js @@ -1,5 +1,6 @@ jest.dontMock('../decode'); jest.dontMock('../CoreManager'); +jest.dontMock('../isDangerousKey'); jest.dontMock('../ParseFile'); jest.dontMock('../ParseGeoPoint'); jest.dontMock('../ParseObject'); @@ -120,4 +121,192 @@ describe('decode', () => { count: 15, }); }); + + describe('Prototype Pollution Protection', () => { + beforeEach(() => { + // Clear any pollution before each test + delete Object.prototype.polluted; + delete Object.prototype.malicious; + delete Object.prototype.exploit; + }); + + afterEach(() => { + // Clean up after tests + delete Object.prototype.polluted; + delete Object.prototype.malicious; + delete Object.prototype.exploit; + }); + + it('should not pollute Object.prototype when decoding object with __proto__ key', () => { + const testObj = {}; + const maliciousInput = { + normalKey: 'value', + __proto__: { polluted: 'yes' }, + }; + + const result = decode(maliciousInput); + + // Verify Object.prototype was not polluted + expect(testObj.polluted).toBeUndefined(); + expect({}.polluted).toBeUndefined(); + expect(Object.prototype.polluted).toBeUndefined(); + + // Verify result only has own property + expect(Object.prototype.hasOwnProperty.call(result, '__proto__')).toBe(false); + expect(result.normalKey).toBe('value'); + }); + + it('should not pollute Object.prototype when decoding object with constructor key', () => { + const testObj = {}; + const maliciousInput = { + normalKey: 'value', + constructor: { polluted: 'yes' }, + }; + + const result = decode(maliciousInput); + + // Verify Object.prototype was not polluted + expect(testObj.polluted).toBeUndefined(); + expect({}.polluted).toBeUndefined(); + expect(Object.prototype.polluted).toBeUndefined(); + + // Verify result doesn't contain constructor from prototype chain + expect(result.normalKey).toBe('value'); + }); + + it('should not pollute Object.prototype when decoding object with prototype key', () => { + const testObj = {}; + const maliciousInput = { + normalKey: 'value', + prototype: { polluted: 'yes' }, + }; + + const result = decode(maliciousInput); + + // Verify Object.prototype was not polluted + expect(testObj.polluted).toBeUndefined(); + expect({}.polluted).toBeUndefined(); + expect(Object.prototype.polluted).toBeUndefined(); + + // Verify result contains only own properties + expect(result.normalKey).toBe('value'); + }); + + it('should not pollute Object.prototype when decoding nested objects with dangerous keys', () => { + const testObj = {}; + const maliciousInput = { + nested: { + __proto__: { polluted: 'nested' }, + data: 'value', + }, + normal: 'key', + }; + + const result = decode(maliciousInput); + + // Verify Object.prototype was not polluted + expect(testObj.polluted).toBeUndefined(); + expect({}.polluted).toBeUndefined(); + expect(Object.prototype.polluted).toBeUndefined(); + + // Verify result structure + expect(result.normal).toBe('key'); + expect(result.nested).toBeDefined(); + expect(result.nested.data).toBe('value'); + expect(Object.prototype.hasOwnProperty.call(result.nested, '__proto__')).toBe(false); + }); + + it('should not pollute Object.prototype when decoding arrays with objects containing dangerous keys', () => { + const testObj = {}; + const maliciousInput = [ + { __proto__: { polluted: 'array1' } }, + { constructor: { malicious: 'array2' } }, + { normalKey: 'value' }, + ]; + + const result = decode(maliciousInput); + + // Verify Object.prototype was not polluted + expect(testObj.polluted).toBeUndefined(); + expect(testObj.malicious).toBeUndefined(); + expect({}.polluted).toBeUndefined(); + expect({}.malicious).toBeUndefined(); + expect(Object.prototype.polluted).toBeUndefined(); + expect(Object.prototype.malicious).toBeUndefined(); + + // Verify result array + expect(Array.isArray(result)).toBe(true); + expect(result.length).toBe(3); + expect(result[2].normalKey).toBe('value'); + }); + + it('should only decode own properties, not inherited ones', () => { + const parent = { inherited: 'parent' }; + const child = Object.create(parent); + child.own = 'child'; + + const result = decode(child); + + // Should only include own property + expect(result.own).toBe('child'); + expect(result.inherited).toBeUndefined(); + }); + + it('should not decode properties from prototype chain', () => { + Object.prototype.exploit = 'malicious'; + const obj = { normalKey: 'value' }; + + const result = decode(obj); + + // Should not include prototype property + expect(result.normalKey).toBe('value'); + expect(Object.prototype.hasOwnProperty.call(result, 'exploit')).toBe(false); + + delete Object.prototype.exploit; + }); + + it('should not pollute Object.prototype when decoding Parse type with dangerous className', () => { + const testObj = {}; + const maliciousInput = { + __type: 'Pointer', + className: '__proto__', + objectId: 'test123', + }; + + // This should be handled by ParseObject.fromJSON + decode(maliciousInput); + + // Verify Object.prototype was not polluted + expect(testObj.polluted).toBeUndefined(); + expect({}.polluted).toBeUndefined(); + expect(Object.prototype.polluted).toBeUndefined(); + }); + + it('should not pollute Object.prototype when decoding deeply nested dangerous keys', () => { + const testObj = {}; + const maliciousInput = { + level1: { + level2: { + level3: { + __proto__: { polluted: 'deep' }, + normalData: 'value', + }, + }, + }, + }; + + const result = decode(maliciousInput); + + // Verify Object.prototype was not polluted + expect(testObj.polluted).toBeUndefined(); + expect({}.polluted).toBeUndefined(); + expect(Object.prototype.polluted).toBeUndefined(); + + // Verify result structure is preserved (without dangerous keys) + expect(result.level1.level2.level3.normalData).toBe('value'); + expect(Object.prototype.hasOwnProperty.call(result.level1.level2.level3, '__proto__')).toBe( + false + ); + }); + }); }); diff --git a/src/__tests__/encode-test.js b/src/__tests__/encode-test.js index 57600313d..c6959f5f2 100644 --- a/src/__tests__/encode-test.js +++ b/src/__tests__/encode-test.js @@ -1,4 +1,5 @@ jest.dontMock('../encode'); +jest.dontMock('../isDangerousKey'); jest.dontMock('../ParseACL'); jest.dontMock('../ParseFile'); jest.dontMock('../ParseGeoPoint'); @@ -193,4 +194,148 @@ describe('encode', () => { str: 'abc', }); }); + + describe('Prototype Pollution Protection', () => { + beforeEach(() => { + // Clear any pollution before each test + delete Object.prototype.polluted; + delete Object.prototype.malicious; + delete Object.prototype.exploit; + }); + + afterEach(() => { + // Clean up after tests + delete Object.prototype.polluted; + delete Object.prototype.malicious; + delete Object.prototype.exploit; + }); + + it('should not pollute Object.prototype when encoding object with __proto__ key', () => { + const testObj = {}; + const maliciousInput = { + normalKey: 'value', + __proto__: { polluted: 'yes' }, + }; + + const result = encode(maliciousInput); + + // Verify Object.prototype was not polluted + expect(testObj.polluted).toBeUndefined(); + expect({}.polluted).toBeUndefined(); + expect(Object.prototype.polluted).toBeUndefined(); + + // Verify result only has own property + expect(Object.prototype.hasOwnProperty.call(result, '__proto__')).toBe(false); + expect(result.normalKey).toBe('value'); + }); + + it('should not pollute Object.prototype when encoding object with constructor key', () => { + const testObj = {}; + const maliciousInput = { + normalKey: 'value', + constructor: { polluted: 'yes' }, + }; + + const result = encode(maliciousInput); + + // Verify Object.prototype was not polluted + expect(testObj.polluted).toBeUndefined(); + expect({}.polluted).toBeUndefined(); + expect(Object.prototype.polluted).toBeUndefined(); + + // Verify result doesn't contain constructor from prototype chain + expect(result.normalKey).toBe('value'); + }); + + it('should not pollute Object.prototype when encoding object with prototype key', () => { + const testObj = {}; + const maliciousInput = { + normalKey: 'value', + prototype: { polluted: 'yes' }, + }; + + const result = encode(maliciousInput); + + // Verify Object.prototype was not polluted + expect(testObj.polluted).toBeUndefined(); + expect({}.polluted).toBeUndefined(); + expect(Object.prototype.polluted).toBeUndefined(); + + // Verify result contains only own properties + expect(result.normalKey).toBe('value'); + }); + + it('should not pollute Object.prototype when encoding nested objects with dangerous keys', () => { + const testObj = {}; + const maliciousInput = { + nested: { + __proto__: { polluted: 'nested' }, + data: 'value', + }, + normal: 'key', + }; + + const result = encode(maliciousInput); + + // Verify Object.prototype was not polluted + expect(testObj.polluted).toBeUndefined(); + expect({}.polluted).toBeUndefined(); + expect(Object.prototype.polluted).toBeUndefined(); + + // Verify result structure + expect(result.normal).toBe('key'); + expect(result.nested).toBeDefined(); + expect(result.nested.data).toBe('value'); + expect(Object.prototype.hasOwnProperty.call(result.nested, '__proto__')).toBe(false); + }); + + it('should not pollute Object.prototype when encoding arrays with objects containing dangerous keys', () => { + const testObj = {}; + const maliciousInput = [ + { __proto__: { polluted: 'array1' } }, + { constructor: { malicious: 'array2' } }, + { normalKey: 'value' }, + ]; + + const result = encode(maliciousInput); + + // Verify Object.prototype was not polluted + expect(testObj.polluted).toBeUndefined(); + expect(testObj.malicious).toBeUndefined(); + expect({}.polluted).toBeUndefined(); + expect({}.malicious).toBeUndefined(); + expect(Object.prototype.polluted).toBeUndefined(); + expect(Object.prototype.malicious).toBeUndefined(); + + // Verify result array + expect(Array.isArray(result)).toBe(true); + expect(result.length).toBe(3); + expect(result[2].normalKey).toBe('value'); + }); + + it('should only encode own properties, not inherited ones', () => { + const parent = { inherited: 'parent' }; + const child = Object.create(parent); + child.own = 'child'; + + const result = encode(child); + + // Should only include own property + expect(result.own).toBe('child'); + expect(result.inherited).toBeUndefined(); + }); + + it('should not encode properties from prototype chain', () => { + Object.prototype.exploit = 'malicious'; + const obj = { normalKey: 'value' }; + + const result = encode(obj); + + // Should not include prototype property + expect(result.normalKey).toBe('value'); + expect(Object.prototype.hasOwnProperty.call(result, 'exploit')).toBe(false); + + delete Object.prototype.exploit; + }); + }); }); diff --git a/src/decode.ts b/src/decode.ts index fc4c876ca..790c889b5 100644 --- a/src/decode.ts +++ b/src/decode.ts @@ -3,6 +3,7 @@ import ParseFile from './ParseFile'; import ParseGeoPoint from './ParseGeoPoint'; import ParsePolygon from './ParsePolygon'; import ParseRelation from './ParseRelation'; +import { isDangerousKey } from "./isDangerousKey"; export default function decode(value: any): any { if (value === null || typeof value !== 'object' || value instanceof Date) { @@ -49,7 +50,13 @@ export default function decode(value: any): any { } const copy = {}; for (const k in value) { - copy[k] = decode(value[k]); + if (Object.prototype.hasOwnProperty.call(value, k)) { + // Skip dangerous keys that could pollute prototypes + if (isDangerousKey(k)) { + continue; + } + copy[k] = decode(value[k]); + } } return copy; } diff --git a/src/encode.ts b/src/encode.ts index a77c477de..730746a47 100644 --- a/src/encode.ts +++ b/src/encode.ts @@ -4,6 +4,7 @@ import ParseFile from './ParseFile'; import ParseGeoPoint from './ParseGeoPoint'; import ParsePolygon from './ParsePolygon'; import ParseRelation from './ParseRelation'; +import { isDangerousKey } from "./isDangerousKey"; function encode( value: any, @@ -71,7 +72,20 @@ function encode( if (value && typeof value === 'object') { const output = {}; for (const k in value) { - output[k] = encode(value[k], disallowObjects, forcePointers, seen, offline); + // Only iterate over own properties + if (Object.prototype.hasOwnProperty.call(value, k)) { + // Skip dangerous keys that could pollute prototypes + if (isDangerousKey(k)) { + continue; + } + output[k] = encode( + value[k], + disallowObjects, + forcePointers, + seen, + offline + ); + } } return output; } diff --git a/src/isDangerousKey.ts b/src/isDangerousKey.ts new file mode 100644 index 000000000..9fe4c7045 --- /dev/null +++ b/src/isDangerousKey.ts @@ -0,0 +1,19 @@ +/** + * Check if a property name or path is potentially dangerous for prototype pollution + * Dangerous keys include: __proto__, constructor, prototype + * @param key - The property name or dotted path to check + * @returns true if the key is dangerous, false otherwise + */ +export function isDangerousKey(key: string): boolean { + const dangerousKeys = ["__proto__", "constructor", "prototype"]; + // Check if the key itself is dangerous + if (dangerousKeys.includes(key)) { + return true; + } + // Check if any part of a dotted path is dangerous + if (key.includes(".")) { + const parts = key.split("."); + return parts.some((part) => dangerousKeys.includes(part)); + } + return false; +} diff --git a/types/isDangerousKey.d.ts b/types/isDangerousKey.d.ts new file mode 100644 index 000000000..9576f5e2c --- /dev/null +++ b/types/isDangerousKey.d.ts @@ -0,0 +1,7 @@ +/** + * Check if a property name or path is potentially dangerous for prototype pollution + * Dangerous keys include: __proto__, constructor, prototype + * @param key - The property name or dotted path to check + * @returns true if the key is dangerous, false otherwise + */ +export declare function isDangerousKey(key: string): boolean;