mirror of
https://github.com/parse-community/parse-server
synced 2026-08-09 13:03:18 +00:00
fix: LiveQuery protected field leak via shared mutable state across concurrent subscribers ([GHSA-m983-v2ff-wq65](https://github.com/parse-community/parse-server/security/advisories/GHSA-m983-v2ff-wq65)) (#10331)
This commit is contained in:
@@ -3072,6 +3072,334 @@ describe('(GHSA-5hmj-jcgp-6hff) Protected fields leak via LiveQuery afterEvent t
|
||||
]);
|
||||
});
|
||||
|
||||
describe('(GHSA-m983-v2ff-wq65) LiveQuery shared mutable state race across concurrent subscribers', () => {
|
||||
// Helper: create a LiveQuery client, wait for open, subscribe, wait for subscription ACK
|
||||
async function createSubscribedClient({ className, masterKey, installationId }) {
|
||||
const opts = {
|
||||
applicationId: 'test',
|
||||
serverURL: 'ws://localhost:8378',
|
||||
javascriptKey: 'test',
|
||||
};
|
||||
if (masterKey) {
|
||||
opts.masterKey = 'test';
|
||||
}
|
||||
if (installationId) {
|
||||
opts.installationId = installationId;
|
||||
}
|
||||
const client = new Parse.LiveQueryClient(opts);
|
||||
client.open();
|
||||
const query = new Parse.Query(className);
|
||||
const sub = client.subscribe(query);
|
||||
await new Promise(resolve => sub.on('open', resolve));
|
||||
return { client, sub };
|
||||
}
|
||||
|
||||
async function setupProtectedClass(className) {
|
||||
const config = Config.get(Parse.applicationId);
|
||||
const schemaController = await config.database.loadSchema();
|
||||
await schemaController.addClassIfNotExists(className, {
|
||||
secretField: { type: 'String' },
|
||||
publicField: { type: 'String' },
|
||||
});
|
||||
await schemaController.updateClass(
|
||||
className,
|
||||
{},
|
||||
{
|
||||
find: { '*': true },
|
||||
get: { '*': true },
|
||||
create: { '*': true },
|
||||
update: { '*': true },
|
||||
delete: { '*': true },
|
||||
addField: {},
|
||||
protectedFields: { '*': ['secretField'] },
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
it('should deliver protected fields to master key LiveQuery client', async () => {
|
||||
const className = 'MasterKeyProtectedClass';
|
||||
Parse.CoreManager.getLiveQueryController().setDefaultLiveQueryClient(null);
|
||||
await reconfigureServer({
|
||||
liveQuery: { classNames: [className] },
|
||||
liveQueryServerOptions: {
|
||||
keyPairs: { masterKey: 'test', javascriptKey: 'test' },
|
||||
},
|
||||
verbose: false,
|
||||
silent: true,
|
||||
});
|
||||
Parse.Cloud.afterLiveQueryEvent(className, () => {});
|
||||
await setupProtectedClass(className);
|
||||
|
||||
const { client: masterClient, sub: masterSub } = await createSubscribedClient({
|
||||
className,
|
||||
masterKey: true,
|
||||
});
|
||||
|
||||
try {
|
||||
const result = new Promise(resolve => {
|
||||
masterSub.on('create', object => {
|
||||
resolve({
|
||||
secretField: object.get('secretField'),
|
||||
publicField: object.get('publicField'),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
const obj = new Parse.Object(className);
|
||||
obj.set('secretField', 'MASTER_VISIBLE');
|
||||
obj.set('publicField', 'public');
|
||||
await obj.save(null, { useMasterKey: true });
|
||||
|
||||
const received = await result;
|
||||
|
||||
// Master key client must see protected fields
|
||||
expect(received.secretField).toBe('MASTER_VISIBLE');
|
||||
expect(received.publicField).toBe('public');
|
||||
} finally {
|
||||
masterClient.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('should not leak protected fields to regular client when master key client subscribes concurrently on update', async () => {
|
||||
const className = 'RaceUpdateClass';
|
||||
Parse.CoreManager.getLiveQueryController().setDefaultLiveQueryClient(null);
|
||||
await reconfigureServer({
|
||||
liveQuery: { classNames: [className] },
|
||||
liveQueryServerOptions: {
|
||||
keyPairs: { masterKey: 'test', javascriptKey: 'test' },
|
||||
},
|
||||
verbose: false,
|
||||
silent: true,
|
||||
});
|
||||
Parse.Cloud.afterLiveQueryEvent(className, () => {});
|
||||
await setupProtectedClass(className);
|
||||
|
||||
const { client: masterClient, sub: masterSub } = await createSubscribedClient({
|
||||
className,
|
||||
masterKey: true,
|
||||
});
|
||||
const { client: regularClient, sub: regularSub } = await createSubscribedClient({
|
||||
className,
|
||||
masterKey: false,
|
||||
});
|
||||
|
||||
try {
|
||||
const obj = new Parse.Object(className);
|
||||
obj.set('secretField', 'TOP_SECRET');
|
||||
obj.set('publicField', 'visible');
|
||||
await obj.save(null, { useMasterKey: true });
|
||||
|
||||
const masterResult = new Promise(resolve => {
|
||||
masterSub.on('update', object => {
|
||||
resolve({
|
||||
secretField: object.get('secretField'),
|
||||
publicField: object.get('publicField'),
|
||||
});
|
||||
});
|
||||
});
|
||||
const regularResult = new Promise(resolve => {
|
||||
regularSub.on('update', object => {
|
||||
resolve({
|
||||
secretField: object.get('secretField'),
|
||||
publicField: object.get('publicField'),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
await obj.save({ publicField: 'updated' }, { useMasterKey: true });
|
||||
const [master, regular] = await Promise.all([masterResult, regularResult]);
|
||||
|
||||
// Regular client must NOT see the secret field
|
||||
expect(regular.secretField).toBeUndefined();
|
||||
expect(regular.publicField).toBe('updated');
|
||||
// Master client must see the secret field
|
||||
expect(master.secretField).toBe('TOP_SECRET');
|
||||
expect(master.publicField).toBe('updated');
|
||||
} finally {
|
||||
masterClient.close();
|
||||
regularClient.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('should not leak protected fields to regular client when master key client subscribes concurrently on create', async () => {
|
||||
const className = 'RaceCreateClass';
|
||||
Parse.CoreManager.getLiveQueryController().setDefaultLiveQueryClient(null);
|
||||
await reconfigureServer({
|
||||
liveQuery: { classNames: [className] },
|
||||
liveQueryServerOptions: {
|
||||
keyPairs: { masterKey: 'test', javascriptKey: 'test' },
|
||||
},
|
||||
verbose: false,
|
||||
silent: true,
|
||||
});
|
||||
Parse.Cloud.afterLiveQueryEvent(className, () => {});
|
||||
await setupProtectedClass(className);
|
||||
|
||||
const { client: masterClient, sub: masterSub } = await createSubscribedClient({
|
||||
className,
|
||||
masterKey: true,
|
||||
});
|
||||
const { client: regularClient, sub: regularSub } = await createSubscribedClient({
|
||||
className,
|
||||
masterKey: false,
|
||||
});
|
||||
|
||||
try {
|
||||
const masterResult = new Promise(resolve => {
|
||||
masterSub.on('create', object => {
|
||||
resolve({
|
||||
secretField: object.get('secretField'),
|
||||
publicField: object.get('publicField'),
|
||||
});
|
||||
});
|
||||
});
|
||||
const regularResult = new Promise(resolve => {
|
||||
regularSub.on('create', object => {
|
||||
resolve({
|
||||
secretField: object.get('secretField'),
|
||||
publicField: object.get('publicField'),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
const newObj = new Parse.Object(className);
|
||||
newObj.set('secretField', 'SECRET');
|
||||
newObj.set('publicField', 'public');
|
||||
await newObj.save(null, { useMasterKey: true });
|
||||
|
||||
const [master, regular] = await Promise.all([masterResult, regularResult]);
|
||||
|
||||
expect(regular.secretField).toBeUndefined();
|
||||
expect(regular.publicField).toBe('public');
|
||||
expect(master.secretField).toBe('SECRET');
|
||||
expect(master.publicField).toBe('public');
|
||||
} finally {
|
||||
masterClient.close();
|
||||
regularClient.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('should not leak protected fields to regular client when master key client subscribes concurrently on delete', async () => {
|
||||
const className = 'RaceDeleteClass';
|
||||
Parse.CoreManager.getLiveQueryController().setDefaultLiveQueryClient(null);
|
||||
await reconfigureServer({
|
||||
liveQuery: { classNames: [className] },
|
||||
liveQueryServerOptions: {
|
||||
keyPairs: { masterKey: 'test', javascriptKey: 'test' },
|
||||
},
|
||||
verbose: false,
|
||||
silent: true,
|
||||
});
|
||||
Parse.Cloud.afterLiveQueryEvent(className, () => {});
|
||||
await setupProtectedClass(className);
|
||||
|
||||
const { client: masterClient, sub: masterSub } = await createSubscribedClient({
|
||||
className,
|
||||
masterKey: true,
|
||||
});
|
||||
const { client: regularClient, sub: regularSub } = await createSubscribedClient({
|
||||
className,
|
||||
masterKey: false,
|
||||
});
|
||||
|
||||
try {
|
||||
const obj = new Parse.Object(className);
|
||||
obj.set('secretField', 'SECRET');
|
||||
obj.set('publicField', 'public');
|
||||
await obj.save(null, { useMasterKey: true });
|
||||
|
||||
const masterResult = new Promise(resolve => {
|
||||
masterSub.on('delete', object => {
|
||||
resolve({
|
||||
secretField: object.get('secretField'),
|
||||
publicField: object.get('publicField'),
|
||||
});
|
||||
});
|
||||
});
|
||||
const regularResult = new Promise(resolve => {
|
||||
regularSub.on('delete', object => {
|
||||
resolve({
|
||||
secretField: object.get('secretField'),
|
||||
publicField: object.get('publicField'),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
await obj.destroy({ useMasterKey: true });
|
||||
const [master, regular] = await Promise.all([masterResult, regularResult]);
|
||||
|
||||
expect(regular.secretField).toBeUndefined();
|
||||
expect(regular.publicField).toBe('public');
|
||||
expect(master.secretField).toBe('SECRET');
|
||||
expect(master.publicField).toBe('public');
|
||||
} finally {
|
||||
masterClient.close();
|
||||
regularClient.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('should not corrupt object when afterEvent trigger modifies res.object for one client', async () => {
|
||||
const className = 'TriggerRaceClass';
|
||||
Parse.CoreManager.getLiveQueryController().setDefaultLiveQueryClient(null);
|
||||
await reconfigureServer({
|
||||
liveQuery: { classNames: [className] },
|
||||
startLiveQueryServer: true,
|
||||
verbose: false,
|
||||
silent: true,
|
||||
});
|
||||
Parse.Cloud.afterLiveQueryEvent(className, req => {
|
||||
if (req.object) {
|
||||
req.object.set('injected', `for-${req.installationId}`);
|
||||
}
|
||||
});
|
||||
const config = Config.get(Parse.applicationId);
|
||||
const schemaController = await config.database.loadSchema();
|
||||
await schemaController.addClassIfNotExists(className, {
|
||||
data: { type: 'String' },
|
||||
injected: { type: 'String' },
|
||||
});
|
||||
|
||||
const { client: client1, sub: sub1 } = await createSubscribedClient({
|
||||
className,
|
||||
masterKey: false,
|
||||
installationId: 'client-1',
|
||||
});
|
||||
const { client: client2, sub: sub2 } = await createSubscribedClient({
|
||||
className,
|
||||
masterKey: false,
|
||||
installationId: 'client-2',
|
||||
});
|
||||
|
||||
try {
|
||||
const result1 = new Promise(resolve => {
|
||||
sub1.on('create', object => {
|
||||
resolve({ data: object.get('data'), injected: object.get('injected') });
|
||||
});
|
||||
});
|
||||
const result2 = new Promise(resolve => {
|
||||
sub2.on('create', object => {
|
||||
resolve({ data: object.get('data'), injected: object.get('injected') });
|
||||
});
|
||||
});
|
||||
|
||||
const newObj = new Parse.Object(className);
|
||||
newObj.set('data', 'value');
|
||||
await newObj.save(null, { useMasterKey: true });
|
||||
|
||||
const [r1, r2] = await Promise.all([result1, result2]);
|
||||
|
||||
expect(r1.data).toBe('value');
|
||||
expect(r2.data).toBe('value');
|
||||
expect(r1.injected).toBe('for-client-1');
|
||||
expect(r2.injected).toBe('for-client-2');
|
||||
expect(r1.injected).not.toBe(r2.injected);
|
||||
} finally {
|
||||
client1.close();
|
||||
client2.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('(GHSA-pfj7-wv7c-22pr) AuthData subset validation bypass with allowExpiredAuthDataToken', () => {
|
||||
let validatorSpy;
|
||||
|
||||
|
||||
@@ -206,6 +206,8 @@ class ParseLiveQueryServer {
|
||||
continue;
|
||||
}
|
||||
requestIds.forEach(async requestId => {
|
||||
// Deep-clone shared object so each concurrent callback works on its own copy
|
||||
let localDeletedParseObject = JSON.parse(JSON.stringify(deletedParseObject));
|
||||
const acl = message.currentParseObject.getACL();
|
||||
// Check CLP
|
||||
const op = this._getCLPOperation(subscription.query);
|
||||
@@ -228,7 +230,7 @@ class ParseLiveQueryServer {
|
||||
res = {
|
||||
event: 'delete',
|
||||
sessionToken: client.sessionToken,
|
||||
object: deletedParseObject,
|
||||
object: localDeletedParseObject,
|
||||
clients: this.clients.size,
|
||||
subscriptions: this.subscriptions.size,
|
||||
useMasterKey: client.hasMasterKey,
|
||||
@@ -250,9 +252,9 @@ class ParseLiveQueryServer {
|
||||
return;
|
||||
}
|
||||
if (res.object && typeof res.object.toJSON === 'function') {
|
||||
deletedParseObject = toJSONwithObjects(res.object, res.object.className || className);
|
||||
localDeletedParseObject = toJSONwithObjects(res.object, res.object.className || className);
|
||||
}
|
||||
res.object = deletedParseObject;
|
||||
res.object = localDeletedParseObject;
|
||||
await this._filterSensitiveData(
|
||||
classLevelPermissions,
|
||||
res,
|
||||
@@ -261,8 +263,7 @@ class ParseLiveQueryServer {
|
||||
op,
|
||||
subscription.query
|
||||
);
|
||||
deletedParseObject = res.object;
|
||||
client.pushDelete(requestId, deletedParseObject);
|
||||
client.pushDelete(requestId, res.object);
|
||||
} catch (e) {
|
||||
const error = resolveError(e);
|
||||
Client.pushError(client.parseWebSocket, error.code, error.message, false, requestId);
|
||||
@@ -318,6 +319,13 @@ class ParseLiveQueryServer {
|
||||
continue;
|
||||
}
|
||||
requestIds.forEach(async requestId => {
|
||||
// Deep-clone shared objects so each concurrent callback works on its own copy.
|
||||
// Without cloning, _filterSensitiveData's in-place field deletion and afterEvent
|
||||
// trigger modifications corrupt the shared state across concurrent subscribers.
|
||||
let localCurrentParseObject = JSON.parse(JSON.stringify(currentParseObject));
|
||||
let localOriginalParseObject = originalParseObject
|
||||
? JSON.parse(JSON.stringify(originalParseObject))
|
||||
: null;
|
||||
// Set orignal ParseObject ACL checking promise, if the object does not match
|
||||
// subscription, we do not need to check ACL
|
||||
let originalACLCheckingPromise;
|
||||
@@ -358,8 +366,8 @@ class ParseLiveQueryServer {
|
||||
]);
|
||||
logger.verbose(
|
||||
'Original %j | Current %j | Match: %s, %s, %s, %s | Query: %s',
|
||||
originalParseObject,
|
||||
currentParseObject,
|
||||
localOriginalParseObject,
|
||||
localCurrentParseObject,
|
||||
isOriginalSubscriptionMatched,
|
||||
isCurrentSubscriptionMatched,
|
||||
isOriginalMatched,
|
||||
@@ -373,7 +381,7 @@ class ParseLiveQueryServer {
|
||||
} else if (isOriginalMatched && !isCurrentMatched) {
|
||||
type = 'leave';
|
||||
} else if (!isOriginalMatched && isCurrentMatched) {
|
||||
if (originalParseObject) {
|
||||
if (localOriginalParseObject) {
|
||||
type = 'enter';
|
||||
} else {
|
||||
type = 'create';
|
||||
@@ -388,8 +396,8 @@ class ParseLiveQueryServer {
|
||||
res = {
|
||||
event: type,
|
||||
sessionToken: client.sessionToken,
|
||||
object: currentParseObject,
|
||||
original: originalParseObject,
|
||||
object: localCurrentParseObject,
|
||||
original: localOriginalParseObject,
|
||||
clients: this.clients.size,
|
||||
subscriptions: this.subscriptions.size,
|
||||
useMasterKey: client.hasMasterKey,
|
||||
@@ -414,16 +422,16 @@ class ParseLiveQueryServer {
|
||||
return;
|
||||
}
|
||||
if (res.object && typeof res.object.toJSON === 'function') {
|
||||
currentParseObject = toJSONwithObjects(res.object, res.object.className || className);
|
||||
localCurrentParseObject = toJSONwithObjects(res.object, res.object.className || className);
|
||||
}
|
||||
if (res.original && typeof res.original.toJSON === 'function') {
|
||||
originalParseObject = toJSONwithObjects(
|
||||
localOriginalParseObject = toJSONwithObjects(
|
||||
res.original,
|
||||
res.original.className || className
|
||||
);
|
||||
}
|
||||
res.object = currentParseObject;
|
||||
res.original = originalParseObject;
|
||||
res.object = localCurrentParseObject;
|
||||
res.original = localOriginalParseObject;
|
||||
await this._filterSensitiveData(
|
||||
classLevelPermissions,
|
||||
res,
|
||||
@@ -432,11 +440,9 @@ class ParseLiveQueryServer {
|
||||
op,
|
||||
subscription.query
|
||||
);
|
||||
currentParseObject = res.object;
|
||||
originalParseObject = res.original ?? null;
|
||||
const functionName = 'push' + res.event.charAt(0).toUpperCase() + res.event.slice(1);
|
||||
if (client[functionName]) {
|
||||
client[functionName](requestId, currentParseObject, originalParseObject);
|
||||
client[functionName](requestId, res.object, res.original ?? null);
|
||||
}
|
||||
} catch (e) {
|
||||
const error = resolveError(e);
|
||||
@@ -764,7 +770,9 @@ class ParseLiveQueryServer {
|
||||
return;
|
||||
}
|
||||
let protectedFields = classLevelPermissions?.protectedFields || [];
|
||||
if (!client.hasMasterKey && !Array.isArray(protectedFields)) {
|
||||
if (client.hasMasterKey) {
|
||||
protectedFields = [];
|
||||
} else if (!Array.isArray(protectedFields)) {
|
||||
protectedFields = getDatabaseController(this.config).addProtectedFields(
|
||||
classLevelPermissions,
|
||||
res.object.className,
|
||||
|
||||
Reference in New Issue
Block a user