Skip to content

Commit ac3c6c4

Browse files
committed
fix: LiveQuery bypasses CLP pointer permission enforcement (GHSA-fph2-r4qg-9576)
1 parent b50e14e commit ac3c6c4

File tree

2 files changed

+353
-1
lines changed

2 files changed

+353
-1
lines changed

spec/vulnerabilities.spec.js

Lines changed: 286 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3349,3 +3349,289 @@ describe('(GHSA-5hmj-jcgp-6hff) Protected fields leak via LiveQuery afterEvent t
33493349
});
33503350
});
33513351
});
3352+
3353+
describe('(GHSA-fph2-r4qg-9576) LiveQuery bypasses CLP pointer permission enforcement', () => {
3354+
const { sleep } = require('../lib/TestUtils');
3355+
3356+
async function updateCLP(className, permissions) {
3357+
const response = await fetch(Parse.serverURL + '/schemas/' + className, {
3358+
method: 'PUT',
3359+
headers: {
3360+
'X-Parse-Application-Id': Parse.applicationId,
3361+
'X-Parse-Master-Key': Parse.masterKey,
3362+
'Content-Type': 'application/json',
3363+
},
3364+
body: JSON.stringify({ classLevelPermissions: permissions }),
3365+
});
3366+
const body = await response.json();
3367+
if (body.error) {
3368+
throw body;
3369+
}
3370+
return body;
3371+
}
3372+
3373+
it('should not deliver LiveQuery events to user not in readUserFields pointer', async () => {
3374+
await reconfigureServer({
3375+
liveQuery: { classNames: ['PrivateMessage'] },
3376+
startLiveQueryServer: true,
3377+
verbose: false,
3378+
silent: true,
3379+
});
3380+
3381+
// Create users using master key to avoid session management issues
3382+
const userA = new Parse.User();
3383+
userA.setUsername('userA_pointer');
3384+
userA.setPassword('password123');
3385+
await userA.signUp();
3386+
await Parse.User.logOut();
3387+
3388+
// User B stays logged in for the subscription
3389+
const userB = new Parse.User();
3390+
userB.setUsername('userB_pointer');
3391+
userB.setPassword('password456');
3392+
await userB.signUp();
3393+
3394+
// Create schema by saving an object with owner pointer, then set CLP
3395+
const seed = new Parse.Object('PrivateMessage');
3396+
seed.set('owner', userA);
3397+
await seed.save(null, { useMasterKey: true });
3398+
await seed.destroy({ useMasterKey: true });
3399+
3400+
await updateCLP('PrivateMessage', {
3401+
create: { '*': true },
3402+
find: {},
3403+
get: {},
3404+
readUserFields: ['owner'],
3405+
});
3406+
3407+
// User B subscribes — should NOT receive events for User A's objects
3408+
const query = new Parse.Query('PrivateMessage');
3409+
const subscription = await query.subscribe(userB.getSessionToken());
3410+
3411+
const createSpy = jasmine.createSpy('create');
3412+
const enterSpy = jasmine.createSpy('enter');
3413+
subscription.on('create', createSpy);
3414+
subscription.on('enter', enterSpy);
3415+
3416+
// Create a message owned by User A
3417+
const msg = new Parse.Object('PrivateMessage');
3418+
msg.set('content', 'secret message');
3419+
msg.set('owner', userA);
3420+
await msg.save(null, { useMasterKey: true });
3421+
3422+
await sleep(500);
3423+
3424+
// User B should NOT have received the create event
3425+
expect(createSpy).not.toHaveBeenCalled();
3426+
expect(enterSpy).not.toHaveBeenCalled();
3427+
});
3428+
3429+
it('should deliver LiveQuery events to user in readUserFields pointer', async () => {
3430+
await reconfigureServer({
3431+
liveQuery: { classNames: ['PrivateMessage2'] },
3432+
startLiveQueryServer: true,
3433+
verbose: false,
3434+
silent: true,
3435+
});
3436+
3437+
// User A stays logged in for the subscription
3438+
const userA = new Parse.User();
3439+
userA.setUsername('userA_owner');
3440+
userA.setPassword('password123');
3441+
await userA.signUp();
3442+
3443+
// Create schema by saving an object with owner pointer
3444+
const seed = new Parse.Object('PrivateMessage2');
3445+
seed.set('owner', userA);
3446+
await seed.save(null, { useMasterKey: true });
3447+
await seed.destroy({ useMasterKey: true });
3448+
3449+
await updateCLP('PrivateMessage2', {
3450+
create: { '*': true },
3451+
find: {},
3452+
get: {},
3453+
readUserFields: ['owner'],
3454+
});
3455+
3456+
// User A subscribes — SHOULD receive events for their own objects
3457+
const query = new Parse.Query('PrivateMessage2');
3458+
const subscription = await query.subscribe(userA.getSessionToken());
3459+
3460+
const createSpy = jasmine.createSpy('create');
3461+
subscription.on('create', createSpy);
3462+
3463+
// Create a message owned by User A
3464+
const msg = new Parse.Object('PrivateMessage2');
3465+
msg.set('content', 'my own message');
3466+
msg.set('owner', userA);
3467+
await msg.save(null, { useMasterKey: true });
3468+
3469+
await sleep(500);
3470+
3471+
// User A SHOULD have received the create event
3472+
expect(createSpy).toHaveBeenCalledTimes(1);
3473+
});
3474+
3475+
it('should not deliver LiveQuery events when find uses pointerFields', async () => {
3476+
await reconfigureServer({
3477+
liveQuery: { classNames: ['PrivateDoc'] },
3478+
startLiveQueryServer: true,
3479+
verbose: false,
3480+
silent: true,
3481+
});
3482+
3483+
const userA = new Parse.User();
3484+
userA.setUsername('userA_doc');
3485+
userA.setPassword('password123');
3486+
await userA.signUp();
3487+
await Parse.User.logOut();
3488+
3489+
// User B stays logged in for the subscription
3490+
const userB = new Parse.User();
3491+
userB.setUsername('userB_doc');
3492+
userB.setPassword('password456');
3493+
await userB.signUp();
3494+
3495+
// Create schema by saving an object with recipient pointer
3496+
const seed = new Parse.Object('PrivateDoc');
3497+
seed.set('recipient', userA);
3498+
await seed.save(null, { useMasterKey: true });
3499+
await seed.destroy({ useMasterKey: true });
3500+
3501+
// Set CLP with pointerFields instead of readUserFields
3502+
await updateCLP('PrivateDoc', {
3503+
create: { '*': true },
3504+
find: { pointerFields: ['recipient'] },
3505+
get: { pointerFields: ['recipient'] },
3506+
});
3507+
3508+
// User B subscribes
3509+
const query = new Parse.Query('PrivateDoc');
3510+
const subscription = await query.subscribe(userB.getSessionToken());
3511+
3512+
const createSpy = jasmine.createSpy('create');
3513+
subscription.on('create', createSpy);
3514+
3515+
// Create doc with recipient = User A (not User B)
3516+
const doc = new Parse.Object('PrivateDoc');
3517+
doc.set('title', 'confidential');
3518+
doc.set('recipient', userA);
3519+
await doc.save(null, { useMasterKey: true });
3520+
3521+
await sleep(500);
3522+
3523+
// User B should NOT receive events for User A's document
3524+
expect(createSpy).not.toHaveBeenCalled();
3525+
});
3526+
3527+
it('should not deliver LiveQuery events to unauthenticated users for pointer-protected classes', async () => {
3528+
await reconfigureServer({
3529+
liveQuery: { classNames: ['SecureItem'] },
3530+
startLiveQueryServer: true,
3531+
verbose: false,
3532+
silent: true,
3533+
});
3534+
3535+
const userA = new Parse.User();
3536+
userA.setUsername('userA_secure');
3537+
userA.setPassword('password123');
3538+
await userA.signUp();
3539+
await Parse.User.logOut();
3540+
3541+
// Create schema
3542+
const seed = new Parse.Object('SecureItem');
3543+
seed.set('owner', userA);
3544+
await seed.save(null, { useMasterKey: true });
3545+
await seed.destroy({ useMasterKey: true });
3546+
3547+
await updateCLP('SecureItem', {
3548+
create: { '*': true },
3549+
find: {},
3550+
get: {},
3551+
readUserFields: ['owner'],
3552+
});
3553+
3554+
// Unauthenticated subscription
3555+
const query = new Parse.Query('SecureItem');
3556+
const subscription = await query.subscribe();
3557+
3558+
const createSpy = jasmine.createSpy('create');
3559+
subscription.on('create', createSpy);
3560+
3561+
const item = new Parse.Object('SecureItem');
3562+
item.set('data', 'private');
3563+
item.set('owner', userA);
3564+
await item.save(null, { useMasterKey: true });
3565+
3566+
await sleep(500);
3567+
3568+
expect(createSpy).not.toHaveBeenCalled();
3569+
});
3570+
3571+
it('should handle readUserFields with array of pointers', async () => {
3572+
await reconfigureServer({
3573+
liveQuery: { classNames: ['SharedDoc'] },
3574+
startLiveQueryServer: true,
3575+
verbose: false,
3576+
silent: true,
3577+
});
3578+
3579+
const userA = new Parse.User();
3580+
userA.setUsername('userA_shared');
3581+
userA.setPassword('password123');
3582+
await userA.signUp();
3583+
await Parse.User.logOut();
3584+
3585+
// User B — don't log out, session must remain valid
3586+
const userB = new Parse.User();
3587+
userB.setUsername('userB_shared');
3588+
userB.setPassword('password456');
3589+
await userB.signUp();
3590+
const userBSessionToken = userB.getSessionToken();
3591+
3592+
// User C — signUp changes current user to C, but B's session stays valid
3593+
const userC = new Parse.User();
3594+
userC.setUsername('userC_shared');
3595+
userC.setPassword('password789');
3596+
await userC.signUp();
3597+
const userCSessionToken = userC.getSessionToken();
3598+
3599+
// Create schema with array field
3600+
const seed = new Parse.Object('SharedDoc');
3601+
seed.set('collaborators', [userA]);
3602+
await seed.save(null, { useMasterKey: true });
3603+
await seed.destroy({ useMasterKey: true });
3604+
3605+
await updateCLP('SharedDoc', {
3606+
create: { '*': true },
3607+
find: {},
3608+
get: {},
3609+
readUserFields: ['collaborators'],
3610+
});
3611+
3612+
// User B subscribes — is in the collaborators array
3613+
const queryB = new Parse.Query('SharedDoc');
3614+
const subscriptionB = await queryB.subscribe(userBSessionToken);
3615+
const createSpyB = jasmine.createSpy('createB');
3616+
subscriptionB.on('create', createSpyB);
3617+
3618+
// User C subscribes — is NOT in the collaborators array
3619+
const queryC = new Parse.Query('SharedDoc');
3620+
const subscriptionC = await queryC.subscribe(userCSessionToken);
3621+
const createSpyC = jasmine.createSpy('createC');
3622+
subscriptionC.on('create', createSpyC);
3623+
3624+
// Create doc with collaborators = [userA, userB] (not userC)
3625+
const doc = new Parse.Object('SharedDoc');
3626+
doc.set('title', 'team doc');
3627+
doc.set('collaborators', [userA, userB]);
3628+
await doc.save(null, { useMasterKey: true });
3629+
3630+
await sleep(500);
3631+
3632+
// User B SHOULD receive the event (in collaborators array)
3633+
expect(createSpyB).toHaveBeenCalledTimes(1);
3634+
// User C should NOT receive the event
3635+
expect(createSpyC).not.toHaveBeenCalled();
3636+
});
3637+
});

src/LiveQuery/ParseLiveQueryServer.ts

Lines changed: 67 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -659,8 +659,10 @@ class ParseLiveQueryServer {
659659
): Promise<any> {
660660
const subscriptionInfo = client.getSubscriptionInfo(requestId);
661661
const aclGroup = ['*'];
662+
let userId;
662663
if (typeof subscriptionInfo !== 'undefined') {
663-
const { userId } = await this.getAuthForSessionToken(subscriptionInfo.sessionToken);
664+
const result = await this.getAuthForSessionToken(subscriptionInfo.sessionToken);
665+
userId = result.userId;
664666
if (userId) {
665667
aclGroup.push(userId);
666668
}
@@ -671,6 +673,70 @@ class ParseLiveQueryServer {
671673
aclGroup,
672674
op
673675
);
676+
// Enforce pointer permissions that validatePermission defers
677+
if (!client.hasMasterKey && classLevelPermissions) {
678+
const permissionField =
679+
['get', 'find', 'count'].indexOf(op) > -1 ? 'readUserFields' : 'writeUserFields';
680+
const pointerFields = [];
681+
if (classLevelPermissions[op]?.pointerFields) {
682+
pointerFields.push(...classLevelPermissions[op].pointerFields);
683+
}
684+
if (Array.isArray(classLevelPermissions[permissionField])) {
685+
for (const field of classLevelPermissions[permissionField]) {
686+
if (!pointerFields.includes(field)) {
687+
pointerFields.push(field);
688+
}
689+
}
690+
}
691+
if (pointerFields.length > 0) {
692+
// If public or user-specific permission already grants access, skip pointer check
693+
if (
694+
!SchemaController.testPermissions(classLevelPermissions, aclGroup, op)
695+
) {
696+
if (!userId) {
697+
throw new Parse.Error(
698+
Parse.Error.OPERATION_FORBIDDEN,
699+
'Permission denied for this action.'
700+
);
701+
}
702+
// Check if any pointer field points to the current user
703+
const hasAccess = pointerFields.some(field => {
704+
const value =
705+
typeof object.get === 'function' ? object.get(field) : object[field];
706+
if (!value) {
707+
return false;
708+
}
709+
// Handle Parse.Object pointer (has .id)
710+
if (value.id) {
711+
return value.id === userId;
712+
}
713+
// Handle raw pointer JSON (has .objectId)
714+
if (value.objectId) {
715+
return value.objectId === userId;
716+
}
717+
// Handle array of pointers
718+
if (Array.isArray(value)) {
719+
return value.some(item => {
720+
if (item.id) {
721+
return item.id === userId;
722+
}
723+
if (item.objectId) {
724+
return item.objectId === userId;
725+
}
726+
return false;
727+
});
728+
}
729+
return false;
730+
});
731+
if (!hasAccess) {
732+
throw new Parse.Error(
733+
Parse.Error.OPERATION_FORBIDDEN,
734+
'Permission denied for this action.'
735+
);
736+
}
737+
}
738+
}
739+
}
674740
}
675741

676742
async _filterSensitiveData(

0 commit comments

Comments
 (0)