diff --git a/spec/ParseLiveQuery.spec.js b/spec/ParseLiveQuery.spec.js index 48bc1504c8..ac46535787 100644 --- a/spec/ParseLiveQuery.spec.js +++ b/spec/ParseLiveQuery.spec.js @@ -646,6 +646,82 @@ 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('rejects subscription with non-string $regex value', async () => { + await reconfigureServer({ + liveQuery: { + classNames: ['TestObject'], + }, + startLiveQueryServer: true, + verbose: false, + silent: true, + }); + + 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(); + + // 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'); + resolve(); + }); + }); + + 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 => { await reconfigureServer({ liveQuery: { diff --git a/spec/QueryTools.spec.js b/spec/QueryTools.spec.js index 8819aadd66..0ea1b560e7 100644 --- a/spec/QueryTools.spec.js +++ b/spec/QueryTools.spec.js @@ -567,6 +567,49 @@ 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); + 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); + } finally { + 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..99f1fe2d21 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,53 @@ 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 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) { + 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 +1011,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; } }