Skip to content

Commit 776c71c

Browse files
authored
fix: LiveQuery protected field leak via shared mutable state across concurrent subscribers ([GHSA-m983-v2ff-wq65](GHSA-m983-v2ff-wq65)) (#10330)
1 parent aee2146 commit 776c71c

2 files changed

Lines changed: 353 additions & 18 deletions

File tree

spec/vulnerabilities.spec.js

Lines changed: 327 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3404,6 +3404,333 @@ describe('(GHSA-5hmj-jcgp-6hff) Protected fields leak via LiveQuery afterEvent t
34043404
]);
34053405
});
34063406

3407+
describe('(GHSA-m983-v2ff-wq65) LiveQuery shared mutable state race across concurrent subscribers', () => {
3408+
// Helper: create a LiveQuery client, wait for open, subscribe, wait for subscription ACK
3409+
async function createSubscribedClient({ className, masterKey, installationId }) {
3410+
const opts = {
3411+
applicationId: 'test',
3412+
serverURL: 'ws://localhost:8378',
3413+
javascriptKey: 'test',
3414+
};
3415+
if (masterKey) {
3416+
opts.masterKey = 'test';
3417+
}
3418+
if (installationId) {
3419+
opts.installationId = installationId;
3420+
}
3421+
const client = new Parse.LiveQueryClient(opts);
3422+
client.open();
3423+
const query = new Parse.Query(className);
3424+
const sub = client.subscribe(query);
3425+
await new Promise(resolve => sub.on('open', resolve));
3426+
return { client, sub };
3427+
}
3428+
3429+
async function setupProtectedClass(className) {
3430+
const config = Config.get(Parse.applicationId);
3431+
const schemaController = await config.database.loadSchema();
3432+
await schemaController.addClassIfNotExists(className, {
3433+
secretField: { type: 'String' },
3434+
publicField: { type: 'String' },
3435+
});
3436+
await schemaController.updateClass(
3437+
className,
3438+
{},
3439+
{
3440+
find: { '*': true },
3441+
get: { '*': true },
3442+
create: { '*': true },
3443+
update: { '*': true },
3444+
delete: { '*': true },
3445+
addField: {},
3446+
protectedFields: { '*': ['secretField'] },
3447+
}
3448+
);
3449+
}
3450+
3451+
it('should deliver protected fields to master key LiveQuery client', async () => {
3452+
const className = 'MasterKeyProtectedClass';
3453+
Parse.CoreManager.getLiveQueryController().setDefaultLiveQueryClient(null);
3454+
await reconfigureServer({
3455+
liveQuery: { classNames: [className] },
3456+
liveQueryServerOptions: {
3457+
keyPairs: { masterKey: 'test', javascriptKey: 'test' },
3458+
},
3459+
verbose: false,
3460+
silent: true,
3461+
});
3462+
Parse.Cloud.afterLiveQueryEvent(className, () => {});
3463+
await setupProtectedClass(className);
3464+
3465+
const { client: masterClient, sub: masterSub } = await createSubscribedClient({
3466+
className,
3467+
masterKey: true,
3468+
});
3469+
3470+
try {
3471+
const result = new Promise(resolve => {
3472+
masterSub.on('create', object => {
3473+
resolve({
3474+
secretField: object.get('secretField'),
3475+
publicField: object.get('publicField'),
3476+
});
3477+
});
3478+
});
3479+
3480+
const obj = new Parse.Object(className);
3481+
obj.set('secretField', 'MASTER_VISIBLE');
3482+
obj.set('publicField', 'public');
3483+
await obj.save(null, { useMasterKey: true });
3484+
3485+
const received = await result;
3486+
3487+
// Master key client must see protected fields
3488+
expect(received.secretField).toBe('MASTER_VISIBLE');
3489+
expect(received.publicField).toBe('public');
3490+
} finally {
3491+
masterClient.close();
3492+
}
3493+
});
3494+
3495+
it('should not leak protected fields to regular client when master key client subscribes concurrently on update', async () => {
3496+
const className = 'RaceUpdateClass';
3497+
Parse.CoreManager.getLiveQueryController().setDefaultLiveQueryClient(null);
3498+
await reconfigureServer({
3499+
liveQuery: { classNames: [className] },
3500+
liveQueryServerOptions: {
3501+
keyPairs: { masterKey: 'test', javascriptKey: 'test' },
3502+
},
3503+
verbose: false,
3504+
silent: true,
3505+
});
3506+
Parse.Cloud.afterLiveQueryEvent(className, () => {});
3507+
await setupProtectedClass(className);
3508+
3509+
const { client: masterClient, sub: masterSub } = await createSubscribedClient({
3510+
className,
3511+
masterKey: true,
3512+
});
3513+
const { client: regularClient, sub: regularSub } = await createSubscribedClient({
3514+
className,
3515+
masterKey: false,
3516+
});
3517+
3518+
try {
3519+
const obj = new Parse.Object(className);
3520+
obj.set('secretField', 'TOP_SECRET');
3521+
obj.set('publicField', 'visible');
3522+
await obj.save(null, { useMasterKey: true });
3523+
3524+
const masterResult = new Promise(resolve => {
3525+
masterSub.on('update', object => {
3526+
resolve({
3527+
secretField: object.get('secretField'),
3528+
publicField: object.get('publicField'),
3529+
});
3530+
});
3531+
});
3532+
const regularResult = new Promise(resolve => {
3533+
regularSub.on('update', object => {
3534+
resolve({
3535+
secretField: object.get('secretField'),
3536+
publicField: object.get('publicField'),
3537+
});
3538+
});
3539+
});
3540+
3541+
await obj.save({ publicField: 'updated' }, { useMasterKey: true });
3542+
const [master, regular] = await Promise.all([masterResult, regularResult]);
3543+
// Regular client must NOT see the secret field
3544+
expect(regular.secretField).toBeUndefined();
3545+
expect(regular.publicField).toBe('updated');
3546+
// Master client must see the secret field
3547+
expect(master.secretField).toBe('TOP_SECRET');
3548+
expect(master.publicField).toBe('updated');
3549+
} finally {
3550+
masterClient.close();
3551+
regularClient.close();
3552+
}
3553+
});
3554+
3555+
it('should not leak protected fields to regular client when master key client subscribes concurrently on create', async () => {
3556+
const className = 'RaceCreateClass';
3557+
Parse.CoreManager.getLiveQueryController().setDefaultLiveQueryClient(null);
3558+
await reconfigureServer({
3559+
liveQuery: { classNames: [className] },
3560+
liveQueryServerOptions: {
3561+
keyPairs: { masterKey: 'test', javascriptKey: 'test' },
3562+
},
3563+
verbose: false,
3564+
silent: true,
3565+
});
3566+
Parse.Cloud.afterLiveQueryEvent(className, () => {});
3567+
await setupProtectedClass(className);
3568+
3569+
const { client: masterClient, sub: masterSub } = await createSubscribedClient({
3570+
className,
3571+
masterKey: true,
3572+
});
3573+
const { client: regularClient, sub: regularSub } = await createSubscribedClient({
3574+
className,
3575+
masterKey: false,
3576+
});
3577+
3578+
try {
3579+
const masterResult = new Promise(resolve => {
3580+
masterSub.on('create', object => {
3581+
resolve({
3582+
secretField: object.get('secretField'),
3583+
publicField: object.get('publicField'),
3584+
});
3585+
});
3586+
});
3587+
const regularResult = new Promise(resolve => {
3588+
regularSub.on('create', object => {
3589+
resolve({
3590+
secretField: object.get('secretField'),
3591+
publicField: object.get('publicField'),
3592+
});
3593+
});
3594+
});
3595+
3596+
const newObj = new Parse.Object(className);
3597+
newObj.set('secretField', 'SECRET');
3598+
newObj.set('publicField', 'public');
3599+
await newObj.save(null, { useMasterKey: true });
3600+
3601+
const [master, regular] = await Promise.all([masterResult, regularResult]);
3602+
3603+
expect(regular.secretField).toBeUndefined();
3604+
expect(regular.publicField).toBe('public');
3605+
expect(master.secretField).toBe('SECRET');
3606+
expect(master.publicField).toBe('public');
3607+
} finally {
3608+
masterClient.close();
3609+
regularClient.close();
3610+
}
3611+
});
3612+
3613+
it('should not leak protected fields to regular client when master key client subscribes concurrently on delete', async () => {
3614+
const className = 'RaceDeleteClass';
3615+
Parse.CoreManager.getLiveQueryController().setDefaultLiveQueryClient(null);
3616+
await reconfigureServer({
3617+
liveQuery: { classNames: [className] },
3618+
liveQueryServerOptions: {
3619+
keyPairs: { masterKey: 'test', javascriptKey: 'test' },
3620+
},
3621+
verbose: false,
3622+
silent: true,
3623+
});
3624+
Parse.Cloud.afterLiveQueryEvent(className, () => {});
3625+
await setupProtectedClass(className);
3626+
3627+
const { client: masterClient, sub: masterSub } = await createSubscribedClient({
3628+
className,
3629+
masterKey: true,
3630+
});
3631+
const { client: regularClient, sub: regularSub } = await createSubscribedClient({
3632+
className,
3633+
masterKey: false,
3634+
});
3635+
3636+
try {
3637+
const obj = new Parse.Object(className);
3638+
obj.set('secretField', 'SECRET');
3639+
obj.set('publicField', 'public');
3640+
await obj.save(null, { useMasterKey: true });
3641+
3642+
const masterResult = new Promise(resolve => {
3643+
masterSub.on('delete', object => {
3644+
resolve({
3645+
secretField: object.get('secretField'),
3646+
publicField: object.get('publicField'),
3647+
});
3648+
});
3649+
});
3650+
const regularResult = new Promise(resolve => {
3651+
regularSub.on('delete', object => {
3652+
resolve({
3653+
secretField: object.get('secretField'),
3654+
publicField: object.get('publicField'),
3655+
});
3656+
});
3657+
});
3658+
3659+
await obj.destroy({ useMasterKey: true });
3660+
const [master, regular] = await Promise.all([masterResult, regularResult]);
3661+
3662+
expect(regular.secretField).toBeUndefined();
3663+
expect(regular.publicField).toBe('public');
3664+
expect(master.secretField).toBe('SECRET');
3665+
expect(master.publicField).toBe('public');
3666+
} finally {
3667+
masterClient.close();
3668+
regularClient.close();
3669+
}
3670+
});
3671+
3672+
it('should not corrupt object when afterEvent trigger modifies res.object for one client', async () => {
3673+
const className = 'TriggerRaceClass';
3674+
Parse.CoreManager.getLiveQueryController().setDefaultLiveQueryClient(null);
3675+
await reconfigureServer({
3676+
liveQuery: { classNames: [className] },
3677+
startLiveQueryServer: true,
3678+
verbose: false,
3679+
silent: true,
3680+
});
3681+
Parse.Cloud.afterLiveQueryEvent(className, req => {
3682+
if (req.object) {
3683+
req.object.set('injected', `for-${req.installationId}`);
3684+
}
3685+
});
3686+
const config = Config.get(Parse.applicationId);
3687+
const schemaController = await config.database.loadSchema();
3688+
await schemaController.addClassIfNotExists(className, {
3689+
data: { type: 'String' },
3690+
injected: { type: 'String' },
3691+
});
3692+
3693+
const { client: client1, sub: sub1 } = await createSubscribedClient({
3694+
className,
3695+
masterKey: false,
3696+
installationId: 'client-1',
3697+
});
3698+
const { client: client2, sub: sub2 } = await createSubscribedClient({
3699+
className,
3700+
masterKey: false,
3701+
installationId: 'client-2',
3702+
});
3703+
3704+
try {
3705+
const result1 = new Promise(resolve => {
3706+
sub1.on('create', object => {
3707+
resolve({ data: object.get('data'), injected: object.get('injected') });
3708+
});
3709+
});
3710+
const result2 = new Promise(resolve => {
3711+
sub2.on('create', object => {
3712+
resolve({ data: object.get('data'), injected: object.get('injected') });
3713+
});
3714+
});
3715+
3716+
const newObj = new Parse.Object(className);
3717+
newObj.set('data', 'value');
3718+
await newObj.save(null, { useMasterKey: true });
3719+
3720+
const [r1, r2] = await Promise.all([result1, result2]);
3721+
3722+
expect(r1.data).toBe('value');
3723+
expect(r2.data).toBe('value');
3724+
expect(r1.injected).toBe('for-client-1');
3725+
expect(r2.injected).toBe('for-client-2');
3726+
expect(r1.injected).not.toBe(r2.injected);
3727+
} finally {
3728+
client1.close();
3729+
client2.close();
3730+
}
3731+
});
3732+
});
3733+
34073734
describe('(GHSA-pfj7-wv7c-22pr) AuthData subset validation bypass with allowExpiredAuthDataToken', () => {
34083735
let validatorSpy;
34093736

0 commit comments

Comments
 (0)