mirror of
https://github.com/parse-community/parse-server
synced 2026-08-09 13:03:18 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user