Skip to content

Commit 7b6335f

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

File tree

4 files changed

+152
-12
lines changed

4 files changed

+152
-12
lines changed

spec/vulnerabilities.spec.js

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -842,6 +842,126 @@ describe('Vulnerabilities', () => {
842842
await expectAsync(obj.save()).toBeResolved();
843843
});
844844
});
845+
846+
describe('(GHSA-mmg8-87c5-jrc2) LiveQuery protected-field guard bypass via array-like $or/$and/$nor', () => {
847+
const { sleep } = require('../lib/TestUtils');
848+
let obj;
849+
850+
beforeEach(async () => {
851+
Parse.CoreManager.getLiveQueryController().setDefaultLiveQueryClient(null);
852+
await reconfigureServer({
853+
liveQuery: { classNames: ['SecretClass'] },
854+
startLiveQueryServer: true,
855+
verbose: false,
856+
silent: true,
857+
});
858+
const config = Config.get(Parse.applicationId);
859+
const schemaController = await config.database.loadSchema();
860+
await schemaController.addClassIfNotExists(
861+
'SecretClass',
862+
{ secretObj: { type: 'Object' }, publicField: { type: 'String' } },
863+
);
864+
await schemaController.updateClass(
865+
'SecretClass',
866+
{},
867+
{
868+
find: { '*': true },
869+
get: { '*': true },
870+
create: { '*': true },
871+
update: { '*': true },
872+
delete: { '*': true },
873+
addField: {},
874+
protectedFields: { '*': ['secretObj'] },
875+
}
876+
);
877+
878+
obj = new Parse.Object('SecretClass');
879+
obj.set('secretObj', { apiKey: 'SENSITIVE_KEY_123', score: 42 });
880+
obj.set('publicField', 'visible');
881+
await obj.save(null, { useMasterKey: true });
882+
});
883+
884+
afterEach(async () => {
885+
const client = await Parse.CoreManager.getLiveQueryController().getDefaultLiveQueryClient();
886+
if (client) {
887+
await client.close();
888+
}
889+
});
890+
891+
it('should reject subscription with array-like $or containing protected field', async () => {
892+
const query = new Parse.Query('SecretClass');
893+
query._where = {
894+
$or: { '0': { 'secretObj.apiKey': 'SENSITIVE_KEY_123' }, length: 1 },
895+
};
896+
await expectAsync(query.subscribe()).toBeRejectedWith(
897+
jasmine.objectContaining({ code: Parse.Error.INVALID_QUERY })
898+
);
899+
});
900+
901+
it('should reject subscription with array-like $and containing protected field', async () => {
902+
const query = new Parse.Query('SecretClass');
903+
query._where = {
904+
$and: { '0': { 'secretObj.apiKey': 'SENSITIVE_KEY_123' }, '1': { publicField: 'visible' }, length: 2 },
905+
};
906+
await expectAsync(query.subscribe()).toBeRejectedWith(
907+
jasmine.objectContaining({ code: Parse.Error.INVALID_QUERY })
908+
);
909+
});
910+
911+
it('should reject subscription with array-like $nor containing protected field', async () => {
912+
const query = new Parse.Query('SecretClass');
913+
query._where = {
914+
$nor: { '0': { 'secretObj.apiKey': 'SENSITIVE_KEY_123' }, length: 1 },
915+
};
916+
await expectAsync(query.subscribe()).toBeRejectedWith(
917+
jasmine.objectContaining({ code: Parse.Error.INVALID_QUERY })
918+
);
919+
});
920+
921+
it('should reject subscription with array-like $or even on non-protected fields', async () => {
922+
const query = new Parse.Query('SecretClass');
923+
query._where = {
924+
$or: { '0': { publicField: 'visible' }, length: 1 },
925+
};
926+
await expectAsync(query.subscribe()).toBeRejectedWith(
927+
jasmine.objectContaining({ code: Parse.Error.INVALID_QUERY })
928+
);
929+
});
930+
931+
it('should not create oracle via array-like $or bypass on protected fields', async () => {
932+
const query = new Parse.Query('SecretClass');
933+
query._where = {
934+
$or: { '0': { 'secretObj.apiKey': 'SENSITIVE_KEY_123' }, length: 1 },
935+
};
936+
937+
// Subscription must be rejected; no event oracle should be possible
938+
let subscriptionError;
939+
let subscription;
940+
try {
941+
subscription = await query.subscribe();
942+
} catch (e) {
943+
subscriptionError = e;
944+
}
945+
946+
if (!subscriptionError) {
947+
const updateSpy = jasmine.createSpy('update');
948+
subscription.on('create', updateSpy);
949+
subscription.on('update', updateSpy);
950+
951+
// Trigger an object change
952+
obj.set('publicField', 'changed');
953+
await obj.save(null, { useMasterKey: true });
954+
await sleep(500);
955+
956+
// If subscription somehow accepted, verify no events fired (evaluator defense)
957+
expect(updateSpy).not.toHaveBeenCalled();
958+
fail('Expected subscription to be rejected');
959+
}
960+
expect(subscriptionError).toEqual(
961+
jasmine.objectContaining({ code: Parse.Error.INVALID_QUERY })
962+
);
963+
});
964+
});
845965
});
846966

847967
describe('(GHSA-mf3j-86qx-cq5j) ReDoS via $regex in LiveQuery subscription', () => {

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)