Skip to content

Commit 5834e29

Browse files
authored
fix: LiveQuery protected field leak via shared mutable state across concurrent subscribers ([GHSA-m983-v2ff-wq65](GHSA-m983-v2ff-wq65)) (#10331)
1 parent cf88643 commit 5834e29

2 files changed

Lines changed: 354 additions & 18 deletions

File tree

spec/vulnerabilities.spec.js

Lines changed: 328 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3072,6 +3072,334 @@ describe('(GHSA-5hmj-jcgp-6hff) Protected fields leak via LiveQuery afterEvent t
30723072
]);
30733073
});
30743074

3075+
describe('(GHSA-m983-v2ff-wq65) LiveQuery shared mutable state race across concurrent subscribers', () => {
3076+
// Helper: create a LiveQuery client, wait for open, subscribe, wait for subscription ACK
3077+
async function createSubscribedClient({ className, masterKey, installationId }) {
3078+
const opts = {
3079+
applicationId: 'test',
3080+
serverURL: 'ws://localhost:8378',
3081+
javascriptKey: 'test',
3082+
};
3083+
if (masterKey) {
3084+
opts.masterKey = 'test';
3085+
}
3086+
if (installationId) {
3087+
opts.installationId = installationId;
3088+
}
3089+
const client = new Parse.LiveQueryClient(opts);
3090+
client.open();
3091+
const query = new Parse.Query(className);
3092+
const sub = client.subscribe(query);
3093+
await new Promise(resolve => sub.on('open', resolve));
3094+
return { client, sub };
3095+
}
3096+
3097+
async function setupProtectedClass(className) {
3098+
const config = Config.get(Parse.applicationId);
3099+
const schemaController = await config.database.loadSchema();
3100+
await schemaController.addClassIfNotExists(className, {
3101+
secretField: { type: 'String' },
3102+
publicField: { type: 'String' },
3103+
});
3104+
await schemaController.updateClass(
3105+
className,
3106+
{},
3107+
{
3108+
find: { '*': true },
3109+
get: { '*': true },
3110+
create: { '*': true },
3111+
update: { '*': true },
3112+
delete: { '*': true },
3113+
addField: {},
3114+
protectedFields: { '*': ['secretField'] },
3115+
}
3116+
);
3117+
}
3118+
3119+
it('should deliver protected fields to master key LiveQuery client', async () => {
3120+
const className = 'MasterKeyProtectedClass';
3121+
Parse.CoreManager.getLiveQueryController().setDefaultLiveQueryClient(null);
3122+
await reconfigureServer({
3123+
liveQuery: { classNames: [className] },
3124+
liveQueryServerOptions: {
3125+
keyPairs: { masterKey: 'test', javascriptKey: 'test' },
3126+
},
3127+
verbose: false,
3128+
silent: true,
3129+
});
3130+
Parse.Cloud.afterLiveQueryEvent(className, () => {});
3131+
await setupProtectedClass(className);
3132+
3133+
const { client: masterClient, sub: masterSub } = await createSubscribedClient({
3134+
className,
3135+
masterKey: true,
3136+
});
3137+
3138+
try {
3139+
const result = new Promise(resolve => {
3140+
masterSub.on('create', object => {
3141+
resolve({
3142+
secretField: object.get('secretField'),
3143+
publicField: object.get('publicField'),
3144+
});
3145+
});
3146+
});
3147+
3148+
const obj = new Parse.Object(className);
3149+
obj.set('secretField', 'MASTER_VISIBLE');
3150+
obj.set('publicField', 'public');
3151+
await obj.save(null, { useMasterKey: true });
3152+
3153+
const received = await result;
3154+
3155+
// Master key client must see protected fields
3156+
expect(received.secretField).toBe('MASTER_VISIBLE');
3157+
expect(received.publicField).toBe('public');
3158+
} finally {
3159+
masterClient.close();
3160+
}
3161+
});
3162+
3163+
it('should not leak protected fields to regular client when master key client subscribes concurrently on update', async () => {
3164+
const className = 'RaceUpdateClass';
3165+
Parse.CoreManager.getLiveQueryController().setDefaultLiveQueryClient(null);
3166+
await reconfigureServer({
3167+
liveQuery: { classNames: [className] },
3168+
liveQueryServerOptions: {
3169+
keyPairs: { masterKey: 'test', javascriptKey: 'test' },
3170+
},
3171+
verbose: false,
3172+
silent: true,
3173+
});
3174+
Parse.Cloud.afterLiveQueryEvent(className, () => {});
3175+
await setupProtectedClass(className);
3176+
3177+
const { client: masterClient, sub: masterSub } = await createSubscribedClient({
3178+
className,
3179+
masterKey: true,
3180+
});
3181+
const { client: regularClient, sub: regularSub } = await createSubscribedClient({
3182+
className,
3183+
masterKey: false,
3184+
});
3185+
3186+
try {
3187+
const obj = new Parse.Object(className);
3188+
obj.set('secretField', 'TOP_SECRET');
3189+
obj.set('publicField', 'visible');
3190+
await obj.save(null, { useMasterKey: true });
3191+
3192+
const masterResult = new Promise(resolve => {
3193+
masterSub.on('update', object => {
3194+
resolve({
3195+
secretField: object.get('secretField'),
3196+
publicField: object.get('publicField'),
3197+
});
3198+
});
3199+
});
3200+
const regularResult = new Promise(resolve => {
3201+
regularSub.on('update', object => {
3202+
resolve({
3203+
secretField: object.get('secretField'),
3204+
publicField: object.get('publicField'),
3205+
});
3206+
});
3207+
});
3208+
3209+
await obj.save({ publicField: 'updated' }, { useMasterKey: true });
3210+
const [master, regular] = await Promise.all([masterResult, regularResult]);
3211+
3212+
// Regular client must NOT see the secret field
3213+
expect(regular.secretField).toBeUndefined();
3214+
expect(regular.publicField).toBe('updated');
3215+
// Master client must see the secret field
3216+
expect(master.secretField).toBe('TOP_SECRET');
3217+
expect(master.publicField).toBe('updated');
3218+
} finally {
3219+
masterClient.close();
3220+
regularClient.close();
3221+
}
3222+
});
3223+
3224+
it('should not leak protected fields to regular client when master key client subscribes concurrently on create', async () => {
3225+
const className = 'RaceCreateClass';
3226+
Parse.CoreManager.getLiveQueryController().setDefaultLiveQueryClient(null);
3227+
await reconfigureServer({
3228+
liveQuery: { classNames: [className] },
3229+
liveQueryServerOptions: {
3230+
keyPairs: { masterKey: 'test', javascriptKey: 'test' },
3231+
},
3232+
verbose: false,
3233+
silent: true,
3234+
});
3235+
Parse.Cloud.afterLiveQueryEvent(className, () => {});
3236+
await setupProtectedClass(className);
3237+
3238+
const { client: masterClient, sub: masterSub } = await createSubscribedClient({
3239+
className,
3240+
masterKey: true,
3241+
});
3242+
const { client: regularClient, sub: regularSub } = await createSubscribedClient({
3243+
className,
3244+
masterKey: false,
3245+
});
3246+
3247+
try {
3248+
const masterResult = new Promise(resolve => {
3249+
masterSub.on('create', object => {
3250+
resolve({
3251+
secretField: object.get('secretField'),
3252+
publicField: object.get('publicField'),
3253+
});
3254+
});
3255+
});
3256+
const regularResult = new Promise(resolve => {
3257+
regularSub.on('create', object => {
3258+
resolve({
3259+
secretField: object.get('secretField'),
3260+
publicField: object.get('publicField'),
3261+
});
3262+
});
3263+
});
3264+
3265+
const newObj = new Parse.Object(className);
3266+
newObj.set('secretField', 'SECRET');
3267+
newObj.set('publicField', 'public');
3268+
await newObj.save(null, { useMasterKey: true });
3269+
3270+
const [master, regular] = await Promise.all([masterResult, regularResult]);
3271+
3272+
expect(regular.secretField).toBeUndefined();
3273+
expect(regular.publicField).toBe('public');
3274+
expect(master.secretField).toBe('SECRET');
3275+
expect(master.publicField).toBe('public');
3276+
} finally {
3277+
masterClient.close();
3278+
regularClient.close();
3279+
}
3280+
});
3281+
3282+
it('should not leak protected fields to regular client when master key client subscribes concurrently on delete', async () => {
3283+
const className = 'RaceDeleteClass';
3284+
Parse.CoreManager.getLiveQueryController().setDefaultLiveQueryClient(null);
3285+
await reconfigureServer({
3286+
liveQuery: { classNames: [className] },
3287+
liveQueryServerOptions: {
3288+
keyPairs: { masterKey: 'test', javascriptKey: 'test' },
3289+
},
3290+
verbose: false,
3291+
silent: true,
3292+
});
3293+
Parse.Cloud.afterLiveQueryEvent(className, () => {});
3294+
await setupProtectedClass(className);
3295+
3296+
const { client: masterClient, sub: masterSub } = await createSubscribedClient({
3297+
className,
3298+
masterKey: true,
3299+
});
3300+
const { client: regularClient, sub: regularSub } = await createSubscribedClient({
3301+
className,
3302+
masterKey: false,
3303+
});
3304+
3305+
try {
3306+
const obj = new Parse.Object(className);
3307+
obj.set('secretField', 'SECRET');
3308+
obj.set('publicField', 'public');
3309+
await obj.save(null, { useMasterKey: true });
3310+
3311+
const masterResult = new Promise(resolve => {
3312+
masterSub.on('delete', object => {
3313+
resolve({
3314+
secretField: object.get('secretField'),
3315+
publicField: object.get('publicField'),
3316+
});
3317+
});
3318+
});
3319+
const regularResult = new Promise(resolve => {
3320+
regularSub.on('delete', object => {
3321+
resolve({
3322+
secretField: object.get('secretField'),
3323+
publicField: object.get('publicField'),
3324+
});
3325+
});
3326+
});
3327+
3328+
await obj.destroy({ useMasterKey: true });
3329+
const [master, regular] = await Promise.all([masterResult, regularResult]);
3330+
3331+
expect(regular.secretField).toBeUndefined();
3332+
expect(regular.publicField).toBe('public');
3333+
expect(master.secretField).toBe('SECRET');
3334+
expect(master.publicField).toBe('public');
3335+
} finally {
3336+
masterClient.close();
3337+
regularClient.close();
3338+
}
3339+
});
3340+
3341+
it('should not corrupt object when afterEvent trigger modifies res.object for one client', async () => {
3342+
const className = 'TriggerRaceClass';
3343+
Parse.CoreManager.getLiveQueryController().setDefaultLiveQueryClient(null);
3344+
await reconfigureServer({
3345+
liveQuery: { classNames: [className] },
3346+
startLiveQueryServer: true,
3347+
verbose: false,
3348+
silent: true,
3349+
});
3350+
Parse.Cloud.afterLiveQueryEvent(className, req => {
3351+
if (req.object) {
3352+
req.object.set('injected', `for-${req.installationId}`);
3353+
}
3354+
});
3355+
const config = Config.get(Parse.applicationId);
3356+
const schemaController = await config.database.loadSchema();
3357+
await schemaController.addClassIfNotExists(className, {
3358+
data: { type: 'String' },
3359+
injected: { type: 'String' },
3360+
});
3361+
3362+
const { client: client1, sub: sub1 } = await createSubscribedClient({
3363+
className,
3364+
masterKey: false,
3365+
installationId: 'client-1',
3366+
});
3367+
const { client: client2, sub: sub2 } = await createSubscribedClient({
3368+
className,
3369+
masterKey: false,
3370+
installationId: 'client-2',
3371+
});
3372+
3373+
try {
3374+
const result1 = new Promise(resolve => {
3375+
sub1.on('create', object => {
3376+
resolve({ data: object.get('data'), injected: object.get('injected') });
3377+
});
3378+
});
3379+
const result2 = new Promise(resolve => {
3380+
sub2.on('create', object => {
3381+
resolve({ data: object.get('data'), injected: object.get('injected') });
3382+
});
3383+
});
3384+
3385+
const newObj = new Parse.Object(className);
3386+
newObj.set('data', 'value');
3387+
await newObj.save(null, { useMasterKey: true });
3388+
3389+
const [r1, r2] = await Promise.all([result1, result2]);
3390+
3391+
expect(r1.data).toBe('value');
3392+
expect(r2.data).toBe('value');
3393+
expect(r1.injected).toBe('for-client-1');
3394+
expect(r2.injected).toBe('for-client-2');
3395+
expect(r1.injected).not.toBe(r2.injected);
3396+
} finally {
3397+
client1.close();
3398+
client2.close();
3399+
}
3400+
});
3401+
});
3402+
30753403
describe('(GHSA-pfj7-wv7c-22pr) AuthData subset validation bypass with allowExpiredAuthDataToken', () => {
30763404
let validatorSpy;
30773405

0 commit comments

Comments
 (0)