fix: 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)) (#10122)

This commit is contained in:
Manuel
2026-03-07 16:40:49 +00:00
committed by GitHub
parent aa7c57e917
commit 2b52feb064
3 changed files with 187 additions and 9 deletions
+33
View File
@@ -525,6 +525,38 @@ async function benchmarkQueryWithIncludeNested(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
*/
@@ -554,6 +586,7 @@ async function runBenchmarks() {
{ name: 'User.login', fn: benchmarkUserLogin },
{ name: 'Query.include (parallel pointers)', fn: benchmarkQueryWithIncludeParallel },
{ name: 'Query.include (nested pointers)', fn: benchmarkQueryWithIncludeNested },
{ name: 'Object.save (nested data, denylist scan)', fn: benchmarkObjectCreateNestedDenylist },
];
// Run each benchmark with database cleanup
+136
View File
@@ -132,6 +132,142 @@ describe('Vulnerabilities', () => {
});
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',
+18 -9
View File
@@ -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;