Skip to content

Commit 1688c77

Browse files
authored
build: Release (#10127)
2 parents 2bb289b + 7871e01 commit 1688c77

9 files changed

Lines changed: 309 additions & 23 deletions

File tree

.github/pull_request_template.md

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,7 @@
44
- Any contribution is under this [license](https://github.com/parse-community/parse-server/blob/alpha/LICENSE).
55

66
## Issue
7-
<!-- Describe the issue. -->
8-
9-
Closes: FILL_THIS_OUT
7+
<!-- Describe or link the issue that this PR closes. -->
108

119
## Approach
1210
<!-- Describe the changes in this PR. -->

.github/workflows/ci-performance.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -245,6 +245,7 @@ jobs:
245245
console.log('');
246246
if (hasRegression) {
247247
console.log('⚠️ **Performance regressions detected.** Please review the changes.');
248+
process.exitCode = 1;
248249
} else if (hasImprovement) {
249250
console.log('🚀 **Performance improvements detected!** Great work!');
250251
} else {

benchmark/performance.js

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -791,6 +791,38 @@ async function benchmarkLiveQueryRegex(name) {
791791
}
792792
}
793793

794+
/**
795+
* Benchmark: Object.save with nested data (denylist scanning)
796+
*
797+
* Measures create latency for objects with deeply nested structures containing
798+
* multiple sibling objects at each level. This exercises the requestKeywordDenylist
799+
* scanner (objectContainsKeyValue) which must traverse all keys and nested values.
800+
*/
801+
async function benchmarkObjectCreateNestedDenylist(name) {
802+
let counter = 0;
803+
804+
return measureOperation({
805+
name,
806+
iterations: 1_000,
807+
operation: async () => {
808+
const TestObject = Parse.Object.extend('BenchmarkDenylist');
809+
const obj = new TestObject();
810+
const idx = counter++;
811+
obj.set('nested', {
812+
meta1: { info: { detail: `value-${idx}` } },
813+
meta2: { info: { detail: `value-${idx}` } },
814+
meta3: { info: { detail: `value-${idx}` } },
815+
tags: ['a', 'b', 'c'],
816+
config: {
817+
setting1: { enabled: true, params: { x: 1 } },
818+
setting2: { enabled: false, params: { y: 2 } },
819+
},
820+
});
821+
await obj.save();
822+
},
823+
});
824+
}
825+
794826
/**
795827
* Run all benchmarks
796828
*/
@@ -824,6 +856,7 @@ async function runBenchmarks() {
824856
{ name: 'Query.include (nested pointers)', fn: benchmarkQueryWithIncludeNested },
825857
{ name: 'Query.find (large result, GC pressure)', fn: benchmarkLargeResultMemory },
826858
{ name: 'Query.find (concurrent, GC pressure)', fn: benchmarkConcurrentQueryMemory },
859+
{ name: 'Object.save (nested data, denylist scan)', fn: benchmarkObjectCreateNestedDenylist },
827860
{ name: 'Query $regex', fn: benchmarkQueryRegex },
828861
{ name: 'LiveQuery $regex', fn: benchmarkLiveQueryRegex },
829862
];

changelogs/CHANGELOG_alpha.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,17 @@
1+
## [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)
2+
3+
4+
### Bug Fixes
5+
6+
* 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))
7+
8+
## [9.5.1-alpha.1](https://github.com/parse-community/parse-server/compare/9.5.0...9.5.1-alpha.1) (2026-03-07)
9+
10+
11+
### Bug Fixes
12+
13+
* 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))
14+
115
# [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)
216

317

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "parse-server",
3-
"version": "9.5.0",
3+
"version": "9.5.1-alpha.2",
44
"description": "An express module providing a Parse-compatible API server",
55
"main": "lib/index.js",
66
"repository": {

spec/vulnerabilities.spec.js

Lines changed: 221 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,7 +160,228 @@ describe('Vulnerabilities', () => {
160160
});
161161
});
162162

163+
describe('(GHSA-5j86-7r7m-p8h6) Cloud function name prototype chain bypass', () => {
164+
const headers = {
165+
'Content-Type': 'application/json',
166+
'X-Parse-Application-Id': 'test',
167+
'X-Parse-REST-API-Key': 'rest',
168+
};
169+
170+
it('rejects "constructor" as cloud function name', async () => {
171+
const response = await request({
172+
headers,
173+
method: 'POST',
174+
url: 'http://localhost:8378/1/functions/constructor',
175+
body: JSON.stringify({}),
176+
}).catch(e => e);
177+
expect(response.status).toBe(400);
178+
const text = JSON.parse(response.text);
179+
expect(text.code).toBe(Parse.Error.SCRIPT_FAILED);
180+
expect(text.error).toContain('Invalid function');
181+
});
182+
183+
it('rejects "toString" as cloud function name', async () => {
184+
const response = await request({
185+
headers,
186+
method: 'POST',
187+
url: 'http://localhost:8378/1/functions/toString',
188+
body: JSON.stringify({}),
189+
}).catch(e => e);
190+
expect(response.status).toBe(400);
191+
const text = JSON.parse(response.text);
192+
expect(text.code).toBe(Parse.Error.SCRIPT_FAILED);
193+
expect(text.error).toContain('Invalid function');
194+
});
195+
196+
it('rejects "valueOf" as cloud function name', async () => {
197+
const response = await request({
198+
headers,
199+
method: 'POST',
200+
url: 'http://localhost:8378/1/functions/valueOf',
201+
body: JSON.stringify({}),
202+
}).catch(e => e);
203+
expect(response.status).toBe(400);
204+
const text = JSON.parse(response.text);
205+
expect(text.code).toBe(Parse.Error.SCRIPT_FAILED);
206+
expect(text.error).toContain('Invalid function');
207+
});
208+
209+
it('rejects "hasOwnProperty" as cloud function name', async () => {
210+
const response = await request({
211+
headers,
212+
method: 'POST',
213+
url: 'http://localhost:8378/1/functions/hasOwnProperty',
214+
body: JSON.stringify({}),
215+
}).catch(e => e);
216+
expect(response.status).toBe(400);
217+
const text = JSON.parse(response.text);
218+
expect(text.code).toBe(Parse.Error.SCRIPT_FAILED);
219+
expect(text.error).toContain('Invalid function');
220+
});
221+
222+
it('rejects "__proto__.toString" as cloud function name', async () => {
223+
const response = await request({
224+
headers,
225+
method: 'POST',
226+
url: 'http://localhost:8378/1/functions/__proto__.toString',
227+
body: JSON.stringify({}),
228+
}).catch(e => e);
229+
expect(response.status).toBe(400);
230+
const text = JSON.parse(response.text);
231+
expect(text.code).toBe(Parse.Error.SCRIPT_FAILED);
232+
expect(text.error).toContain('Invalid function');
233+
});
234+
235+
it('still executes a legitimately defined cloud function', async () => {
236+
Parse.Cloud.define('legitimateFunction', () => 'hello');
237+
const response = await request({
238+
headers,
239+
method: 'POST',
240+
url: 'http://localhost:8378/1/functions/legitimateFunction',
241+
body: JSON.stringify({}),
242+
});
243+
expect(response.status).toBe(200);
244+
expect(JSON.parse(response.text).result).toBe('hello');
245+
});
246+
});
247+
163248
describe('Request denylist', () => {
249+
describe('(GHSA-q342-9w2p-57fp) Denylist bypass via sibling nested objects', () => {
250+
it('denies _bsontype:Code after a sibling nested object', async () => {
251+
const headers = {
252+
'Content-Type': 'application/json',
253+
'X-Parse-Application-Id': 'test',
254+
'X-Parse-REST-API-Key': 'rest',
255+
};
256+
const response = await request({
257+
headers,
258+
method: 'POST',
259+
url: 'http://localhost:8378/1/classes/Bypass',
260+
body: JSON.stringify({
261+
obj: {
262+
metadata: {},
263+
_bsontype: 'Code',
264+
code: 'malicious',
265+
},
266+
}),
267+
}).catch(e => e);
268+
expect(response.status).toBe(400);
269+
const text = JSON.parse(response.text);
270+
expect(text.code).toBe(Parse.Error.INVALID_KEY_NAME);
271+
expect(text.error).toBe(
272+
'Prohibited keyword in request data: {"key":"_bsontype","value":"Code"}.'
273+
);
274+
});
275+
276+
it('denies _bsontype:Code after a sibling nested array', async () => {
277+
const headers = {
278+
'Content-Type': 'application/json',
279+
'X-Parse-Application-Id': 'test',
280+
'X-Parse-REST-API-Key': 'rest',
281+
};
282+
const response = await request({
283+
headers,
284+
method: 'POST',
285+
url: 'http://localhost:8378/1/classes/Bypass',
286+
body: JSON.stringify({
287+
obj: {
288+
tags: ['safe'],
289+
_bsontype: 'Code',
290+
code: 'malicious',
291+
},
292+
}),
293+
}).catch(e => e);
294+
expect(response.status).toBe(400);
295+
const text = JSON.parse(response.text);
296+
expect(text.code).toBe(Parse.Error.INVALID_KEY_NAME);
297+
expect(text.error).toBe(
298+
'Prohibited keyword in request data: {"key":"_bsontype","value":"Code"}.'
299+
);
300+
});
301+
302+
it('denies __proto__ after a sibling nested object', async () => {
303+
// Cannot test via HTTP because deepcopy() strips __proto__ before the denylist
304+
// check runs. Test objectContainsKeyValue directly with a JSON.parse'd object
305+
// that preserves __proto__ as an own property.
306+
const Utils = require('../lib/Utils');
307+
const data = JSON.parse('{"profile": {"name": "alice"}, "__proto__": {"isAdmin": true}}');
308+
expect(Utils.objectContainsKeyValue(data, '__proto__', undefined)).toBe(true);
309+
});
310+
311+
it('denies constructor after a sibling nested object', async () => {
312+
const headers = {
313+
'Content-Type': 'application/json',
314+
'X-Parse-Application-Id': 'test',
315+
'X-Parse-REST-API-Key': 'rest',
316+
};
317+
const response = await request({
318+
headers,
319+
method: 'POST',
320+
url: 'http://localhost:8378/1/classes/Bypass',
321+
body: JSON.stringify({
322+
obj: {
323+
data: {},
324+
constructor: { prototype: { polluted: true } },
325+
},
326+
}),
327+
}).catch(e => e);
328+
expect(response.status).toBe(400);
329+
const text = JSON.parse(response.text);
330+
expect(text.code).toBe(Parse.Error.INVALID_KEY_NAME);
331+
expect(text.error).toBe(
332+
'Prohibited keyword in request data: {"key":"constructor"}.'
333+
);
334+
});
335+
336+
it('denies _bsontype:Code nested inside a second sibling object', async () => {
337+
const headers = {
338+
'Content-Type': 'application/json',
339+
'X-Parse-Application-Id': 'test',
340+
'X-Parse-REST-API-Key': 'rest',
341+
};
342+
const response = await request({
343+
headers,
344+
method: 'POST',
345+
url: 'http://localhost:8378/1/classes/Bypass',
346+
body: JSON.stringify({
347+
field1: { safe: true },
348+
field2: { _bsontype: 'Code', code: 'malicious' },
349+
}),
350+
}).catch(e => e);
351+
expect(response.status).toBe(400);
352+
const text = JSON.parse(response.text);
353+
expect(text.code).toBe(Parse.Error.INVALID_KEY_NAME);
354+
expect(text.error).toBe(
355+
'Prohibited keyword in request data: {"key":"_bsontype","value":"Code"}.'
356+
);
357+
});
358+
359+
it('handles circular references without infinite loop', () => {
360+
const Utils = require('../lib/Utils');
361+
const obj = { name: 'test', nested: { value: 1 } };
362+
obj.nested.self = obj;
363+
expect(Utils.objectContainsKeyValue(obj, 'nonexistent', undefined)).toBe(false);
364+
});
365+
366+
it('denies _bsontype:Code in file metadata after a sibling nested object', async () => {
367+
const str = 'Hello World!';
368+
const data = [];
369+
for (let i = 0; i < str.length; i++) {
370+
data.push(str.charCodeAt(i));
371+
}
372+
const file = new Parse.File('hello.txt', data, 'text/plain');
373+
file.addMetadata('nested', { safe: true });
374+
file.addMetadata('_bsontype', 'Code');
375+
file.addMetadata('code', 'malicious');
376+
await expectAsync(file.save()).toBeRejectedWith(
377+
new Parse.Error(
378+
Parse.Error.INVALID_KEY_NAME,
379+
'Prohibited keyword in request data: {"key":"_bsontype","value":"Code"}.'
380+
)
381+
);
382+
});
383+
});
384+
164385
it('denies BSON type code data in write request by default', async () => {
165386
const headers = {
166387
'Content-Type': 'application/json',

src/Utils.js

Lines changed: 18 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -344,16 +344,25 @@ class Utils {
344344
const isMatch = (a, b) => (typeof a === 'string' && new RegExp(b).test(a)) || a === b;
345345
const isKeyMatch = k => isMatch(k, key);
346346
const isValueMatch = v => isMatch(v, value);
347-
for (const [k, v] of Object.entries(obj)) {
348-
if (key !== undefined && value === undefined && isKeyMatch(k)) {
349-
return true;
350-
} else if (key === undefined && value !== undefined && isValueMatch(v)) {
351-
return true;
352-
} else if (key !== undefined && value !== undefined && isKeyMatch(k) && isValueMatch(v)) {
353-
return true;
347+
const stack = [obj];
348+
const seen = new WeakSet();
349+
while (stack.length > 0) {
350+
const current = stack.pop();
351+
if (seen.has(current)) {
352+
continue;
354353
}
355-
if (['[object Object]', '[object Array]'].includes(Object.prototype.toString.call(v))) {
356-
return Utils.objectContainsKeyValue(v, key, value);
354+
seen.add(current);
355+
for (const [k, v] of Object.entries(current)) {
356+
if (key !== undefined && value === undefined && isKeyMatch(k)) {
357+
return true;
358+
} else if (key === undefined && value !== undefined && isValueMatch(v)) {
359+
return true;
360+
} else if (key !== undefined && value !== undefined && isKeyMatch(k) && isValueMatch(v)) {
361+
return true;
362+
}
363+
if (['[object Object]', '[object Array]'].includes(Object.prototype.toString.call(v))) {
364+
stack.push(v);
365+
}
357366
}
358367
}
359368
return false;

0 commit comments

Comments
 (0)