Skip to content

Commit 43658f1

Browse files
authored
fix: Relation $relatedTo query bypasses protectedFields and owning-object ACL ([GHSA-wmwx-jr2p-4j4r](GHSA-wmwx-jr2p-4j4r)) (parse-community#10493)
1 parent a4118f6 commit 43658f1

3 files changed

Lines changed: 390 additions & 13 deletions

File tree

benchmark/performance.js

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -829,6 +829,40 @@ async function benchmarkObjectCreateNestedDenylist(name) {
829829
});
830830
}
831831

832+
/**
833+
* Benchmark: $relatedTo relation query (public, non-master)
834+
*
835+
* Measures a public `$relatedTo` query, which now performs an owning-object
836+
* read-access check before reading the relation join table (GHSA-wmwx-jr2p-4j4r).
837+
* This captures the cost of that added authorization read on the relation path.
838+
*/
839+
async function benchmarkRelatedToQuery(name) {
840+
const Child = Parse.Object.extend('BenchmarkRelChild');
841+
const children = [];
842+
for (let i = 0; i < 50; i++) {
843+
children.push(new Child({ value: i }));
844+
}
845+
await Parse.Object.saveAll(children, { useMasterKey: true });
846+
847+
// Publicly readable owning object, so the authorized relation path runs fully.
848+
const Parent = Parse.Object.extend('BenchmarkRelParent');
849+
const parent = new Parent({ name: 'benchmark-parent' });
850+
const acl = new Parse.ACL();
851+
acl.setPublicReadAccess(true);
852+
parent.setACL(acl);
853+
parent.relation('members').add(children);
854+
await parent.save(null, { useMasterKey: true });
855+
856+
return measureOperation({
857+
name,
858+
iterations: 1_000,
859+
operation: async () => {
860+
// Non-master query exercises the owning-object read-access check.
861+
await parent.relation('members').query().find();
862+
},
863+
});
864+
}
865+
832866
/**
833867
* Run all benchmarks
834868
*/
@@ -856,6 +890,7 @@ async function runBenchmarks() {
856890
{ name: 'Object.saveAll (batch save)', fn: benchmarkBatchSave },
857891
{ name: 'Query.get (by objectId)', fn: benchmarkObjectRead },
858892
{ name: 'Query.find (simple query)', fn: benchmarkSimpleQuery },
893+
{ name: 'Query.find ($relatedTo relation)', fn: benchmarkRelatedToQuery },
859894
{ name: 'User.signUp', fn: benchmarkUserSignup },
860895
{ name: 'User.login', fn: benchmarkUserLogin },
861896
{ name: 'Query.include (parallel pointers)', fn: benchmarkQueryWithIncludeParallel },

spec/vulnerabilities.spec.js

Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2307,6 +2307,207 @@ describe('Vulnerabilities', () => {
23072307
});
23082308
});
23092309

2310+
describe('(GHSA-wmwx-jr2p-4j4r) $relatedTo bypasses protectedFields and parent ACL for Relation fields', () => {
2311+
let childLinked;
2312+
let parentProtectedKey;
2313+
let parentPrivate;
2314+
let parentPublic;
2315+
2316+
const relatedToWhere = (parentId, key, extra = {}) => ({
2317+
$relatedTo: {
2318+
object: { __type: 'Pointer', className: 'RelParent', objectId: parentId },
2319+
key,
2320+
},
2321+
...extra,
2322+
});
2323+
2324+
const queryChild = (where, headers = {}) =>
2325+
request({
2326+
method: 'GET',
2327+
url: `${Parse.serverURL}/classes/RelChild`,
2328+
headers: {
2329+
'X-Parse-Application-Id': Parse.applicationId,
2330+
'X-Parse-REST-API-Key': 'rest',
2331+
...headers,
2332+
},
2333+
qs: { where: JSON.stringify(where) },
2334+
}).catch(e => e);
2335+
2336+
beforeEach(async () => {
2337+
const schema = new Parse.Schema('RelParent');
2338+
schema.addString('name');
2339+
schema.addRelation('secretRel', 'RelChild');
2340+
schema.addRelation('openRel', 'RelChild');
2341+
schema.setCLP({
2342+
find: { '*': true },
2343+
get: { '*': true },
2344+
create: { '*': true },
2345+
update: { '*': true },
2346+
delete: { '*': true },
2347+
addField: {},
2348+
// secretRel is a protected Relation field for public clients
2349+
protectedFields: { '*': ['secretRel'] },
2350+
});
2351+
await schema.save();
2352+
2353+
childLinked = new Parse.Object('RelChild', { value: 'linked child' });
2354+
await childLinked.save(null, { useMasterKey: true });
2355+
2356+
const publicAcl = new Parse.ACL();
2357+
publicAcl.setPublicReadAccess(true);
2358+
2359+
const privateAcl = new Parse.ACL();
2360+
privateAcl.setPublicReadAccess(false);
2361+
privateAcl.setPublicWriteAccess(false);
2362+
2363+
// Publicly readable parent whose relation key is protected (isolates the
2364+
// protectedFields facet).
2365+
parentProtectedKey = new Parse.Object('RelParent', { name: 'protected-key parent' });
2366+
parentProtectedKey.setACL(publicAcl);
2367+
parentProtectedKey.relation('secretRel').add(childLinked);
2368+
await parentProtectedKey.save(null, { useMasterKey: true });
2369+
2370+
// Parent that is not readable by the public, queried via a non-protected
2371+
// relation key (isolates the parent-ACL facet).
2372+
parentPrivate = new Parse.Object('RelParent', { name: 'private parent' });
2373+
parentPrivate.setACL(privateAcl);
2374+
parentPrivate.relation('openRel').add(childLinked);
2375+
await parentPrivate.save(null, { useMasterKey: true });
2376+
2377+
// Publicly readable parent with a non-protected relation key (legitimate
2378+
// use that must keep working).
2379+
parentPublic = new Parse.Object('RelParent', { name: 'public parent' });
2380+
parentPublic.setACL(publicAcl);
2381+
parentPublic.relation('openRel').add(childLinked);
2382+
await parentPublic.save(null, { useMasterKey: true });
2383+
});
2384+
2385+
it('denies $relatedTo query that references a protected relation field', async () => {
2386+
const res = await queryChild(relatedToWhere(parentProtectedKey.id, 'secretRel'));
2387+
expect(res.data.code).toBe(Parse.Error.OPERATION_FORBIDDEN);
2388+
expect(res.data.error).toBe('Permission denied');
2389+
});
2390+
2391+
it('denies $relatedTo on a protected relation field nested in $or', async () => {
2392+
const res = await queryChild({
2393+
$or: [relatedToWhere(parentProtectedKey.id, 'secretRel')],
2394+
});
2395+
expect(res.data.code).toBe(Parse.Error.OPERATION_FORBIDDEN);
2396+
expect(res.data.error).toBe('Permission denied');
2397+
});
2398+
2399+
it('denies $relatedTo on a protected relation field nested in $and', async () => {
2400+
const res = await queryChild({
2401+
$and: [relatedToWhere(parentProtectedKey.id, 'secretRel')],
2402+
});
2403+
expect(res.data.code).toBe(Parse.Error.OPERATION_FORBIDDEN);
2404+
expect(res.data.error).toBe('Permission denied');
2405+
});
2406+
2407+
it('denies $relatedTo on a protected relation field nested in $nor', async () => {
2408+
const res = await queryChild({
2409+
$nor: [relatedToWhere(parentProtectedKey.id, 'secretRel')],
2410+
});
2411+
expect(res.data.code).toBe(Parse.Error.OPERATION_FORBIDDEN);
2412+
expect(res.data.error).toBe('Permission denied');
2413+
});
2414+
2415+
it('returns no results when the owning object is not readable by the caller', async () => {
2416+
const res = await queryChild(relatedToWhere(parentPrivate.id, 'openRel'));
2417+
expect(res.data.results).toEqual([]);
2418+
});
2419+
2420+
it('does not act as a membership oracle for an unreadable owning object', async () => {
2421+
const res = await queryChild(
2422+
relatedToWhere(parentPrivate.id, 'openRel', { objectId: childLinked.id })
2423+
);
2424+
expect(res.data.results).toEqual([]);
2425+
});
2426+
2427+
it('still returns related objects for a readable parent and non-protected key', async () => {
2428+
const res = await queryChild(relatedToWhere(parentPublic.id, 'openRel'));
2429+
expect(res.data.results.length).toBe(1);
2430+
expect(res.data.results[0].objectId).toBe(childLinked.id);
2431+
});
2432+
2433+
it('allows master key to query a protected relation and an unreadable parent', async () => {
2434+
const masterHeaders = { 'X-Parse-Master-Key': Parse.masterKey };
2435+
const resProtected = await queryChild(
2436+
relatedToWhere(parentProtectedKey.id, 'secretRel'),
2437+
masterHeaders
2438+
);
2439+
expect(resProtected.data.results.length).toBe(1);
2440+
const resPrivate = await queryChild(
2441+
relatedToWhere(parentPrivate.id, 'openRel'),
2442+
masterHeaders
2443+
);
2444+
expect(resPrivate.data.results.length).toBe(1);
2445+
});
2446+
2447+
it('respects user-level read access to the owning object', async () => {
2448+
const userA = await Parse.User.signUp('relUserA', 'pw');
2449+
const userB = await Parse.User.signUp('relUserB', 'pw');
2450+
2451+
const acl = new Parse.ACL();
2452+
acl.setReadAccess(userA, true);
2453+
const parent = new Parse.Object('RelParent', { name: 'user-scoped parent' });
2454+
parent.setACL(acl);
2455+
parent.relation('openRel').add(childLinked);
2456+
await parent.save(null, { useMasterKey: true });
2457+
2458+
const resA = await queryChild(relatedToWhere(parent.id, 'openRel'), {
2459+
'X-Parse-Session-Token': userA.getSessionToken(),
2460+
});
2461+
expect(resA.data.results.length).toBe(1);
2462+
2463+
const resB = await queryChild(relatedToWhere(parent.id, 'openRel'), {
2464+
'X-Parse-Session-Token': userB.getSessionToken(),
2465+
});
2466+
expect(resB.data.results).toEqual([]);
2467+
});
2468+
2469+
it('returns no results when the owning class denies get permission (CLP)', async () => {
2470+
// Owning class denies public `get`, so the owning-object read throws
2471+
// OPERATION_FORBIDDEN; the relation must then return no results.
2472+
const schema = new Parse.Schema('RelParentNoGet');
2473+
schema.addRelation('members', 'RelChild');
2474+
schema.setCLP({
2475+
find: { '*': true },
2476+
get: {},
2477+
create: { '*': true },
2478+
update: { '*': true },
2479+
delete: { '*': true },
2480+
addField: {},
2481+
});
2482+
await schema.save();
2483+
2484+
const acl = new Parse.ACL();
2485+
acl.setPublicReadAccess(true);
2486+
const parent = new Parse.Object('RelParentNoGet', { name: 'no-get parent' });
2487+
parent.setACL(acl);
2488+
parent.relation('members').add(childLinked);
2489+
await parent.save(null, { useMasterKey: true });
2490+
2491+
const res = await request({
2492+
method: 'GET',
2493+
url: `${Parse.serverURL}/classes/RelChild`,
2494+
headers: {
2495+
'X-Parse-Application-Id': Parse.applicationId,
2496+
'X-Parse-REST-API-Key': 'rest',
2497+
},
2498+
qs: {
2499+
where: JSON.stringify({
2500+
$relatedTo: {
2501+
object: { __type: 'Pointer', className: 'RelParentNoGet', objectId: parent.id },
2502+
key: 'members',
2503+
},
2504+
}),
2505+
},
2506+
}).catch(e => e);
2507+
expect(res.data.results).toEqual([]);
2508+
});
2509+
});
2510+
23102511
describe('(GHSA-j7mm-f4rv-6q6q) Protected fields bypass via LiveQuery dot-notation WHERE', () => {
23112512
let obj;
23122513

0 commit comments

Comments
 (0)