fix: Parse Server role escalation and CLP bypass via direct `_Join table write ([GHSA-5f92-jrq3-28rc](https://github.com/parse-community/parse-server/security/advisories/GHSA-5f92-jrq3-28rc)) (#10142)

This commit is contained in:
Manuel
2026-03-08 16:26:52 +00:00
committed by GitHub
parent efb6cfefc3
commit 1c58ef7878
3 changed files with 123 additions and 0 deletions
+3
View File
@@ -247,6 +247,9 @@ global.afterEachFn = async () => {
if (!className.startsWith('_')) {
return true;
}
if (className.startsWith('_Join:')) {
return true;
}
return [
'_User',
'_Installation',
+111
View File
@@ -953,6 +953,117 @@ describe('rest update', () => {
});
});
describe('_Join table security', () => {
let config;
beforeEach(() => {
config = Config.get('test');
});
it('cannot create object in _Join table without masterKey', () => {
expect(() =>
rest.create(config, auth.nobody(config), '_Join:users:_Role', {
relatedId: 'someUserId',
owningId: 'someRoleId',
})
).toThrowError(/Permission denied/);
});
it('cannot find objects in _Join table without masterKey', async () => {
await expectAsync(
rest.find(config, auth.nobody(config), '_Join:users:_Role', {})
).toBeRejectedWith(
jasmine.objectContaining({
code: Parse.Error.OPERATION_FORBIDDEN,
})
);
});
it('cannot update object in _Join table without masterKey', () => {
expect(() =>
rest.update(config, auth.nobody(config), '_Join:users:_Role', { relatedId: 'someUserId' }, { owningId: 'newRoleId' })
).toThrowError(/Permission denied/);
});
it('cannot delete object in _Join table without masterKey', () => {
expect(() =>
rest.del(config, auth.nobody(config), '_Join:users:_Role', 'someObjectId')
).toThrowError(/Permission denied/);
});
it('cannot get object in _Join table without masterKey', async () => {
await expectAsync(
rest.get(config, auth.nobody(config), '_Join:users:_Role', 'someObjectId')
).toBeRejectedWith(
jasmine.objectContaining({
code: Parse.Error.OPERATION_FORBIDDEN,
})
);
});
it('can find objects in _Join table with masterKey', async () => {
await expectAsync(
rest.find(config, auth.master(config), '_Join:users:_Role', {})
).toBeResolved();
});
it('can find objects in _Join table with maintenance key', async () => {
await expectAsync(
rest.find(config, auth.maintenance(config), '_Join:users:_Role', {})
).toBeResolved();
});
it('legitimate relation operations still work', async () => {
const role = new Parse.Role('admin', new Parse.ACL());
const user = await Parse.User.signUp('testuser', 'password123');
role.getUsers().add(user);
await role.save(null, { useMasterKey: true });
const result = await rest.find(config, auth.master(config), '_Join:users:_Role', {});
expect(result.results.length).toBe(1);
});
it('blocks _Join table access for any relation, not just _Role', () => {
expect(() =>
rest.create(config, auth.nobody(config), '_Join:viewers:ConfidentialDoc', {
relatedId: 'someUserId',
owningId: 'someDocId',
})
).toThrowError(/Permission denied/);
});
it('cannot escalate role via direct _Join table write', async () => {
const role = new Parse.Role('superadmin', new Parse.ACL());
await role.save(null, { useMasterKey: true });
const user = await Parse.User.signUp('attacker', 'password123');
const sessionToken = user.getSessionToken();
const userAuth = await auth.getAuthForSessionToken({
config,
sessionToken,
});
expect(() =>
rest.create(config, userAuth, '_Join:users:_Role', {
relatedId: user.id,
owningId: role.id,
})
).toThrowError(/Permission denied/);
});
it('cannot write to _Join table with read-only masterKey', () => {
expect(() =>
rest.create(config, auth.readOnly(config), '_Join:users:_Role', {
relatedId: 'someUserId',
owningId: 'someRoleId',
})
).toThrowError(/Permission denied/);
});
it('can read _Join table with read-only masterKey', async () => {
await expectAsync(
rest.find(config, auth.readOnly(config), '_Join:users:_Role', {})
).toBeResolved();
});
});
describe('read-only masterKey', () => {
let loggerErrorSpy;
let logger;
+9
View File
@@ -33,6 +33,15 @@ function enforceRoleSecurity(method, className, auth, config) {
);
}
// _Join tables are internal and must only be modified through relation operations
if (className.startsWith('_Join:') && !auth.isMaster && !auth.isMaintenance) {
throw createSanitizedError(
Parse.Error.OPERATION_FORBIDDEN,
`Clients aren't allowed to perform the ${method} operation on the ${className} collection.`,
config
);
}
// readOnly masterKey is not allowed
if (auth.isReadOnly && (method === 'delete' || method === 'create' || method === 'update')) {
throw createSanitizedError(