Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions spec/ParseLiveQuery.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

it('can handle mutate beforeSubscribe query', async done => {
await reconfigureServer({
liveQuery: {
Expand Down
40 changes: 40 additions & 0 deletions spec/QueryTools.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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));
Expand Down
69 changes: 60 additions & 9 deletions src/LiveQuery/ParseLiveQueryServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -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') {
Expand Down Expand Up @@ -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}`
);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
}
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));
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
}
}
}
}

_matchesSubscription(parseObject: any, subscription: any): boolean {
// Object is undefined or null, not match
if (!parseObject) {
Expand Down Expand Up @@ -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
Expand Down
33 changes: 17 additions & 16 deletions src/LiveQuery/QueryTools.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}

Expand Down
Loading