Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 30 additions & 10 deletions src/SingleInstanceStateController.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,17 @@
import * as ObjectStateMutations from './ObjectStateMutations';
import * as ObjectStateMutations from "./ObjectStateMutations";

import type { Op } from './ParseOp';
import type ParseObject from './ParseObject';
import type { AttributeMap, ObjectCache, OpsMap, State } from './ObjectStateMutations';
import type { Op } from "./ParseOp";
import type ParseObject from "./ParseObject";
import type {
AttributeMap,
ObjectCache,
OpsMap,
State,
} from "./ObjectStateMutations";

let objectState: Record<string, Record<string, State>> = {};
// Use Object.create(null) to create an object without prototype chain
// This prevents prototype pollution attacks
let objectState: Record<string, Record<string, State>> = Object.create(null);

export function getState(obj: ParseObject): State | null {
const classData = objectState[obj.className];
Expand All @@ -20,7 +27,8 @@ export function initializeState(obj: ParseObject, initial?: State): State {
return state;
}
if (!objectState[obj.className]) {
objectState[obj.className] = {};
// Use Object.create(null) for nested objects too
objectState[obj.className] = Object.create(null);
}
if (!initial) {
initial = ObjectStateMutations.defaultState();
Expand Down Expand Up @@ -90,7 +98,12 @@ export function getObjectCache(obj: ParseObject): ObjectCache {
export function estimateAttribute(obj: ParseObject, attr: string): any {
const serverData = getServerData(obj);
const pendingOps = getPendingOps(obj);
return ObjectStateMutations.estimateAttribute(serverData, pendingOps, obj, attr);
return ObjectStateMutations.estimateAttribute(
serverData,
pendingOps,
obj,
attr
);
}

export function estimateAttributes(obj: ParseObject): AttributeMap {
Expand All @@ -101,16 +114,23 @@ export function estimateAttributes(obj: ParseObject): AttributeMap {

export function commitServerChanges(obj: ParseObject, changes: AttributeMap) {
const state = initializeState(obj);
ObjectStateMutations.commitServerChanges(state.serverData, state.objectCache, changes);
ObjectStateMutations.commitServerChanges(
state.serverData,
state.objectCache,
changes
);
}

export function enqueueTask(obj: ParseObject, task: () => Promise<any>): Promise<void> {
export function enqueueTask(
obj: ParseObject,
task: () => Promise<any>
): Promise<void> {
const state = initializeState(obj);
return state.tasks.enqueue(task);
}

export function clearAllState() {
objectState = {};
objectState = Object.create(null);
}

export function duplicateState(source: { id: string }, dest: { id: string }) {
Expand Down
93 changes: 91 additions & 2 deletions src/__tests__/SingleInstanceStateController-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@ jest.dontMock('../SingleInstanceStateController');
jest.dontMock('../TaskQueue');
jest.dontMock('./test_helpers/flushPromises');

const mockObject = function () {};
mockObject.registerSubclass = function () {};
const mockObject = function () { };
mockObject.registerSubclass = function () { };
jest.setMock('../ParseObject', {
__esModule: true,
default: mockObject,
Expand Down Expand Up @@ -698,4 +698,93 @@ describe('SingleInstanceStateController', () => {
existed: false,
});
});

describe('Prototype Pollution Protection (CVE-2025-57324)', () => {
beforeEach(() => {
SingleInstanceStateController.clearAllState();
});

it('prevents prototype pollution via __proto__ as className', () => {
const testObj = { className: '__proto__', id: 'pollutedProperty' };

// Should not throw error (silent prevention via Object.create(null))
SingleInstanceStateController.initializeState(testObj, {});

// Verify no pollution occurred on actual Object.prototype
expect({}.pollutedProperty).toBe(undefined);
expect(Object.prototype.pollutedProperty).toBe(undefined);
});

it('prevents prototype pollution via constructor as className', () => {
const testObj = { className: 'constructor', id: 'testId' };

// Should not throw error (silent prevention)
SingleInstanceStateController.initializeState(testObj, {});

// Verify no pollution occurred
const freshObj = {};
expect(freshObj.testId).toBe(undefined);
});

it('prevents prototype pollution via prototype as className', () => {
const testObj = { className: 'prototype', id: 'testId' };

// Should not throw error (silent prevention)
SingleInstanceStateController.initializeState(testObj, {});

// Verify no pollution occurred
const freshObj = {};
expect(freshObj.testId).toBe(undefined);
});

it('prevents prototype pollution via __proto__ as id', () => {
const testObj = { className: 'TestClass', id: '__proto__' };

// Should not throw error (silent prevention)
SingleInstanceStateController.initializeState(testObj, {});

// Verify no pollution occurred
expect({}.TestClass).toBe(undefined);
});

it('can store and retrieve data even with dangerous property names', () => {
const testObj1 = { className: '__proto__', id: 'pollutedProperty' };
const testObj2 = { className: 'constructor', id: 'testId' };

// Should work normally without polluting
SingleInstanceStateController.setServerData(testObj1, { value: 'test1' });
SingleInstanceStateController.setServerData(testObj2, { value: 'test2' });

// Should be able to retrieve the data
const state1 = SingleInstanceStateController.getState(testObj1);
const state2 = SingleInstanceStateController.getState(testObj2);

expect(state1.serverData).toEqual({ value: 'test1' });
expect(state2.serverData).toEqual({ value: 'test2' });

// But no pollution should occur
expect({}.pollutedProperty).toBe(undefined);
expect({}.testId).toBe(undefined);
});

it('allows normal className and id values', () => {
const testObj = { className: 'NormalClass', id: 'normalId123' };

SingleInstanceStateController.setServerData(testObj, { counter: 12 });

const state = SingleInstanceStateController.getState(testObj);
expect(state).toBeTruthy();
expect(state.serverData).toEqual({ counter: 12 });
});

it('prevents pollution when removing dangerous property names', () => {
const testObj = { className: '__proto__', id: 'dangerousId' };

SingleInstanceStateController.setServerData(testObj, { data: 'test' });
SingleInstanceStateController.removeState(testObj);

// Verify no pollution occurred
expect({}.dangerousId).toBe(undefined);
});
});
});
6 changes: 3 additions & 3 deletions types/SingleInstanceStateController.d.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { Op } from './ParseOp';
import type ParseObject from './ParseObject';
import type { AttributeMap, ObjectCache, OpsMap, State } from './ObjectStateMutations';
import type { Op } from "./ParseOp";
import type ParseObject from "./ParseObject";
import type { AttributeMap, ObjectCache, OpsMap, State } from "./ObjectStateMutations";
export declare function getState(obj: ParseObject): State | null;
export declare function initializeState(obj: ParseObject, initial?: State): State;
export declare function removeState(obj: ParseObject): State | null;
Expand Down