From 428f6f57035f62644cbe42130e2b4adaea818254 Mon Sep 17 00:00:00 2001 From: Manuel Trezza <5673677+mtrezza@users.noreply.github.com> Date: Fri, 20 Mar 2026 01:42:19 +0000 Subject: [PATCH] fix: LiveQuery bypasses CLP pointer permission enforcement (GHSA-fph2-r4qg-9576) --- spec/vulnerabilities.spec.js | 301 ++++++++++++++++++++++++++ src/LiveQuery/ParseLiveQueryServer.ts | 74 ++++++- 2 files changed, 372 insertions(+), 3 deletions(-) diff --git a/spec/vulnerabilities.spec.js b/spec/vulnerabilities.spec.js index f15fd0bb1d..a6a25d53d4 100644 --- a/spec/vulnerabilities.spec.js +++ b/spec/vulnerabilities.spec.js @@ -3115,3 +3115,304 @@ describe('(GHSA-5hmj-jcgp-6hff) Protected fields leak via LiveQuery afterEvent t }); }); }); + +describe('(GHSA-fph2-r4qg-9576) LiveQuery bypasses CLP pointer permission enforcement', () => { + const { sleep } = require('../lib/TestUtils'); + + beforeEach(() => { + Parse.CoreManager.getLiveQueryController().setDefaultLiveQueryClient(null); + }); + + afterEach(async () => { + try { + const client = await Parse.CoreManager.getLiveQueryController().getDefaultLiveQueryClient(); + if (client) { + await client.close(); + } + } catch (e) { + // Ignore cleanup errors when client is not initialized + } + }); + + async function updateCLP(className, permissions) { + const response = await fetch(Parse.serverURL + '/schemas/' + className, { + method: 'PUT', + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-Master-Key': Parse.masterKey, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ classLevelPermissions: permissions }), + }); + const body = await response.json(); + if (body.error) { + throw body; + } + return body; + } + + it('should not deliver LiveQuery events to user not in readUserFields pointer', async () => { + await reconfigureServer({ + liveQuery: { classNames: ['PrivateMessage'] }, + startLiveQueryServer: true, + verbose: false, + silent: true, + }); + + // Create users using master key to avoid session management issues + const userA = new Parse.User(); + userA.setUsername('userA_pointer'); + userA.setPassword('password123'); + await userA.signUp(); + await Parse.User.logOut(); + + // User B stays logged in for the subscription + const userB = new Parse.User(); + userB.setUsername('userB_pointer'); + userB.setPassword('password456'); + await userB.signUp(); + + // Create schema by saving an object with owner pointer, then set CLP + const seed = new Parse.Object('PrivateMessage'); + seed.set('owner', userA); + await seed.save(null, { useMasterKey: true }); + await seed.destroy({ useMasterKey: true }); + + await updateCLP('PrivateMessage', { + create: { '*': true }, + find: {}, + get: {}, + readUserFields: ['owner'], + }); + + // User B subscribes — should NOT receive events for User A's objects + const query = new Parse.Query('PrivateMessage'); + const subscription = await query.subscribe(userB.getSessionToken()); + + const createSpy = jasmine.createSpy('create'); + const enterSpy = jasmine.createSpy('enter'); + subscription.on('create', createSpy); + subscription.on('enter', enterSpy); + + // Create a message owned by User A + const msg = new Parse.Object('PrivateMessage'); + msg.set('content', 'secret message'); + msg.set('owner', userA); + await msg.save(null, { useMasterKey: true }); + + await sleep(500); + + // User B should NOT have received the create event + expect(createSpy).not.toHaveBeenCalled(); + expect(enterSpy).not.toHaveBeenCalled(); + }); + + it('should deliver LiveQuery events to user in readUserFields pointer', async () => { + await reconfigureServer({ + liveQuery: { classNames: ['PrivateMessage2'] }, + startLiveQueryServer: true, + verbose: false, + silent: true, + }); + + // User A stays logged in for the subscription + const userA = new Parse.User(); + userA.setUsername('userA_owner'); + userA.setPassword('password123'); + await userA.signUp(); + + // Create schema by saving an object with owner pointer + const seed = new Parse.Object('PrivateMessage2'); + seed.set('owner', userA); + await seed.save(null, { useMasterKey: true }); + await seed.destroy({ useMasterKey: true }); + + await updateCLP('PrivateMessage2', { + create: { '*': true }, + find: {}, + get: {}, + readUserFields: ['owner'], + }); + + // User A subscribes — SHOULD receive events for their own objects + const query = new Parse.Query('PrivateMessage2'); + const subscription = await query.subscribe(userA.getSessionToken()); + + const createSpy = jasmine.createSpy('create'); + subscription.on('create', createSpy); + + // Create a message owned by User A + const msg = new Parse.Object('PrivateMessage2'); + msg.set('content', 'my own message'); + msg.set('owner', userA); + await msg.save(null, { useMasterKey: true }); + + await sleep(500); + + // User A SHOULD have received the create event + expect(createSpy).toHaveBeenCalledTimes(1); + }); + + it('should not deliver LiveQuery events when find uses pointerFields', async () => { + await reconfigureServer({ + liveQuery: { classNames: ['PrivateDoc'] }, + startLiveQueryServer: true, + verbose: false, + silent: true, + }); + + const userA = new Parse.User(); + userA.setUsername('userA_doc'); + userA.setPassword('password123'); + await userA.signUp(); + await Parse.User.logOut(); + + // User B stays logged in for the subscription + const userB = new Parse.User(); + userB.setUsername('userB_doc'); + userB.setPassword('password456'); + await userB.signUp(); + + // Create schema by saving an object with recipient pointer + const seed = new Parse.Object('PrivateDoc'); + seed.set('recipient', userA); + await seed.save(null, { useMasterKey: true }); + await seed.destroy({ useMasterKey: true }); + + // Set CLP with pointerFields instead of readUserFields + await updateCLP('PrivateDoc', { + create: { '*': true }, + find: { pointerFields: ['recipient'] }, + get: { pointerFields: ['recipient'] }, + }); + + // User B subscribes + const query = new Parse.Query('PrivateDoc'); + const subscription = await query.subscribe(userB.getSessionToken()); + + const createSpy = jasmine.createSpy('create'); + subscription.on('create', createSpy); + + // Create doc with recipient = User A (not User B) + const doc = new Parse.Object('PrivateDoc'); + doc.set('title', 'confidential'); + doc.set('recipient', userA); + await doc.save(null, { useMasterKey: true }); + + await sleep(500); + + // User B should NOT receive events for User A's document + expect(createSpy).not.toHaveBeenCalled(); + }); + + it('should not deliver LiveQuery events to unauthenticated users for pointer-protected classes', async () => { + await reconfigureServer({ + liveQuery: { classNames: ['SecureItem'] }, + startLiveQueryServer: true, + verbose: false, + silent: true, + }); + + const userA = new Parse.User(); + userA.setUsername('userA_secure'); + userA.setPassword('password123'); + await userA.signUp(); + await Parse.User.logOut(); + + // Create schema + const seed = new Parse.Object('SecureItem'); + seed.set('owner', userA); + await seed.save(null, { useMasterKey: true }); + await seed.destroy({ useMasterKey: true }); + + await updateCLP('SecureItem', { + create: { '*': true }, + find: {}, + get: {}, + readUserFields: ['owner'], + }); + + // Unauthenticated subscription + const query = new Parse.Query('SecureItem'); + const subscription = await query.subscribe(); + + const createSpy = jasmine.createSpy('create'); + subscription.on('create', createSpy); + + const item = new Parse.Object('SecureItem'); + item.set('data', 'private'); + item.set('owner', userA); + await item.save(null, { useMasterKey: true }); + + await sleep(500); + + expect(createSpy).not.toHaveBeenCalled(); + }); + + it('should handle readUserFields with array of pointers', async () => { + await reconfigureServer({ + liveQuery: { classNames: ['SharedDoc'] }, + startLiveQueryServer: true, + verbose: false, + silent: true, + }); + + const userA = new Parse.User(); + userA.setUsername('userA_shared'); + userA.setPassword('password123'); + await userA.signUp(); + await Parse.User.logOut(); + + // User B — don't log out, session must remain valid + const userB = new Parse.User(); + userB.setUsername('userB_shared'); + userB.setPassword('password456'); + await userB.signUp(); + const userBSessionToken = userB.getSessionToken(); + + // User C — signUp changes current user to C, but B's session stays valid + const userC = new Parse.User(); + userC.setUsername('userC_shared'); + userC.setPassword('password789'); + await userC.signUp(); + const userCSessionToken = userC.getSessionToken(); + + // Create schema with array field + const seed = new Parse.Object('SharedDoc'); + seed.set('collaborators', [userA]); + await seed.save(null, { useMasterKey: true }); + await seed.destroy({ useMasterKey: true }); + + await updateCLP('SharedDoc', { + create: { '*': true }, + find: {}, + get: {}, + readUserFields: ['collaborators'], + }); + + // User B subscribes — is in the collaborators array + const queryB = new Parse.Query('SharedDoc'); + const subscriptionB = await queryB.subscribe(userBSessionToken); + const createSpyB = jasmine.createSpy('createB'); + subscriptionB.on('create', createSpyB); + + // User C subscribes — is NOT in the collaborators array + const queryC = new Parse.Query('SharedDoc'); + const subscriptionC = await queryC.subscribe(userCSessionToken); + const createSpyC = jasmine.createSpy('createC'); + subscriptionC.on('create', createSpyC); + + // Create doc with collaborators = [userA, userB] (not userC) + const doc = new Parse.Object('SharedDoc'); + doc.set('title', 'team doc'); + doc.set('collaborators', [userA, userB]); + await doc.save(null, { useMasterKey: true }); + + await sleep(500); + + // User B SHOULD receive the event (in collaborators array) + expect(createSpyB).toHaveBeenCalledTimes(1); + // User C should NOT receive the event + expect(createSpyC).not.toHaveBeenCalled(); + }); +}); diff --git a/src/LiveQuery/ParseLiveQueryServer.ts b/src/LiveQuery/ParseLiveQueryServer.ts index 31a02e85c0..baa3a138fc 100644 --- a/src/LiveQuery/ParseLiveQueryServer.ts +++ b/src/LiveQuery/ParseLiveQueryServer.ts @@ -211,13 +211,16 @@ class ParseLiveQueryServer { const op = this._getCLPOperation(subscription.query); let res: any = {}; try { - await this._matchesCLP( + const matchesCLP = await this._matchesCLP( classLevelPermissions, message.currentParseObject, client, requestId, op ); + if (matchesCLP === false) { + return null; + } const isMatched = await this._matchesACL(acl, client, requestId); if (!isMatched) { return null; @@ -339,13 +342,16 @@ class ParseLiveQueryServer { } try { const op = this._getCLPOperation(subscription.query); - await this._matchesCLP( + const matchesCLP = await this._matchesCLP( classLevelPermissions, message.currentParseObject, client, requestId, op ); + if (matchesCLP === false) { + return; + } const [isOriginalMatched, isCurrentMatched] = await Promise.all([ originalACLCheckingPromise, currentACLCheckingPromise, @@ -659,8 +665,10 @@ class ParseLiveQueryServer { ): Promise { const subscriptionInfo = client.getSubscriptionInfo(requestId); const aclGroup = ['*']; + let userId; if (typeof subscriptionInfo !== 'undefined') { - const { userId } = await this.getAuthForSessionToken(subscriptionInfo.sessionToken); + const result = await this.getAuthForSessionToken(subscriptionInfo.sessionToken); + userId = result.userId; if (userId) { aclGroup.push(userId); } @@ -671,6 +679,66 @@ class ParseLiveQueryServer { aclGroup, op ); + // Enforce pointer permissions that validatePermission defers. + // Returns false to silently skip the event (like ACL), rather than + // throwing which would push errors to the client and log noise. + if (!client.hasMasterKey && classLevelPermissions) { + const permissionField = + ['get', 'find', 'count'].indexOf(op) > -1 ? 'readUserFields' : 'writeUserFields'; + const pointerFields = []; + if (classLevelPermissions[op]?.pointerFields) { + pointerFields.push(...classLevelPermissions[op].pointerFields); + } + if (Array.isArray(classLevelPermissions[permissionField])) { + for (const field of classLevelPermissions[permissionField]) { + if (!pointerFields.includes(field)) { + pointerFields.push(field); + } + } + } + if (pointerFields.length > 0) { + // If public or user-specific permission already grants access, skip pointer check + if ( + !SchemaController.testPermissions(classLevelPermissions, aclGroup, op) + ) { + if (!userId) { + return false; + } + // Check if any pointer field points to the current user + const hasAccess = pointerFields.some(field => { + const value = + typeof object.get === 'function' ? object.get(field) : object[field]; + if (!value) { + return false; + } + // Handle Parse.Object pointer (has .id) + if (value.id) { + return value.id === userId; + } + // Handle raw pointer JSON (has .objectId) + if (value.objectId) { + return value.objectId === userId; + } + // Handle array of pointers + if (Array.isArray(value)) { + return value.some(item => { + if (item.id) { + return item.id === userId; + } + if (item.objectId) { + return item.objectId === userId; + } + return false; + }); + } + return false; + }); + if (!hasAccess) { + return false; + } + } + } + } } async _filterSensitiveData(