From 35580b6a62f37c2905c535e81a56cdea42f9c0c5 Mon Sep 17 00:00:00 2001 From: Manuel Trezza <5673677+mtrezza@users.noreply.github.com> Date: Sat, 14 Mar 2026 03:08:02 +0100 Subject: [PATCH 1/2] fix --- spec/ParseLiveQuery.spec.js | 47 ++++++++++++++++++ spec/QueryTools.spec.js | 40 ++++++++++++++++ src/LiveQuery/ParseLiveQueryServer.ts | 69 +++++++++++++++++++++++---- src/LiveQuery/QueryTools.js | 33 ++++++------- 4 files changed, 164 insertions(+), 25 deletions(-) diff --git a/spec/ParseLiveQuery.spec.js b/spec/ParseLiveQuery.spec.js index 48bc1504c8..f257739392 100644 --- a/spec/ParseLiveQuery.spec.js +++ b/spec/ParseLiveQuery.spec.js @@ -646,6 +646,53 @@ describe('ParseLiveQuery', function () { ); }); + it('rejects subscription with invalid $regex pattern', async () => { + await reconfigureServer({ + liveQuery: { + classNames: ['TestObject'], + }, + startLiveQueryServer: true, + verbose: false, + silent: true, + }); + + const query = new Parse.Query('TestObject'); + query._where = { foo: { $regex: '[invalid' } }; + await expectAsync(query.subscribe()).toBeRejectedWithError(/Invalid regular expression/); + }); + + it('does not crash server when subscription has invalid $regex and object is saved', async () => { + await reconfigureServer({ + liveQuery: { + classNames: ['TestObject'], + }, + startLiveQueryServer: true, + verbose: false, + silent: true, + }); + + // Create a valid subscription first + const object = new TestObject(); + object.set('foo', 'bar'); + await object.save(); + + const validQuery = new Parse.Query('TestObject'); + validQuery.equalTo('objectId', object.id); + const validSubscription = await validQuery.subscribe(); + + // Verify valid subscription still works after an object update + const updatePromise = new Promise(resolve => { + validSubscription.on('update', obj => { + expect(obj.get('foo')).toBe('baz'); + resolve(); + }); + }); + + object.set('foo', 'baz'); + await object.save(); + await updatePromise; + }); + it('can handle mutate beforeSubscribe query', async done => { await reconfigureServer({ liveQuery: { diff --git a/spec/QueryTools.spec.js b/spec/QueryTools.spec.js index 8819aadd66..c613f21a6e 100644 --- a/spec/QueryTools.spec.js +++ b/spec/QueryTools.spec.js @@ -567,6 +567,46 @@ describe('matchesQuery', function () { expect(config.liveQuery.regexTimeout).toBe(100); }); + it('does not throw on invalid $regex pattern', function () { + const player = { + id: new Id('Player', 'P1'), + name: 'Player 1', + }; + + // Invalid regex syntax should not throw, just return false + const q = new Parse.Query('Player'); + q._where = { name: { $regex: '[invalid' } }; + expect(() => matchesQuery(player, q)).not.toThrow(); + expect(matchesQuery(player, q)).toBe(false); + }); + + it('does not throw on invalid $regex pattern with regexTimeout enabled', function () { + const { setRegexTimeout } = require('../lib/LiveQuery/QueryTools'); + setRegexTimeout(100); + const player = { + id: new Id('Player', 'P1'), + name: 'Player 1', + }; + + const q = new Parse.Query('Player'); + q._where = { name: { $regex: '[invalid' } }; + expect(() => matchesQuery(player, q)).not.toThrow(); + expect(matchesQuery(player, q)).toBe(false); + setRegexTimeout(0); + }); + + it('does not throw on invalid $regex flags', function () { + const player = { + id: new Id('Player', 'P1'), + name: 'Player 1', + }; + + const q = new Parse.Query('Player'); + q._where = { name: { $regex: 'valid', $options: 'xyz' } }; + expect(() => matchesQuery(player, q)).not.toThrow(); + expect(matchesQuery(player, q)).toBe(false); + }); + it('matches $nearSphere queries', function () { let q = new Parse.Query('Checkin'); q.near('location', new Parse.GeoPoint(20, 20)); diff --git a/src/LiveQuery/ParseLiveQueryServer.ts b/src/LiveQuery/ParseLiveQueryServer.ts index 3deaa0e7b2..5a2dff2727 100644 --- a/src/LiveQuery/ParseLiveQueryServer.ts +++ b/src/LiveQuery/ParseLiveQueryServer.ts @@ -190,7 +190,13 @@ class ParseLiveQueryServer { } for (const subscription of classSubscriptions.values()) { - const isSubscriptionMatched = this._matchesSubscription(deletedParseObject, subscription); + let isSubscriptionMatched; + try { + isSubscriptionMatched = this._matchesSubscription(deletedParseObject, subscription); + } catch (e) { + logger.error(`Failed matching subscription for class ${className}: ${e.message}`); + continue; + } if (!isSubscriptionMatched) { continue; } @@ -286,14 +292,21 @@ class ParseLiveQueryServer { return; } for (const subscription of classSubscriptions.values()) { - const isOriginalSubscriptionMatched = this._matchesSubscription( - originalParseObject, - subscription - ); - const isCurrentSubscriptionMatched = this._matchesSubscription( - currentParseObject, - subscription - ); + let isOriginalSubscriptionMatched; + let isCurrentSubscriptionMatched; + try { + isOriginalSubscriptionMatched = this._matchesSubscription( + originalParseObject, + subscription + ); + isCurrentSubscriptionMatched = this._matchesSubscription( + currentParseObject, + subscription + ); + } catch (e) { + logger.error(`Failed matching subscription for class ${className}: ${e.message}`); + continue; + } for (const [clientId, requestIds] of _.entries(subscription.clientRequestIds)) { const client = this.clients.get(clientId); if (typeof client === 'undefined') { @@ -520,6 +533,41 @@ class ParseLiveQueryServer { }); } + _validateQueryConstraints(where: any): void { + if (typeof where !== 'object' || where === null) { + return; + } + for (const key of Object.keys(where)) { + const constraint = where[key]; + if (typeof constraint === 'object' && constraint !== null) { + if (constraint.$regex !== undefined) { + const pattern = typeof constraint.$regex === 'object' + ? constraint.$regex.source + : constraint.$regex; + const flags = typeof constraint.$regex === 'object' + ? constraint.$regex.flags + : constraint.$options || ''; + try { + new RegExp(pattern, flags); + } catch (e) { + throw new Parse.Error( + Parse.Error.INVALID_QUERY, + `Invalid regular expression: ${e.message}` + ); + } + } + for (const op of ['$or', '$and', '$nor']) { + if (Array.isArray(constraint[op])) { + constraint[op].forEach((subQuery: any) => this._validateQueryConstraints(subQuery)); + } + } + if (Array.isArray(where[key])) { + where[key].forEach((subQuery: any) => this._validateQueryConstraints(subQuery)); + } + } + } + } + _matchesSubscription(parseObject: any, subscription: any): boolean { // Object is undefined or null, not match if (!parseObject) { @@ -951,6 +999,9 @@ class ParseLiveQueryServer { } } + // Validate regex patterns in the subscription query + this._validateQueryConstraints(request.query.where); + // Get subscription from subscriptions, create one if necessary const subscriptionHash = queryHash(request.query); // Add className to subscriptions if necessary diff --git a/src/LiveQuery/QueryTools.js b/src/LiveQuery/QueryTools.js index 0d182b8007..95159ed8b9 100644 --- a/src/LiveQuery/QueryTools.js +++ b/src/LiveQuery/QueryTools.js @@ -14,28 +14,29 @@ function setRegexTimeout(ms) { } function safeRegexTest(pattern, flags, input) { - if (!regexTimeout) { - var re = new RegExp(pattern, flags); - return re.test(input); - } - var cacheKey = flags + ':' + pattern; - var script = scriptCache.get(cacheKey); - if (!script) { - if (scriptCache.size >= SCRIPT_CACHE_MAX) { scriptCache.clear(); } - script = new vm.Script('new RegExp(pattern, flags).test(input)'); - scriptCache.set(cacheKey, script); - } - vmContext.pattern = pattern; - vmContext.flags = flags; - vmContext.input = input; try { + if (!regexTimeout) { + var re = new RegExp(pattern, flags); + return re.test(input); + } + var cacheKey = flags + ':' + pattern; + var script = scriptCache.get(cacheKey); + if (!script) { + if (scriptCache.size >= SCRIPT_CACHE_MAX) { scriptCache.clear(); } + script = new vm.Script('new RegExp(pattern, flags).test(input)'); + scriptCache.set(cacheKey, script); + } + vmContext.pattern = pattern; + vmContext.flags = flags; + vmContext.input = input; return script.runInContext(vmContext, { timeout: regexTimeout }); } catch (e) { if (e.code === 'ERR_SCRIPT_EXECUTION_TIMEOUT') { logger.warn(`Regex timeout: pattern "${pattern}" with flags "${flags}" exceeded ${regexTimeout}ms limit`); - return false; + } else { + logger.warn(`Invalid regex: pattern "${pattern}" with flags "${flags}": ${e.message}`); } - throw e; + return false; } } From cac6e1f43d7d25ce58823cc8117a6d8f8a53908f Mon Sep 17 00:00:00 2001 From: Manuel Trezza <5673677+mtrezza@users.noreply.github.com> Date: Sat, 14 Mar 2026 03:29:05 +0100 Subject: [PATCH 2/2] fix --- spec/ParseLiveQuery.spec.js | 35 ++++++++++++++++++++++++--- spec/QueryTools.spec.js | 21 +++++++++------- src/LiveQuery/ParseLiveQueryServer.ts | 28 +++++++++++++++------ 3 files changed, 64 insertions(+), 20 deletions(-) diff --git a/spec/ParseLiveQuery.spec.js b/spec/ParseLiveQuery.spec.js index f257739392..ac46535787 100644 --- a/spec/ParseLiveQuery.spec.js +++ b/spec/ParseLiveQuery.spec.js @@ -661,7 +661,7 @@ describe('ParseLiveQuery', function () { await expectAsync(query.subscribe()).toBeRejectedWithError(/Invalid regular expression/); }); - it('does not crash server when subscription has invalid $regex and object is saved', async () => { + it('rejects subscription with non-string $regex value', async () => { await reconfigureServer({ liveQuery: { classNames: ['TestObject'], @@ -671,16 +671,42 @@ describe('ParseLiveQuery', function () { silent: true, }); - // Create a valid subscription first + const query = new Parse.Query('TestObject'); + query._where = { foo: { $regex: 123 } }; + await expectAsync(query.subscribe()).toBeRejectedWithError( + /\$regex must be a string or RegExp/ + ); + }); + + it('does not crash server when subscription matching throws and other subscriptions still work', async () => { + const server = await reconfigureServer({ + liveQuery: { + classNames: ['TestObject'], + }, + startLiveQueryServer: true, + verbose: false, + silent: true, + }); + const object = new TestObject(); object.set('foo', 'bar'); await object.save(); + // Create a valid subscription const validQuery = new Parse.Query('TestObject'); validQuery.equalTo('objectId', object.id); const validSubscription = await validQuery.subscribe(); - // Verify valid subscription still works after an object update + // Inject a malformed subscription directly into the LiveQuery server + // to bypass subscribe-time validation and test the try-catch in _onAfterSave + const lqServer = server.liveQueryServer; + const Subscription = require('../lib/LiveQuery/Subscription').Subscription; + const badSubscription = new Subscription('TestObject', { foo: { $regex: '[invalid' } }); + badSubscription.addClientSubscription('fakeClientId', 'fakeRequestId'); + const classSubscriptions = lqServer.subscriptions.get('TestObject'); + classSubscriptions.set('bad-hash', badSubscription); + + // Verify the valid subscription still receives updates despite the bad subscription const updatePromise = new Promise(resolve => { validSubscription.on('update', obj => { expect(obj.get('foo')).toBe('baz'); @@ -691,6 +717,9 @@ describe('ParseLiveQuery', function () { object.set('foo', 'baz'); await object.save(); await updatePromise; + + // Clean up the injected subscription + classSubscriptions.delete('bad-hash'); }); it('can handle mutate beforeSubscribe query', async done => { diff --git a/spec/QueryTools.spec.js b/spec/QueryTools.spec.js index c613f21a6e..0ea1b560e7 100644 --- a/spec/QueryTools.spec.js +++ b/spec/QueryTools.spec.js @@ -583,16 +583,19 @@ describe('matchesQuery', function () { it('does not throw on invalid $regex pattern with regexTimeout enabled', function () { const { setRegexTimeout } = require('../lib/LiveQuery/QueryTools'); setRegexTimeout(100); - const player = { - id: new Id('Player', 'P1'), - name: 'Player 1', - }; + try { + const player = { + id: new Id('Player', 'P1'), + name: 'Player 1', + }; - const q = new Parse.Query('Player'); - q._where = { name: { $regex: '[invalid' } }; - expect(() => matchesQuery(player, q)).not.toThrow(); - expect(matchesQuery(player, q)).toBe(false); - setRegexTimeout(0); + const q = new Parse.Query('Player'); + q._where = { name: { $regex: '[invalid' } }; + expect(() => matchesQuery(player, q)).not.toThrow(); + expect(matchesQuery(player, q)).toBe(false); + } finally { + setRegexTimeout(0); + } }); it('does not throw on invalid $regex flags', function () { diff --git a/src/LiveQuery/ParseLiveQueryServer.ts b/src/LiveQuery/ParseLiveQueryServer.ts index 5a2dff2727..99f1fe2d21 100644 --- a/src/LiveQuery/ParseLiveQueryServer.ts +++ b/src/LiveQuery/ParseLiveQueryServer.ts @@ -541,12 +541,20 @@ class ParseLiveQueryServer { const constraint = where[key]; if (typeof constraint === 'object' && constraint !== null) { if (constraint.$regex !== undefined) { - const pattern = typeof constraint.$regex === 'object' - ? constraint.$regex.source - : constraint.$regex; - const flags = typeof constraint.$regex === 'object' - ? constraint.$regex.flags - : constraint.$options || ''; + const regex = constraint.$regex; + const isRegExpLike = + regex !== null && + typeof regex === 'object' && + typeof regex.source === 'string' && + typeof regex.flags === 'string'; + if (typeof regex !== 'string' && !isRegExpLike) { + throw new Parse.Error( + Parse.Error.INVALID_QUERY, + 'Invalid regular expression: $regex must be a string or RegExp' + ); + } + const pattern = isRegExpLike ? regex.source : regex; + const flags = isRegExpLike ? regex.flags : constraint.$options || ''; try { new RegExp(pattern, flags); } catch (e) { @@ -558,11 +566,15 @@ class ParseLiveQueryServer { } for (const op of ['$or', '$and', '$nor']) { if (Array.isArray(constraint[op])) { - constraint[op].forEach((subQuery: any) => this._validateQueryConstraints(subQuery)); + constraint[op].forEach((subQuery: any) => { + this._validateQueryConstraints(subQuery); + }); } } if (Array.isArray(where[key])) { - where[key].forEach((subQuery: any) => this._validateQueryConstraints(subQuery)); + where[key].forEach((subQuery: any) => { + this._validateQueryConstraints(subQuery); + }); } } }