Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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
76 changes: 76 additions & 0 deletions spec/ParseLiveQuery.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
43 changes: 43 additions & 0 deletions spec/QueryTools.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down
81 changes: 72 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,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) {
Expand Down Expand Up @@ -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
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