fix: Protected fields bypass via LiveQuery subscription WHERE clause ([GHSA-j7mm-f4rv-6q6q](https://github.com/parse-community/parse-server/security/advisories/GHSA-j7mm-f4rv-6q6q)) (#10175)

This commit is contained in:
Manuel
2026-03-10 20:53:25 +00:00
committed by GitHub
parent 0744225caf
commit 4d48847e99
2 changed files with 242 additions and 0 deletions
+207
View File
@@ -1706,6 +1706,213 @@ describe('(GHSA-r2m8-pxm9-9c4g) Protected fields WHERE clause bypass via dot-not
});
});
describe('(GHSA-j7mm-f4rv-6q6q) Protected fields bypass via LiveQuery dot-notation WHERE', () => {
let obj;
beforeEach(async () => {
Parse.CoreManager.getLiveQueryController().setDefaultLiveQueryClient(null);
await reconfigureServer({
liveQuery: { classNames: ['SecretClass'] },
startLiveQueryServer: true,
verbose: false,
silent: true,
});
const config = Config.get(Parse.applicationId);
const schemaController = await config.database.loadSchema();
await schemaController.addClassIfNotExists(
'SecretClass',
{ secretObj: { type: 'Object' }, publicField: { type: 'String' } },
);
await schemaController.updateClass(
'SecretClass',
{},
{
find: { '*': true },
get: { '*': true },
create: { '*': true },
update: { '*': true },
delete: { '*': true },
addField: {},
protectedFields: { '*': ['secretObj'] },
}
);
obj = new Parse.Object('SecretClass');
obj.set('secretObj', { apiKey: 'SENSITIVE_KEY_123', score: 42 });
obj.set('publicField', 'visible');
await obj.save(null, { useMasterKey: true });
});
afterEach(async () => {
const client = await Parse.CoreManager.getLiveQueryController().getDefaultLiveQueryClient();
if (client) {
await client.close();
}
});
it('should reject LiveQuery subscription with dot-notation on protected field in where clause', async () => {
const query = new Parse.Query('SecretClass');
query._addCondition('secretObj.apiKey', '$eq', 'SENSITIVE_KEY_123');
await expectAsync(query.subscribe()).toBeRejectedWith(
new Parse.Error(Parse.Error.OPERATION_FORBIDDEN, 'Permission denied')
);
});
it('should reject LiveQuery subscription with protected field directly in where clause', async () => {
const query = new Parse.Query('SecretClass');
query.exists('secretObj');
await expectAsync(query.subscribe()).toBeRejectedWith(
new Parse.Error(Parse.Error.OPERATION_FORBIDDEN, 'Permission denied')
);
});
it('should reject LiveQuery subscription with protected field in $or', async () => {
const q1 = new Parse.Query('SecretClass');
q1._addCondition('secretObj.apiKey', '$eq', 'SENSITIVE_KEY_123');
const q2 = new Parse.Query('SecretClass');
q2._addCondition('secretObj.apiKey', '$eq', 'other');
const query = Parse.Query.or(q1, q2);
await expectAsync(query.subscribe()).toBeRejectedWith(
new Parse.Error(Parse.Error.OPERATION_FORBIDDEN, 'Permission denied')
);
});
it('should reject LiveQuery subscription with protected field in $and', async () => {
// Build $and manually since Parse SDK doesn't expose it directly
const query = new Parse.Query('SecretClass');
query._where = { $and: [{ 'secretObj.apiKey': 'SENSITIVE_KEY_123' }, { publicField: 'visible' }] };
await expectAsync(query.subscribe()).toBeRejectedWith(
new Parse.Error(Parse.Error.OPERATION_FORBIDDEN, 'Permission denied')
);
});
it('should reject LiveQuery subscription with protected field in $nor', async () => {
// Build $nor manually since Parse SDK doesn't expose it directly
const query = new Parse.Query('SecretClass');
query._where = { $nor: [{ 'secretObj.apiKey': 'SENSITIVE_KEY_123' }] };
await expectAsync(query.subscribe()).toBeRejectedWith(
new Parse.Error(Parse.Error.OPERATION_FORBIDDEN, 'Permission denied')
);
});
it('should reject LiveQuery subscription with $regex on protected field (boolean oracle)', async () => {
const query = new Parse.Query('SecretClass');
query._addCondition('secretObj.apiKey', '$regex', '^S');
await expectAsync(query.subscribe()).toBeRejectedWith(
new Parse.Error(Parse.Error.OPERATION_FORBIDDEN, 'Permission denied')
);
});
it('should reject LiveQuery subscription with deeply nested dot-notation on protected field', async () => {
const query = new Parse.Query('SecretClass');
query._addCondition('secretObj.nested.deep.key', '$eq', 'value');
await expectAsync(query.subscribe()).toBeRejectedWith(
new Parse.Error(Parse.Error.OPERATION_FORBIDDEN, 'Permission denied')
);
});
it('should allow LiveQuery subscription on non-protected fields and strip protected fields from response', async () => {
const query = new Parse.Query('SecretClass');
query.exists('publicField');
const subscription = await query.subscribe();
await Promise.all([
new Promise(resolve => {
subscription.on('update', object => {
expect(object.get('secretObj')).toBeUndefined();
expect(object.get('publicField')).toBe('updated');
resolve();
});
}),
obj.save({ publicField: 'updated' }, { useMasterKey: true }),
]);
});
it('should reject admin user querying protected field when both * and role protect it', async () => {
// Common case: protectedFields has both '*' and 'role:admin' entries.
// Even without resolving user roles, the '*' protection applies and blocks the query.
// This validates that role-based exemptions are irrelevant when '*' covers the field.
const config = Config.get(Parse.applicationId);
const schemaController = await config.database.loadSchema();
await schemaController.updateClass(
'SecretClass',
{},
{
find: { '*': true },
get: { '*': true },
create: { '*': true },
update: { '*': true },
delete: { '*': true },
addField: {},
protectedFields: { '*': ['secretObj'], 'role:admin': ['secretObj'] },
}
);
const user = new Parse.User();
user.setUsername('adminuser');
user.setPassword('password');
await user.signUp();
const roleACL = new Parse.ACL();
roleACL.setPublicReadAccess(true);
const role = new Parse.Role('admin', roleACL);
role.getUsers().add(user);
await role.save(null, { useMasterKey: true });
const query = new Parse.Query('SecretClass');
query._addCondition('secretObj.apiKey', '$eq', 'SENSITIVE_KEY_123');
await expectAsync(query.subscribe(user.getSessionToken())).toBeRejectedWith(
new Parse.Error(Parse.Error.OPERATION_FORBIDDEN, 'Permission denied')
);
});
it('should not reject when role-only protection exists without * entry', async () => {
// Edge case: protectedFields only has a role entry, no '*'.
// Without resolving roles, the protection set is empty, so the subscription is allowed.
// This is a correctness gap, not a security issue: the role entry means "protect this
// field FROM role members" (i.e. admins should not see it). Not resolving roles means
// the admin loses their own restriction — they see data meant to be hidden from them.
// This does not allow unprivileged users to access protected data.
const config = Config.get(Parse.applicationId);
const schemaController = await config.database.loadSchema();
await schemaController.updateClass(
'SecretClass',
{},
{
find: { '*': true },
get: { '*': true },
create: { '*': true },
update: { '*': true },
delete: { '*': true },
addField: {},
protectedFields: { 'role:admin': ['secretObj'] },
}
);
const user = new Parse.User();
user.setUsername('adminuser2');
user.setPassword('password');
await user.signUp();
const roleACL = new Parse.ACL();
roleACL.setPublicReadAccess(true);
const role = new Parse.Role('admin', roleACL);
role.getUsers().add(user);
await role.save(null, { useMasterKey: true });
// This subscribes successfully because without '*' entry, no fields are protected
// for purposes of WHERE clause validation. The role-only config means "hide secretObj
// from admins" — a restriction ON the privileged user, not a security boundary.
const query = new Parse.Query('SecretClass');
query._addCondition('secretObj.apiKey', '$eq', 'SENSITIVE_KEY_123');
const subscription = await query.subscribe(user.getSessionToken());
expect(subscription).toBeDefined();
});
// Note: master key bypass is inherently tested by the `!client.hasMasterKey` guard
// in the implementation. Testing master key LiveQuery requires configuring keyPairs
// in the LiveQuery server config, which is not part of the default test setup.
});
describe('(GHSA-w54v-hf9p-8856) User enumeration via email verification endpoint', () => {
let sendVerificationEmail;
+35
View File
@@ -916,6 +916,41 @@ class ParseLiveQueryServer {
op
);
// Check protected fields in WHERE clause
if (!client.hasMasterKey) {
const auth = request.user ? { user: request.user, userRoles: [] } : {};
const protectedFields =
appConfig.database.addProtectedFields(
classLevelPermissions,
className,
request.query.where,
aclGroup,
auth
) || [];
if (protectedFields.length > 0 && request.query.where) {
const checkWhere = (where: any) => {
if (typeof where !== 'object' || where === null) {
return;
}
for (const whereKey of Object.keys(where)) {
const rootField = whereKey.split('.')[0];
if (protectedFields.includes(whereKey) || protectedFields.includes(rootField)) {
throw new Parse.Error(
Parse.Error.OPERATION_FORBIDDEN,
'Permission denied'
);
}
}
for (const op of ['$or', '$and', '$nor']) {
if (Array.isArray(where[op])) {
where[op].forEach((subQuery: any) => checkWhere(subQuery));
}
}
};
checkWhere(request.query.where);
}
}
// Get subscription from subscriptions, create one if necessary
const subscriptionHash = queryHash(request.query);
// Add className to subscriptions if necessary