Skip to content

Commit 0ae0eee

Browse files
authored
fix: LiveQuery subscription with invalid regular expression crashes server ([GHSA-827p-g5x5-h86c](GHSA-827p-g5x5-h86c)) (#10197)
1 parent 0cef831 commit 0ae0eee

File tree

4 files changed

+208
-25
lines changed

4 files changed

+208
-25
lines changed

spec/ParseLiveQuery.spec.js

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -646,6 +646,82 @@ describe('ParseLiveQuery', function () {
646646
);
647647
});
648648

649+
it('rejects subscription with invalid $regex pattern', async () => {
650+
await reconfigureServer({
651+
liveQuery: {
652+
classNames: ['TestObject'],
653+
},
654+
startLiveQueryServer: true,
655+
verbose: false,
656+
silent: true,
657+
});
658+
659+
const query = new Parse.Query('TestObject');
660+
query._where = { foo: { $regex: '[invalid' } };
661+
await expectAsync(query.subscribe()).toBeRejectedWithError(/Invalid regular expression/);
662+
});
663+
664+
it('rejects subscription with non-string $regex value', async () => {
665+
await reconfigureServer({
666+
liveQuery: {
667+
classNames: ['TestObject'],
668+
},
669+
startLiveQueryServer: true,
670+
verbose: false,
671+
silent: true,
672+
});
673+
674+
const query = new Parse.Query('TestObject');
675+
query._where = { foo: { $regex: 123 } };
676+
await expectAsync(query.subscribe()).toBeRejectedWithError(
677+
/\$regex must be a string or RegExp/
678+
);
679+
});
680+
681+
it('does not crash server when subscription matching throws and other subscriptions still work', async () => {
682+
const server = await reconfigureServer({
683+
liveQuery: {
684+
classNames: ['TestObject'],
685+
},
686+
startLiveQueryServer: true,
687+
verbose: false,
688+
silent: true,
689+
});
690+
691+
const object = new TestObject();
692+
object.set('foo', 'bar');
693+
await object.save();
694+
695+
// Create a valid subscription
696+
const validQuery = new Parse.Query('TestObject');
697+
validQuery.equalTo('objectId', object.id);
698+
const validSubscription = await validQuery.subscribe();
699+
700+
// Inject a malformed subscription directly into the LiveQuery server
701+
// to bypass subscribe-time validation and test the try-catch in _onAfterSave
702+
const lqServer = server.liveQueryServer;
703+
const Subscription = require('../lib/LiveQuery/Subscription').Subscription;
704+
const badSubscription = new Subscription('TestObject', { foo: { $regex: '[invalid' } });
705+
badSubscription.addClientSubscription('fakeClientId', 'fakeRequestId');
706+
const classSubscriptions = lqServer.subscriptions.get('TestObject');
707+
classSubscriptions.set('bad-hash', badSubscription);
708+
709+
// Verify the valid subscription still receives updates despite the bad subscription
710+
const updatePromise = new Promise(resolve => {
711+
validSubscription.on('update', obj => {
712+
expect(obj.get('foo')).toBe('baz');
713+
resolve();
714+
});
715+
});
716+
717+
object.set('foo', 'baz');
718+
await object.save();
719+
await updatePromise;
720+
721+
// Clean up the injected subscription
722+
classSubscriptions.delete('bad-hash');
723+
});
724+
649725
it('can handle mutate beforeSubscribe query', async done => {
650726
await reconfigureServer({
651727
liveQuery: {

spec/QueryTools.spec.js

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -567,6 +567,49 @@ describe('matchesQuery', function () {
567567
expect(config.liveQuery.regexTimeout).toBe(100);
568568
});
569569

570+
it('does not throw on invalid $regex pattern', function () {
571+
const player = {
572+
id: new Id('Player', 'P1'),
573+
name: 'Player 1',
574+
};
575+
576+
// Invalid regex syntax should not throw, just return false
577+
const q = new Parse.Query('Player');
578+
q._where = { name: { $regex: '[invalid' } };
579+
expect(() => matchesQuery(player, q)).not.toThrow();
580+
expect(matchesQuery(player, q)).toBe(false);
581+
});
582+
583+
it('does not throw on invalid $regex pattern with regexTimeout enabled', function () {
584+
const { setRegexTimeout } = require('../lib/LiveQuery/QueryTools');
585+
setRegexTimeout(100);
586+
try {
587+
const player = {
588+
id: new Id('Player', 'P1'),
589+
name: 'Player 1',
590+
};
591+
592+
const q = new Parse.Query('Player');
593+
q._where = { name: { $regex: '[invalid' } };
594+
expect(() => matchesQuery(player, q)).not.toThrow();
595+
expect(matchesQuery(player, q)).toBe(false);
596+
} finally {
597+
setRegexTimeout(0);
598+
}
599+
});
600+
601+
it('does not throw on invalid $regex flags', function () {
602+
const player = {
603+
id: new Id('Player', 'P1'),
604+
name: 'Player 1',
605+
};
606+
607+
const q = new Parse.Query('Player');
608+
q._where = { name: { $regex: 'valid', $options: 'xyz' } };
609+
expect(() => matchesQuery(player, q)).not.toThrow();
610+
expect(matchesQuery(player, q)).toBe(false);
611+
});
612+
570613
it('matches $nearSphere queries', function () {
571614
let q = new Parse.Query('Checkin');
572615
q.near('location', new Parse.GeoPoint(20, 20));

src/LiveQuery/ParseLiveQueryServer.ts

Lines changed: 72 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -190,7 +190,13 @@ class ParseLiveQueryServer {
190190
}
191191

192192
for (const subscription of classSubscriptions.values()) {
193-
const isSubscriptionMatched = this._matchesSubscription(deletedParseObject, subscription);
193+
let isSubscriptionMatched;
194+
try {
195+
isSubscriptionMatched = this._matchesSubscription(deletedParseObject, subscription);
196+
} catch (e) {
197+
logger.error(`Failed matching subscription for class ${className}: ${e.message}`);
198+
continue;
199+
}
194200
if (!isSubscriptionMatched) {
195201
continue;
196202
}
@@ -286,14 +292,21 @@ class ParseLiveQueryServer {
286292
return;
287293
}
288294
for (const subscription of classSubscriptions.values()) {
289-
const isOriginalSubscriptionMatched = this._matchesSubscription(
290-
originalParseObject,
291-
subscription
292-
);
293-
const isCurrentSubscriptionMatched = this._matchesSubscription(
294-
currentParseObject,
295-
subscription
296-
);
295+
let isOriginalSubscriptionMatched;
296+
let isCurrentSubscriptionMatched;
297+
try {
298+
isOriginalSubscriptionMatched = this._matchesSubscription(
299+
originalParseObject,
300+
subscription
301+
);
302+
isCurrentSubscriptionMatched = this._matchesSubscription(
303+
currentParseObject,
304+
subscription
305+
);
306+
} catch (e) {
307+
logger.error(`Failed matching subscription for class ${className}: ${e.message}`);
308+
continue;
309+
}
297310
for (const [clientId, requestIds] of _.entries(subscription.clientRequestIds)) {
298311
const client = this.clients.get(clientId);
299312
if (typeof client === 'undefined') {
@@ -520,6 +533,53 @@ class ParseLiveQueryServer {
520533
});
521534
}
522535

536+
_validateQueryConstraints(where: any): void {
537+
if (typeof where !== 'object' || where === null) {
538+
return;
539+
}
540+
for (const key of Object.keys(where)) {
541+
const constraint = where[key];
542+
if (typeof constraint === 'object' && constraint !== null) {
543+
if (constraint.$regex !== undefined) {
544+
const regex = constraint.$regex;
545+
const isRegExpLike =
546+
regex !== null &&
547+
typeof regex === 'object' &&
548+
typeof regex.source === 'string' &&
549+
typeof regex.flags === 'string';
550+
if (typeof regex !== 'string' && !isRegExpLike) {
551+
throw new Parse.Error(
552+
Parse.Error.INVALID_QUERY,
553+
'Invalid regular expression: $regex must be a string or RegExp'
554+
);
555+
}
556+
const pattern = isRegExpLike ? regex.source : regex;
557+
const flags = isRegExpLike ? regex.flags : constraint.$options || '';
558+
try {
559+
new RegExp(pattern, flags);
560+
} catch (e) {
561+
throw new Parse.Error(
562+
Parse.Error.INVALID_QUERY,
563+
`Invalid regular expression: ${e.message}`
564+
);
565+
}
566+
}
567+
for (const op of ['$or', '$and', '$nor']) {
568+
if (Array.isArray(constraint[op])) {
569+
constraint[op].forEach((subQuery: any) => {
570+
this._validateQueryConstraints(subQuery);
571+
});
572+
}
573+
}
574+
if (Array.isArray(where[key])) {
575+
where[key].forEach((subQuery: any) => {
576+
this._validateQueryConstraints(subQuery);
577+
});
578+
}
579+
}
580+
}
581+
}
582+
523583
_matchesSubscription(parseObject: any, subscription: any): boolean {
524584
// Object is undefined or null, not match
525585
if (!parseObject) {
@@ -951,6 +1011,9 @@ class ParseLiveQueryServer {
9511011
}
9521012
}
9531013

1014+
// Validate regex patterns in the subscription query
1015+
this._validateQueryConstraints(request.query.where);
1016+
9541017
// Get subscription from subscriptions, create one if necessary
9551018
const subscriptionHash = queryHash(request.query);
9561019
// Add className to subscriptions if necessary

src/LiveQuery/QueryTools.js

Lines changed: 17 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -14,28 +14,29 @@ function setRegexTimeout(ms) {
1414
}
1515

1616
function safeRegexTest(pattern, flags, input) {
17-
if (!regexTimeout) {
18-
var re = new RegExp(pattern, flags);
19-
return re.test(input);
20-
}
21-
var cacheKey = flags + ':' + pattern;
22-
var script = scriptCache.get(cacheKey);
23-
if (!script) {
24-
if (scriptCache.size >= SCRIPT_CACHE_MAX) { scriptCache.clear(); }
25-
script = new vm.Script('new RegExp(pattern, flags).test(input)');
26-
scriptCache.set(cacheKey, script);
27-
}
28-
vmContext.pattern = pattern;
29-
vmContext.flags = flags;
30-
vmContext.input = input;
3117
try {
18+
if (!regexTimeout) {
19+
var re = new RegExp(pattern, flags);
20+
return re.test(input);
21+
}
22+
var cacheKey = flags + ':' + pattern;
23+
var script = scriptCache.get(cacheKey);
24+
if (!script) {
25+
if (scriptCache.size >= SCRIPT_CACHE_MAX) { scriptCache.clear(); }
26+
script = new vm.Script('new RegExp(pattern, flags).test(input)');
27+
scriptCache.set(cacheKey, script);
28+
}
29+
vmContext.pattern = pattern;
30+
vmContext.flags = flags;
31+
vmContext.input = input;
3232
return script.runInContext(vmContext, { timeout: regexTimeout });
3333
} catch (e) {
3434
if (e.code === 'ERR_SCRIPT_EXECUTION_TIMEOUT') {
3535
logger.warn(`Regex timeout: pattern "${pattern}" with flags "${flags}" exceeded ${regexTimeout}ms limit`);
36-
return false;
36+
} else {
37+
logger.warn(`Invalid regex: pattern "${pattern}" with flags "${flags}": ${e.message}`);
3738
}
38-
throw e;
39+
return false;
3940
}
4041
}
4142

0 commit comments

Comments
 (0)