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
4 changes: 1 addition & 3 deletions .github/pull_request_template.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,7 @@
- Any contribution is under this [license](https://github.com/parse-community/parse-server/blob/alpha/LICENSE).

## Issue
<!-- Describe the issue. -->

Closes: FILL_THIS_OUT
<!-- Describe or link the issue that this PR closes. -->

## Approach
<!-- Describe the changes in this PR. -->
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/ci-performance.yml
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,7 @@ jobs:
console.log('');
if (hasRegression) {
console.log('⚠️ **Performance regressions detected.** Please review the changes.');
process.exitCode = 1;
} else if (hasImprovement) {
console.log('🚀 **Performance improvements detected!** Great work!');
} else {
Expand Down
33 changes: 33 additions & 0 deletions benchmark/performance.js
Original file line number Diff line number Diff line change
Expand Up @@ -791,6 +791,38 @@ async function benchmarkLiveQueryRegex(name) {
}
}

/**
* Benchmark: Object.save with nested data (denylist scanning)
*
* Measures create latency for objects with deeply nested structures containing
* multiple sibling objects at each level. This exercises the requestKeywordDenylist
* scanner (objectContainsKeyValue) which must traverse all keys and nested values.
*/
async function benchmarkObjectCreateNestedDenylist(name) {
let counter = 0;

return measureOperation({
name,
iterations: 1_000,
operation: async () => {
const TestObject = Parse.Object.extend('BenchmarkDenylist');
const obj = new TestObject();
const idx = counter++;
obj.set('nested', {
meta1: { info: { detail: `value-${idx}` } },
meta2: { info: { detail: `value-${idx}` } },
meta3: { info: { detail: `value-${idx}` } },
tags: ['a', 'b', 'c'],
config: {
setting1: { enabled: true, params: { x: 1 } },
setting2: { enabled: false, params: { y: 2 } },
},
});
await obj.save();
},
});
}

/**
* Run all benchmarks
*/
Expand Down Expand Up @@ -824,6 +856,7 @@ async function runBenchmarks() {
{ name: 'Query.include (nested pointers)', fn: benchmarkQueryWithIncludeNested },
{ name: 'Query.find (large result, GC pressure)', fn: benchmarkLargeResultMemory },
{ name: 'Query.find (concurrent, GC pressure)', fn: benchmarkConcurrentQueryMemory },
{ name: 'Object.save (nested data, denylist scan)', fn: benchmarkObjectCreateNestedDenylist },
{ name: 'Query $regex', fn: benchmarkQueryRegex },
{ name: 'LiveQuery $regex', fn: benchmarkLiveQueryRegex },
];
Expand Down
14 changes: 14 additions & 0 deletions changelogs/CHANGELOG_alpha.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,17 @@
## [9.5.1-alpha.2](https://github.com/parse-community/parse-server/compare/9.5.1-alpha.1...9.5.1-alpha.2) (2026-03-07)


### Bug Fixes

* Denial of Service (DoS) and Cloud Function Dispatch Bypass via Prototype Chain Resolution ([GHSA-5j86-7r7m-p8h6](https://github.com/parse-community/parse-server/security/advisories/GHSA-5j86-7r7m-p8h6)) ([#10125](https://github.com/parse-community/parse-server/issues/10125)) ([560e6e7](https://github.com/parse-community/parse-server/commit/560e6e77c7625da0655b2d01dc2d10632a80f591))

## [9.5.1-alpha.1](https://github.com/parse-community/parse-server/compare/9.5.0...9.5.1-alpha.1) (2026-03-07)


### Bug Fixes

* Denylist `requestKeywordDenylist` keyword scan bypass through nested object placement ([GHSA-q342-9w2p-57fp](https://github.com/parse-community/parse-server/security/advisories/GHSA-q342-9w2p-57fp)) ([#10123](https://github.com/parse-community/parse-server/issues/10123)) ([4a44247](https://github.com/parse-community/parse-server/commit/4a44247a649a40ef3f1db8261a0e780080f494ba))

# [9.5.0-alpha.14](https://github.com/parse-community/parse-server/compare/9.5.0-alpha.13...9.5.0-alpha.14) (2026-03-07)


Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "parse-server",
"version": "9.5.0",
"version": "9.5.1-alpha.2",
"description": "An express module providing a Parse-compatible API server",
"main": "lib/index.js",
"repository": {
Expand Down
221 changes: 221 additions & 0 deletions spec/vulnerabilities.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -160,7 +160,228 @@ describe('Vulnerabilities', () => {
});
});

describe('(GHSA-5j86-7r7m-p8h6) Cloud function name prototype chain bypass', () => {
const headers = {
'Content-Type': 'application/json',
'X-Parse-Application-Id': 'test',
'X-Parse-REST-API-Key': 'rest',
};

it('rejects "constructor" as cloud function name', async () => {
const response = await request({
headers,
method: 'POST',
url: 'http://localhost:8378/1/functions/constructor',
body: JSON.stringify({}),
}).catch(e => e);
expect(response.status).toBe(400);
const text = JSON.parse(response.text);
expect(text.code).toBe(Parse.Error.SCRIPT_FAILED);
expect(text.error).toContain('Invalid function');
});

it('rejects "toString" as cloud function name', async () => {
const response = await request({
headers,
method: 'POST',
url: 'http://localhost:8378/1/functions/toString',
body: JSON.stringify({}),
}).catch(e => e);
expect(response.status).toBe(400);
const text = JSON.parse(response.text);
expect(text.code).toBe(Parse.Error.SCRIPT_FAILED);
expect(text.error).toContain('Invalid function');
});

it('rejects "valueOf" as cloud function name', async () => {
const response = await request({
headers,
method: 'POST',
url: 'http://localhost:8378/1/functions/valueOf',
body: JSON.stringify({}),
}).catch(e => e);
expect(response.status).toBe(400);
const text = JSON.parse(response.text);
expect(text.code).toBe(Parse.Error.SCRIPT_FAILED);
expect(text.error).toContain('Invalid function');
});

it('rejects "hasOwnProperty" as cloud function name', async () => {
const response = await request({
headers,
method: 'POST',
url: 'http://localhost:8378/1/functions/hasOwnProperty',
body: JSON.stringify({}),
}).catch(e => e);
expect(response.status).toBe(400);
const text = JSON.parse(response.text);
expect(text.code).toBe(Parse.Error.SCRIPT_FAILED);
expect(text.error).toContain('Invalid function');
});

it('rejects "__proto__.toString" as cloud function name', async () => {
const response = await request({
headers,
method: 'POST',
url: 'http://localhost:8378/1/functions/__proto__.toString',
body: JSON.stringify({}),
}).catch(e => e);
expect(response.status).toBe(400);
const text = JSON.parse(response.text);
expect(text.code).toBe(Parse.Error.SCRIPT_FAILED);
expect(text.error).toContain('Invalid function');
});

it('still executes a legitimately defined cloud function', async () => {
Parse.Cloud.define('legitimateFunction', () => 'hello');
const response = await request({
headers,
method: 'POST',
url: 'http://localhost:8378/1/functions/legitimateFunction',
body: JSON.stringify({}),
});
expect(response.status).toBe(200);
expect(JSON.parse(response.text).result).toBe('hello');
});
});

describe('Request denylist', () => {
describe('(GHSA-q342-9w2p-57fp) Denylist bypass via sibling nested objects', () => {
it('denies _bsontype:Code after a sibling nested object', async () => {
const headers = {
'Content-Type': 'application/json',
'X-Parse-Application-Id': 'test',
'X-Parse-REST-API-Key': 'rest',
};
const response = await request({
headers,
method: 'POST',
url: 'http://localhost:8378/1/classes/Bypass',
body: JSON.stringify({
obj: {
metadata: {},
_bsontype: 'Code',
code: 'malicious',
},
}),
}).catch(e => e);
expect(response.status).toBe(400);
const text = JSON.parse(response.text);
expect(text.code).toBe(Parse.Error.INVALID_KEY_NAME);
expect(text.error).toBe(
'Prohibited keyword in request data: {"key":"_bsontype","value":"Code"}.'
);
});

it('denies _bsontype:Code after a sibling nested array', async () => {
const headers = {
'Content-Type': 'application/json',
'X-Parse-Application-Id': 'test',
'X-Parse-REST-API-Key': 'rest',
};
const response = await request({
headers,
method: 'POST',
url: 'http://localhost:8378/1/classes/Bypass',
body: JSON.stringify({
obj: {
tags: ['safe'],
_bsontype: 'Code',
code: 'malicious',
},
}),
}).catch(e => e);
expect(response.status).toBe(400);
const text = JSON.parse(response.text);
expect(text.code).toBe(Parse.Error.INVALID_KEY_NAME);
expect(text.error).toBe(
'Prohibited keyword in request data: {"key":"_bsontype","value":"Code"}.'
);
});

it('denies __proto__ after a sibling nested object', async () => {
// Cannot test via HTTP because deepcopy() strips __proto__ before the denylist
// check runs. Test objectContainsKeyValue directly with a JSON.parse'd object
// that preserves __proto__ as an own property.
const Utils = require('../lib/Utils');
const data = JSON.parse('{"profile": {"name": "alice"}, "__proto__": {"isAdmin": true}}');
expect(Utils.objectContainsKeyValue(data, '__proto__', undefined)).toBe(true);
});

it('denies constructor after a sibling nested object', async () => {
const headers = {
'Content-Type': 'application/json',
'X-Parse-Application-Id': 'test',
'X-Parse-REST-API-Key': 'rest',
};
const response = await request({
headers,
method: 'POST',
url: 'http://localhost:8378/1/classes/Bypass',
body: JSON.stringify({
obj: {
data: {},
constructor: { prototype: { polluted: true } },
},
}),
}).catch(e => e);
expect(response.status).toBe(400);
const text = JSON.parse(response.text);
expect(text.code).toBe(Parse.Error.INVALID_KEY_NAME);
expect(text.error).toBe(
'Prohibited keyword in request data: {"key":"constructor"}.'
);
});

it('denies _bsontype:Code nested inside a second sibling object', async () => {
const headers = {
'Content-Type': 'application/json',
'X-Parse-Application-Id': 'test',
'X-Parse-REST-API-Key': 'rest',
};
const response = await request({
headers,
method: 'POST',
url: 'http://localhost:8378/1/classes/Bypass',
body: JSON.stringify({
field1: { safe: true },
field2: { _bsontype: 'Code', code: 'malicious' },
}),
}).catch(e => e);
expect(response.status).toBe(400);
const text = JSON.parse(response.text);
expect(text.code).toBe(Parse.Error.INVALID_KEY_NAME);
expect(text.error).toBe(
'Prohibited keyword in request data: {"key":"_bsontype","value":"Code"}.'
);
});

it('handles circular references without infinite loop', () => {
const Utils = require('../lib/Utils');
const obj = { name: 'test', nested: { value: 1 } };
obj.nested.self = obj;
expect(Utils.objectContainsKeyValue(obj, 'nonexistent', undefined)).toBe(false);
});

it('denies _bsontype:Code in file metadata after a sibling nested object', async () => {
const str = 'Hello World!';
const data = [];
for (let i = 0; i < str.length; i++) {
data.push(str.charCodeAt(i));
}
const file = new Parse.File('hello.txt', data, 'text/plain');
file.addMetadata('nested', { safe: true });
file.addMetadata('_bsontype', 'Code');
file.addMetadata('code', 'malicious');
await expectAsync(file.save()).toBeRejectedWith(
new Parse.Error(
Parse.Error.INVALID_KEY_NAME,
'Prohibited keyword in request data: {"key":"_bsontype","value":"Code"}.'
)
);
});
});

it('denies BSON type code data in write request by default', async () => {
const headers = {
'Content-Type': 'application/json',
Expand Down
27 changes: 18 additions & 9 deletions src/Utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -344,16 +344,25 @@ class Utils {
const isMatch = (a, b) => (typeof a === 'string' && new RegExp(b).test(a)) || a === b;
const isKeyMatch = k => isMatch(k, key);
const isValueMatch = v => isMatch(v, value);
for (const [k, v] of Object.entries(obj)) {
if (key !== undefined && value === undefined && isKeyMatch(k)) {
return true;
} else if (key === undefined && value !== undefined && isValueMatch(v)) {
return true;
} else if (key !== undefined && value !== undefined && isKeyMatch(k) && isValueMatch(v)) {
return true;
const stack = [obj];
const seen = new WeakSet();
while (stack.length > 0) {
const current = stack.pop();
if (seen.has(current)) {
continue;
}
if (['[object Object]', '[object Array]'].includes(Object.prototype.toString.call(v))) {
return Utils.objectContainsKeyValue(v, key, value);
seen.add(current);
for (const [k, v] of Object.entries(current)) {
if (key !== undefined && value === undefined && isKeyMatch(k)) {
return true;
} else if (key === undefined && value !== undefined && isValueMatch(v)) {
return true;
} else if (key !== undefined && value !== undefined && isKeyMatch(k) && isValueMatch(v)) {
return true;
}
if (['[object Object]', '[object Array]'].includes(Object.prototype.toString.call(v))) {
stack.push(v);
}
}
}
return false;
Expand Down
Loading
Loading