Skip to content

Commit bfc8812

Browse files
committed
fix: LiveQuery protected-field guard bypass via array-like object
1 parent 9702abd commit bfc8812

4 files changed

Lines changed: 152 additions & 12 deletions

File tree

spec/vulnerabilities.spec.js

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4828,3 +4828,123 @@ describe('(GHSA-wp76-gg32-8258) /verifyPassword leaks raw authData via missing a
48284828
});
48294829
});
48304830
});
4831+
4832+
describe('(GHSA-mmg8-87c5-jrc2) LiveQuery protected-field guard bypass via array-like $or/$and/$nor', () => {
4833+
const { sleep } = require('../lib/TestUtils');
4834+
let obj;
4835+
4836+
beforeEach(async () => {
4837+
Parse.CoreManager.getLiveQueryController().setDefaultLiveQueryClient(null);
4838+
await reconfigureServer({
4839+
liveQuery: { classNames: ['SecretClass'] },
4840+
startLiveQueryServer: true,
4841+
verbose: false,
4842+
silent: true,
4843+
});
4844+
const config = Config.get(Parse.applicationId);
4845+
const schemaController = await config.database.loadSchema();
4846+
await schemaController.addClassIfNotExists(
4847+
'SecretClass',
4848+
{ secretObj: { type: 'Object' }, publicField: { type: 'String' } },
4849+
);
4850+
await schemaController.updateClass(
4851+
'SecretClass',
4852+
{},
4853+
{
4854+
find: { '*': true },
4855+
get: { '*': true },
4856+
create: { '*': true },
4857+
update: { '*': true },
4858+
delete: { '*': true },
4859+
addField: {},
4860+
protectedFields: { '*': ['secretObj'] },
4861+
}
4862+
);
4863+
4864+
obj = new Parse.Object('SecretClass');
4865+
obj.set('secretObj', { apiKey: 'SENSITIVE_KEY_123', score: 42 });
4866+
obj.set('publicField', 'visible');
4867+
await obj.save(null, { useMasterKey: true });
4868+
});
4869+
4870+
afterEach(async () => {
4871+
const client = await Parse.CoreManager.getLiveQueryController().getDefaultLiveQueryClient();
4872+
if (client) {
4873+
await client.close();
4874+
}
4875+
});
4876+
4877+
it('should reject subscription with array-like $or containing protected field', async () => {
4878+
const query = new Parse.Query('SecretClass');
4879+
query._where = {
4880+
$or: { '0': { 'secretObj.apiKey': 'SENSITIVE_KEY_123' }, length: 1 },
4881+
};
4882+
await expectAsync(query.subscribe()).toBeRejectedWith(
4883+
jasmine.objectContaining({ code: Parse.Error.INVALID_QUERY })
4884+
);
4885+
});
4886+
4887+
it('should reject subscription with array-like $and containing protected field', async () => {
4888+
const query = new Parse.Query('SecretClass');
4889+
query._where = {
4890+
$and: { '0': { 'secretObj.apiKey': 'SENSITIVE_KEY_123' }, '1': { publicField: 'visible' }, length: 2 },
4891+
};
4892+
await expectAsync(query.subscribe()).toBeRejectedWith(
4893+
jasmine.objectContaining({ code: Parse.Error.INVALID_QUERY })
4894+
);
4895+
});
4896+
4897+
it('should reject subscription with array-like $nor containing protected field', async () => {
4898+
const query = new Parse.Query('SecretClass');
4899+
query._where = {
4900+
$nor: { '0': { 'secretObj.apiKey': 'SENSITIVE_KEY_123' }, length: 1 },
4901+
};
4902+
await expectAsync(query.subscribe()).toBeRejectedWith(
4903+
jasmine.objectContaining({ code: Parse.Error.INVALID_QUERY })
4904+
);
4905+
});
4906+
4907+
it('should reject subscription with array-like $or even on non-protected fields', async () => {
4908+
const query = new Parse.Query('SecretClass');
4909+
query._where = {
4910+
$or: { '0': { publicField: 'visible' }, length: 1 },
4911+
};
4912+
await expectAsync(query.subscribe()).toBeRejectedWith(
4913+
jasmine.objectContaining({ code: Parse.Error.INVALID_QUERY })
4914+
);
4915+
});
4916+
4917+
it('should not create oracle via array-like $or bypass on protected fields', async () => {
4918+
const query = new Parse.Query('SecretClass');
4919+
query._where = {
4920+
$or: { '0': { 'secretObj.apiKey': 'SENSITIVE_KEY_123' }, length: 1 },
4921+
};
4922+
4923+
// Subscription must be rejected; no event oracle should be possible
4924+
let subscriptionError;
4925+
let subscription;
4926+
try {
4927+
subscription = await query.subscribe();
4928+
} catch (e) {
4929+
subscriptionError = e;
4930+
}
4931+
4932+
if (!subscriptionError) {
4933+
const updateSpy = jasmine.createSpy('update');
4934+
subscription.on('create', updateSpy);
4935+
subscription.on('update', updateSpy);
4936+
4937+
// Trigger an object change
4938+
obj.set('publicField', 'changed');
4939+
await obj.save(null, { useMasterKey: true });
4940+
await sleep(500);
4941+
4942+
// If subscription somehow accepted, verify no events fired (evaluator defense)
4943+
expect(updateSpy).not.toHaveBeenCalled();
4944+
fail('Expected subscription to be rejected');
4945+
}
4946+
expect(subscriptionError).toEqual(
4947+
jasmine.objectContaining({ code: Parse.Error.INVALID_QUERY })
4948+
);
4949+
});
4950+
});

src/LiveQuery/ParseLiveQueryServer.ts

Lines changed: 16 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -555,6 +555,16 @@ class ParseLiveQueryServer {
555555
if (typeof where !== 'object' || where === null) {
556556
return;
557557
}
558+
for (const op of ['$or', '$and', '$nor']) {
559+
if (where[op] !== undefined && !Array.isArray(where[op])) {
560+
throw new Parse.Error(Parse.Error.INVALID_QUERY, `${op} must be an array`);
561+
}
562+
if (Array.isArray(where[op])) {
563+
where[op].forEach((subQuery: any) => {
564+
this._validateQueryConstraints(subQuery);
565+
});
566+
}
567+
}
558568
for (const key of Object.keys(where)) {
559569
const constraint = where[key];
560570
if (typeof constraint === 'object' && constraint !== null) {
@@ -582,18 +592,6 @@ class ParseLiveQueryServer {
582592
);
583593
}
584594
}
585-
for (const op of ['$or', '$and', '$nor']) {
586-
if (Array.isArray(constraint[op])) {
587-
constraint[op].forEach((subQuery: any) => {
588-
this._validateQueryConstraints(subQuery);
589-
});
590-
}
591-
}
592-
if (Array.isArray(where[key])) {
593-
where[key].forEach((subQuery: any) => {
594-
this._validateQueryConstraints(subQuery);
595-
});
596-
}
597595
}
598596
}
599597
}
@@ -1048,6 +1046,9 @@ class ParseLiveQueryServer {
10481046
return;
10491047
}
10501048
for (const op of ['$or', '$and', '$nor']) {
1049+
if (where[op] !== undefined && !Array.isArray(where[op])) {
1050+
throw new Parse.Error(Parse.Error.INVALID_QUERY, `${op} must be an array`);
1051+
}
10511052
if (Array.isArray(where[op])) {
10521053
for (const subQuery of where[op]) {
10531054
checkDepth(subQuery, depth + 1);
@@ -1111,6 +1112,9 @@ class ParseLiveQueryServer {
11111112
}
11121113
}
11131114
for (const op of ['$or', '$and', '$nor']) {
1115+
if (where[op] !== undefined && !Array.isArray(where[op])) {
1116+
throw new Parse.Error(Parse.Error.INVALID_QUERY, `${op} must be an array`);
1117+
}
11141118
if (Array.isArray(where[op])) {
11151119
where[op].forEach((subQuery: any) => checkWhere(subQuery));
11161120
}

src/LiveQuery/QueryTools.js

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,9 @@ function matchesKeyConstraints(object, key, constraints) {
206206
}
207207
var i;
208208
if (key === '$or') {
209+
if (!Array.isArray(constraints)) {
210+
return false;
211+
}
209212
for (i = 0; i < constraints.length; i++) {
210213
if (matchesQuery(object, constraints[i])) {
211214
return true;
@@ -214,6 +217,9 @@ function matchesKeyConstraints(object, key, constraints) {
214217
return false;
215218
}
216219
if (key === '$and') {
220+
if (!Array.isArray(constraints)) {
221+
return false;
222+
}
217223
for (i = 0; i < constraints.length; i++) {
218224
if (!matchesQuery(object, constraints[i])) {
219225
return false;
@@ -222,6 +228,9 @@ function matchesKeyConstraints(object, key, constraints) {
222228
return true;
223229
}
224230
if (key === '$nor') {
231+
if (!Array.isArray(constraints)) {
232+
return false;
233+
}
225234
for (i = 0; i < constraints.length; i++) {
226235
if (matchesQuery(object, constraints[i])) {
227236
return false;

src/RestQuery.js

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -904,6 +904,13 @@ _UnsafeRestQuery.prototype.denyProtectedFields = async function () {
904904
}
905905
}
906906
for (const op of ['$or', '$and', '$nor']) {
907+
if (where[op] !== undefined && !Array.isArray(where[op])) {
908+
throw createSanitizedError(
909+
Parse.Error.INVALID_QUERY,
910+
`${op} must be an array`,
911+
this.config
912+
);
913+
}
907914
if (Array.isArray(where[op])) {
908915
where[op].forEach(subQuery => checkWhere(subQuery));
909916
}

0 commit comments

Comments
 (0)