fix: LiveQuery subscription with invalid regular expression crashes server ([GHSA-827p-g5x5-h86c](https://github.com/parse-community/parse-server/security/advisories/GHSA-827p-g5x5-h86c)) (#10199)

This commit is contained in:
Manuel
2026-03-14 14:21:52 +01:00
committed by GitHub
parent 4ac6da4d8a
commit 522f008f64
4 changed files with 208 additions and 25 deletions
+76
View File
@@ -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: {
+43
View File
@@ -553,6 +553,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));
+72 -9
View File
@@ -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
+17 -16
View File
@@ -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;
}
}