fix: LiveQuery discloses object data to a subscriber across an ACL read-access change ([GHSA-97pr-9hgg-3p8r](https://github.com/parse-community/parse-server/security/advisories/GHSA-97pr-9hgg-3p8r)) (#10515)

This commit is contained in:
Manuel
2026-06-19 02:13:55 +02:00
committed by GitHub
parent 3de7aa3fd1
commit e9c85dfe40
2 changed files with 219 additions and 0 deletions
+190
View File
@@ -1815,3 +1815,193 @@ describe('ParseLiveQuery cross-origin connection authorization', function () {
expect(attacker.createdIds()).toEqual([publicObj.id, publicObj2.id]); expect(attacker.createdIds()).toEqual([publicObj.id, publicObj2.id]);
}); });
}); });
describe('ParseLiveQuery ACL transition disclosure', function () {
const WebSocket = require('ws');
const waitFor = async predicate => {
const deadline = Date.now() + 6000;
while (Date.now() < deadline) {
if (predicate()) {
return;
}
await sleep(20);
}
throw new Error('timed out waiting for condition');
};
let sockets;
beforeEach(() => {
Parse.CoreManager.getLiveQueryController().setDefaultLiveQueryClient(null);
sockets = [];
});
afterEach(() => {
for (const socket of sockets) {
if (socket.readyState === WebSocket.OPEN) {
socket.close();
}
}
sockets = [];
});
// Opens a raw LiveQuery WebSocket client authenticated with the given session
// token so the exact wire payload of each event can be asserted directly.
const openClient = async sessionToken => {
const socket = new WebSocket('ws://localhost:8378/1');
sockets.push(socket);
const messages = [];
socket.on('message', data => messages.push(JSON.parse(data.toString())));
await new Promise((resolve, reject) => {
socket.on('open', resolve);
socket.on('error', reject);
});
socket.send(
JSON.stringify({ op: 'connect', applicationId: Parse.applicationId, sessionToken })
);
const client = {
socket,
messages,
subscribe(requestId, className, where) {
socket.send(
JSON.stringify({ op: 'subscribe', requestId, query: { className, where }, sessionToken })
);
},
messagesForOp(op) {
return messages.filter(message => message.op === op);
},
waitForOpCount(op, count) {
return waitFor(() => this.messagesForOp(op).length >= count);
},
};
await waitFor(() => messages.some(message => message.op === 'connected'));
return client;
};
it('does not leak the post-revocation object body in a leave event when a save revokes the subscriber ACL read access', async () => {
await reconfigureServer({
liveQuery: { classNames: ['TestObject'] },
startLiveQueryServer: true,
verbose: false,
silent: true,
});
const user = new Parse.User();
user.setUsername('leave-acl-user');
user.setPassword('password');
await user.signUp();
// Object readable by the user, with an initial value.
const obj = new Parse.Object('TestObject');
const acl = new Parse.ACL();
acl.setPublicReadAccess(false);
acl.setReadAccess(user, true);
obj.setACL(acl);
obj.set('secretField', 'INITIAL');
await obj.save(null, { useMasterKey: true });
const client = await openClient(user.getSessionToken());
client.subscribe(1, 'TestObject', {});
await client.waitForOpCount('subscribed', 1);
// Control update: keep the user's ACL read access, only change the field. The
// user is still authorized and receives the new value via an update event.
await obj.save({ secretField: 'BENIGN_VISIBLE' }, { useMasterKey: true });
await client.waitForOpCount('update', 1);
expect(client.messagesForOp('update')[0].object.secretField).toBe('BENIGN_VISIBLE');
// Attack update: change the field AND remove the user's read access in the same save.
const revokedACL = new Parse.ACL();
revokedACL.setPublicReadAccess(false);
obj.setACL(revokedACL);
obj.set('secretField', 'POST_REVOCATION_SECRET');
await obj.save(null, { useMasterKey: true });
await client.waitForOpCount('leave', 1);
const leave = client.messagesForOp('leave')[0];
// The subscriber must not receive the post-revocation value they can no longer read.
expect(leave.object.secretField).not.toBe('POST_REVOCATION_SECRET');
// They receive the last value they were authorized to see.
expect(leave.object.secretField).toBe('BENIGN_VISIBLE');
});
it('does not leak the pre-grant original object body in an enter event when a save grants the subscriber ACL read access', async () => {
await reconfigureServer({
liveQuery: { classNames: ['TestObject'] },
startLiveQueryServer: true,
verbose: false,
silent: true,
});
const user = new Parse.User();
user.setUsername('enter-acl-user');
user.setPassword('password');
await user.signUp();
// Object NOT readable by the user, with a pre-grant value.
const obj = new Parse.Object('TestObject');
const noAccessACL = new Parse.ACL();
noAccessACL.setPublicReadAccess(false);
obj.setACL(noAccessACL);
obj.set('secretField', 'PRE_GRANT_SECRET');
await obj.save(null, { useMasterKey: true });
const client = await openClient(user.getSessionToken());
client.subscribe(1, 'TestObject', {});
await client.waitForOpCount('subscribed', 1);
// Grant update: change the field AND add the user's read access in the same save.
const grantedACL = new Parse.ACL();
grantedACL.setPublicReadAccess(false);
grantedACL.setReadAccess(user, true);
obj.setACL(grantedACL);
obj.set('secretField', 'GRANTED_VALUE');
await obj.save(null, { useMasterKey: true });
await client.waitForOpCount('enter', 1);
const enter = client.messagesForOp('enter')[0];
// The current (now-authorized) value is delivered.
expect(enter.object.secretField).toBe('GRANTED_VALUE');
// The pre-grant state the user was never authorized to read must not be delivered.
expect(enter.original).toBeUndefined();
});
it('still delivers the current object in a leave event caused by a query mismatch when the subscriber retains read access', async () => {
await reconfigureServer({
liveQuery: { classNames: ['TestObject'] },
startLiveQueryServer: true,
verbose: false,
silent: true,
});
const user = new Parse.User();
user.setUsername('leave-query-user');
user.setPassword('password');
await user.signUp();
// Object readable by the user that matches the subscription query.
const obj = new Parse.Object('TestObject');
const acl = new Parse.ACL();
acl.setPublicReadAccess(false);
acl.setReadAccess(user, true);
obj.setACL(acl);
obj.set('status', 'active');
obj.set('secretField', 'INITIAL');
await obj.save(null, { useMasterKey: true });
const client = await openClient(user.getSessionToken());
client.subscribe(1, 'TestObject', { status: 'active' });
await client.waitForOpCount('subscribed', 1);
// Update the field so the object no longer matches the query (query-mismatch leave)
// while preserving the user's ACL read access. The user is still authorized to read
// the current object, so the current state is delivered as designed.
await obj.save({ status: 'archived', secretField: 'VISIBLE_NEW' }, { useMasterKey: true });
await client.waitForOpCount('leave', 1);
const leave = client.messagesForOp('leave')[0];
expect(leave.object.status).toBe('archived');
expect(leave.object.secretField).toBe('VISIBLE_NEW');
});
});
+29
View File
@@ -393,6 +393,35 @@ class ParseLiveQueryServer {
if (!watchFieldsChanged && (type === 'update' || type === 'create')) { if (!watchFieldsChanged && (type === 'update' || type === 'create')) {
return; return;
} }
// A `leave` or `enter` transition can be caused either by the object's
// query match changing (the subscriber keeps read access) or by the
// subscriber's ACL read access being revoked or granted in the same save.
// In the access-change case the subscriber is not authorized to read the
// object state that triggered the transition, so that state must not be
// sent over the channel. (CLP read denial is handled earlier by
// `_matchesCLP`, which skips the event entirely.)
if (type === 'leave') {
// The post-update object is readable on a query-mismatch leave but not
// on an ACL-loss leave. Only send the post-update body when the
// subscriber can still read the current object; otherwise fall back to
// the last authorized (original) state, which still carries the objectId.
const currentReadable = isCurrentSubscriptionMatched
? false
: await this._matchesACL(message.currentParseObject.getACL(), client, requestId);
if (!currentReadable) {
localCurrentParseObject = JSON.parse(JSON.stringify(localOriginalParseObject));
}
} else if (type === 'enter') {
// The pre-update object was readable on a query-match-gain enter but not
// on an ACL-grant enter. Only send the pre-update body as `original`
// when the subscriber could read the original object.
const originalReadable = isOriginalSubscriptionMatched
? false
: await this._matchesACL(message.originalParseObject.getACL(), client, requestId);
if (!originalReadable) {
localOriginalParseObject = null;
}
}
res = { res = {
event: type, event: type,
sessionToken: client.sessionToken, sessionToken: client.sessionToken,