Skip to content

Commit 0c0a0a5

Browse files
authored
fix: Protected field change detection oracle via LiveQuery watch parameter ([GHSA-qpc3-fg4j-8hgm](GHSA-qpc3-fg4j-8hgm)) (#10253)
1 parent 5fa7543 commit 0c0a0a5

File tree

2 files changed

+113
-1
lines changed

2 files changed

+113
-1
lines changed

spec/vulnerabilities.spec.js

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3650,3 +3650,104 @@ describe('(GHSA-fph2-r4qg-9576) LiveQuery bypasses CLP pointer permission enforc
36503650
expect(createSpyC).not.toHaveBeenCalled();
36513651
});
36523652
});
3653+
3654+
describe('(GHSA-qpc3-fg4j-8hgm) Protected field change detection oracle via LiveQuery watch parameter', () => {
3655+
const { sleep } = require('../lib/TestUtils');
3656+
let obj;
3657+
3658+
beforeEach(async () => {
3659+
Parse.CoreManager.getLiveQueryController().setDefaultLiveQueryClient(null);
3660+
await reconfigureServer({
3661+
liveQuery: { classNames: ['SecretClass'] },
3662+
startLiveQueryServer: true,
3663+
verbose: false,
3664+
silent: true,
3665+
});
3666+
const config = Config.get(Parse.applicationId);
3667+
const schemaController = await config.database.loadSchema();
3668+
await schemaController.addClassIfNotExists('SecretClass', {
3669+
secretObj: { type: 'Object' },
3670+
publicField: { type: 'String' },
3671+
});
3672+
await schemaController.updateClass(
3673+
'SecretClass',
3674+
{},
3675+
{
3676+
find: { '*': true },
3677+
get: { '*': true },
3678+
create: { '*': true },
3679+
update: { '*': true },
3680+
delete: { '*': true },
3681+
addField: {},
3682+
protectedFields: { '*': ['secretObj'] },
3683+
}
3684+
);
3685+
3686+
obj = new Parse.Object('SecretClass');
3687+
obj.set('secretObj', { apiKey: 'SENSITIVE_KEY_123', score: 42 });
3688+
obj.set('publicField', 'visible');
3689+
await obj.save(null, { useMasterKey: true });
3690+
});
3691+
3692+
afterEach(async () => {
3693+
const client = await Parse.CoreManager.getLiveQueryController().getDefaultLiveQueryClient();
3694+
if (client) {
3695+
await client.close();
3696+
}
3697+
});
3698+
3699+
it('should reject LiveQuery subscription with protected field in watch', async () => {
3700+
const query = new Parse.Query('SecretClass');
3701+
query.watch('secretObj');
3702+
await expectAsync(query.subscribe()).toBeRejectedWith(
3703+
new Parse.Error(Parse.Error.OPERATION_FORBIDDEN, 'Permission denied')
3704+
);
3705+
});
3706+
3707+
it('should reject LiveQuery subscription with dot-notation on protected field in watch', async () => {
3708+
const query = new Parse.Query('SecretClass');
3709+
query.watch('secretObj.apiKey');
3710+
await expectAsync(query.subscribe()).toBeRejectedWith(
3711+
new Parse.Error(Parse.Error.OPERATION_FORBIDDEN, 'Permission denied')
3712+
);
3713+
});
3714+
3715+
it('should reject LiveQuery subscription with deeply nested dot-notation on protected field in watch', async () => {
3716+
const query = new Parse.Query('SecretClass');
3717+
query.watch('secretObj.nested.deep.key');
3718+
await expectAsync(query.subscribe()).toBeRejectedWith(
3719+
new Parse.Error(Parse.Error.OPERATION_FORBIDDEN, 'Permission denied')
3720+
);
3721+
});
3722+
3723+
it('should allow LiveQuery subscription with non-protected field in watch', async () => {
3724+
const query = new Parse.Query('SecretClass');
3725+
query.watch('publicField');
3726+
const subscription = await query.subscribe();
3727+
await Promise.all([
3728+
new Promise(resolve => {
3729+
subscription.on('update', object => {
3730+
expect(object.get('secretObj')).toBeUndefined();
3731+
expect(object.get('publicField')).toBe('updated');
3732+
resolve();
3733+
});
3734+
}),
3735+
obj.save({ publicField: 'updated' }, { useMasterKey: true }),
3736+
]);
3737+
});
3738+
3739+
it('should not deliver update event when only non-watched field changes', async () => {
3740+
const query = new Parse.Query('SecretClass');
3741+
query.watch('publicField');
3742+
const subscription = await query.subscribe();
3743+
const updateSpy = jasmine.createSpy('update');
3744+
subscription.on('update', updateSpy);
3745+
3746+
// Change a field that is NOT in the watch list
3747+
obj.set('secretObj', { apiKey: 'ROTATED_KEY', score: 99 });
3748+
await obj.save(null, { useMasterKey: true });
3749+
await sleep(500);
3750+
expect(updateSpy).not.toHaveBeenCalled();
3751+
});
3752+
3753+
});

src/LiveQuery/ParseLiveQueryServer.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1050,7 +1050,7 @@ class ParseLiveQueryServer {
10501050
op
10511051
);
10521052

1053-
// Check protected fields in WHERE clause
1053+
// Check protected fields in WHERE clause and WATCH parameter
10541054
if (!client.hasMasterKey) {
10551055
const auth = request.user ? { user: request.user, userRoles: [] } : {};
10561056
const protectedFields =
@@ -1083,6 +1083,17 @@ class ParseLiveQueryServer {
10831083
};
10841084
checkWhere(request.query.where);
10851085
}
1086+
if (protectedFields.length > 0 && Array.isArray(request.query.watch)) {
1087+
for (const watchField of request.query.watch) {
1088+
const rootField = watchField.split('.')[0];
1089+
if (protectedFields.includes(watchField) || protectedFields.includes(rootField)) {
1090+
throw new Parse.Error(
1091+
Parse.Error.OPERATION_FORBIDDEN,
1092+
'Permission denied'
1093+
);
1094+
}
1095+
}
1096+
}
10861097
}
10871098

10881099
// Validate regex patterns in the subscription query

0 commit comments

Comments
 (0)