fix: Bypass of class-level permissions in LiveQuery ([GHSA-7ch5-98q2-7289](https://github.com/parse-community/parse-server/security/advisories/GHSA-7ch5-98q2-7289)) (#10134)

This commit is contained in:
Manuel
2026-03-08 01:15:17 +00:00
committed by GitHub
parent 0aeedb29a2
commit 6fecec52c5
3 changed files with 133 additions and 64 deletions
+84
View File
@@ -1308,4 +1308,88 @@ describe('ParseLiveQuery', function () {
await new Promise(resolve => setTimeout(resolve, 100));
expect(createSpy).toHaveBeenCalledTimes(1);
});
describe('class level permissions', () => {
async function setPermissionsOnClass(className, permissions, doPut) {
const method = doPut ? 'PUT' : 'POST';
const response = await fetch(Parse.serverURL + '/schemas/' + className, {
method,
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('delivers LiveQuery event to authenticated client when CLP allows find', async () => {
await reconfigureServer({
liveQuery: {
classNames: ['SecureChat'],
},
startLiveQueryServer: true,
verbose: false,
silent: true,
});
const user = new Parse.User();
user.setUsername('admin');
user.setPassword('password');
await user.signUp();
await setPermissionsOnClass('SecureChat', {
create: { '*': true },
find: { [user.id]: true },
});
// Subscribe as the authorized user
const query = new Parse.Query('SecureChat');
const subscription = await query.subscribe(user.getSessionToken());
const spy = jasmine.createSpy('create');
subscription.on('create', spy);
const obj = new Parse.Object('SecureChat');
obj.set('secret', 'data');
await obj.save(null, { useMasterKey: true });
await sleep(500);
expect(spy).toHaveBeenCalledTimes(1);
});
it('rejects LiveQuery subscription when CLP denies find at subscription time', async () => {
await reconfigureServer({
liveQuery: {
classNames: ['SecureChat'],
},
startLiveQueryServer: true,
verbose: false,
silent: true,
});
const user = new Parse.User();
user.setUsername('admin');
user.setPassword('password');
await user.signUp();
await setPermissionsOnClass('SecureChat', {
create: { '*': true },
find: { [user.id]: true },
});
// Log out so subscription is unauthenticated
await Parse.User.logOut();
const query = new Parse.Query('SecureChat');
await expectAsync(query.subscribe()).toBeRejected();
});
});
});
+15 -39
View File
@@ -1575,11 +1575,8 @@ describe('ParseLiveQueryServer', function () {
});
describe('class level permissions', () => {
it('matches CLP when find is closed', done => {
it('rejects CLP when find is closed', async () => {
const parseLiveQueryServer = new ParseLiveQueryServer({});
const acl = new Parse.ACL();
acl.setReadAccess(testUserId, true);
// Mock sessionTokenCache will return false when sessionToken is undefined
const client = {
sessionToken: 'sessionToken',
getSubscriptionInfo: jasmine.createSpy('getSubscriptionInfo').and.returnValue({
@@ -1588,27 +1585,19 @@ describe('ParseLiveQueryServer', function () {
};
const requestId = 0;
parseLiveQueryServer
._matchesCLP(
{
find: {},
},
await expectAsync(
parseLiveQueryServer._matchesCLP(
{ find: {} },
{ className: 'Yolo' },
client,
requestId,
'find'
)
.then(isMatched => {
expect(isMatched).toBe(false);
done();
});
).toBeRejected();
});
it('matches CLP when find is open', done => {
it('resolves CLP when find is open', async () => {
const parseLiveQueryServer = new ParseLiveQueryServer({});
const acl = new Parse.ACL();
acl.setReadAccess(testUserId, true);
// Mock sessionTokenCache will return false when sessionToken is undefined
const client = {
sessionToken: 'sessionToken',
getSubscriptionInfo: jasmine.createSpy('getSubscriptionInfo').and.returnValue({
@@ -1617,27 +1606,19 @@ describe('ParseLiveQueryServer', function () {
};
const requestId = 0;
parseLiveQueryServer
._matchesCLP(
{
find: { '*': true },
},
await expectAsync(
parseLiveQueryServer._matchesCLP(
{ find: { '*': true } },
{ className: 'Yolo' },
client,
requestId,
'find'
)
.then(isMatched => {
expect(isMatched).toBe(true);
done();
});
).toBeResolved();
});
it('matches CLP when find is restricted to userIds', done => {
it('rejects CLP when find is restricted to userIds', async () => {
const parseLiveQueryServer = new ParseLiveQueryServer({});
const acl = new Parse.ACL();
acl.setReadAccess(testUserId, true);
// Mock sessionTokenCache will return false when sessionToken is undefined
const client = {
sessionToken: 'sessionToken',
getSubscriptionInfo: jasmine.createSpy('getSubscriptionInfo').and.returnValue({
@@ -1646,20 +1627,15 @@ describe('ParseLiveQueryServer', function () {
};
const requestId = 0;
parseLiveQueryServer
._matchesCLP(
{
find: { userId: true },
},
await expectAsync(
parseLiveQueryServer._matchesCLP(
{ find: { userId: true } },
{ className: 'Yolo' },
client,
requestId,
'find'
)
.then(isMatched => {
expect(isMatched).toBe(false);
done();
});
).toBeRejected();
});
});
+34 -25
View File
@@ -20,6 +20,7 @@ import {
} from '../triggers';
import { getAuthForSessionToken, Auth } from '../Auth';
import { getCacheController, getDatabaseController } from '../Controllers';
import Config from '../Config';
import { LRUCache as LRU } from 'lru-cache';
import UserRouter from '../Routers/UsersRouter';
import DatabaseController from '../Controllers/DatabaseController';
@@ -590,39 +591,20 @@ class ParseLiveQueryServer {
requestId?: number,
op?: string
): Promise<any> {
// try to match on user first, less expensive than with roles
const subscriptionInfo = client.getSubscriptionInfo(requestId);
const aclGroup = ['*'];
let userId;
if (typeof subscriptionInfo !== 'undefined') {
const { userId } = await this.getAuthForSessionToken(subscriptionInfo.sessionToken);
if (userId) {
aclGroup.push(userId);
}
}
try {
await SchemaController.validatePermission(
classLevelPermissions,
object.className,
aclGroup,
op
);
return true;
} catch (e) {
logger.verbose(`Failed matching CLP for ${object.id} ${userId} ${e}`);
return false;
}
// TODO: handle roles permissions
// Object.keys(classLevelPermissions).forEach((key) => {
// const perm = classLevelPermissions[key];
// Object.keys(perm).forEach((key) => {
// if (key.indexOf('role'))
// });
// })
// // it's rejected here, check the roles
// var rolesQuery = new Parse.Query(Parse.Role);
// rolesQuery.equalTo("users", user);
// return rolesQuery.find({useMasterKey:true});
await SchemaController.validatePermission(
classLevelPermissions,
object.className,
aclGroup,
op
);
}
async _filterSensitiveData(
@@ -907,6 +889,33 @@ class ParseLiveQueryServer {
return;
}
}
// Check CLP for subscribe operation
const appConfig = Config.get(this.config.appId);
const schemaController = await appConfig.database.loadSchema();
const classLevelPermissions = schemaController.getClassLevelPermissions(className);
const op = this._getCLPOperation(request.query);
const aclGroup = ['*'];
if (!authCalled) {
const auth = await this.getAuthFromClient(
client,
request.requestId,
request.sessionToken
);
authCalled = true;
if (auth && auth.user) {
request.user = auth.user;
aclGroup.push(auth.user.id);
}
} else if (request.user) {
aclGroup.push(request.user.id);
}
await SchemaController.validatePermission(
classLevelPermissions,
className,
aclGroup,
op
);
// Get subscription from subscriptions, create one if necessary
const subscriptionHash = queryHash(request.query);
// Add className to subscriptions if necessary