mirror of
https://github.com/parse-community/parse-server
synced 2026-08-09 13:03:18 +00:00
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:
@@ -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;
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user