fix: Regular Expression Denial of Service (ReDoS) via $regex query in LiveQuery ([GHSA-mf3j-86qx-cq5j](https://github.com/parse-community/parse-server/security/advisories/GHSA-mf3j-86qx-cq5j)) (#10118)

This commit is contained in:
Manuel
2026-03-07 02:14:37 +00:00
committed by GitHub
parent 22d707240e
commit 5e113c2128
9 changed files with 258 additions and 4 deletions
+112
View File
@@ -445,6 +445,118 @@ describe('matchesQuery', function () {
expect(matchesQuery(player, q)).toBe(false);
});
it('rejects $regex with catastrophic backtracking pattern (string)', function () {
const { setRegexTimeout } = require('../lib/LiveQuery/QueryTools');
setRegexTimeout(100);
try {
const player = {
id: new Id('Player', 'P1'),
name: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaac',
};
// (a+)+b - classic catastrophic backtracking pattern
let q = new Parse.Query('Player');
q._addCondition('name', '$regex', '(a+)+b');
expect(matchesQuery(player, q)).toBe(false);
// (a|a)+b - exponential alternation
q = new Parse.Query('Player');
q._addCondition('name', '$regex', '(a|a)+b');
expect(matchesQuery(player, q)).toBe(false);
// (a+){2,}b - nested quantifiers
q = new Parse.Query('Player');
q._addCondition('name', '$regex', '(a+){2,}b');
expect(matchesQuery(player, q)).toBe(false);
} finally {
setRegexTimeout(0);
}
});
it('rejects $regex with catastrophic backtracking pattern (RegExp object)', function () {
const { setRegexTimeout } = require('../lib/LiveQuery/QueryTools');
setRegexTimeout(100);
try {
const player = {
id: new Id('Player', 'P1'),
name: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaac',
};
const q = new Parse.Query('Player');
q.matches('name', /(a+)+b/);
expect(matchesQuery(player, q)).toBe(false);
} finally {
setRegexTimeout(0);
}
});
it('still matches safe $regex patterns with regexTimeout enabled', function () {
const { setRegexTimeout } = require('../lib/LiveQuery/QueryTools');
setRegexTimeout(100);
try {
const player = {
id: new Id('Player', 'P1'),
name: 'Player 1',
};
// Safe string regex
let q = new Parse.Query('Player');
q.startsWith('name', 'Play');
expect(matchesQuery(player, q)).toBe(true);
q = new Parse.Query('Player');
q.endsWith('name', ' 1');
expect(matchesQuery(player, q)).toBe(true);
q = new Parse.Query('Player');
q.contains('name', 'ayer');
expect(matchesQuery(player, q)).toBe(true);
// Safe RegExp object
q = new Parse.Query('Player');
q.matches('name', /Play.*/);
expect(matchesQuery(player, q)).toBe(true);
// Case-insensitive
q = new Parse.Query('Player');
q._addCondition('name', '$regex', 'player');
q._addCondition('name', '$options', 'i');
expect(matchesQuery(player, q)).toBe(true);
} finally {
setRegexTimeout(0);
}
});
it('matches $regex with backreferences when regexTimeout is enabled', function () {
const { setRegexTimeout } = require('../lib/LiveQuery/QueryTools');
setRegexTimeout(100);
try {
const player = {
id: new Id('Player', 'P1'),
name: 'aa',
};
const q = new Parse.Query('Player');
q._addCondition('name', '$regex', '(a)\\1');
expect(matchesQuery(player, q)).toBe(true);
} finally {
setRegexTimeout(0);
}
});
it('uses native RegExp when regexTimeout is 0 (disabled)', function () {
const { setRegexTimeout } = require('../lib/LiveQuery/QueryTools');
setRegexTimeout(0);
const player = {
id: new Id('Player', 'P1'),
name: 'Player 1',
};
const q = new Parse.Query('Player');
q.startsWith('name', 'Play');
expect(matchesQuery(player, q)).toBe(true);
});
it('matches $nearSphere queries', function () {
let q = new Parse.Query('Checkin');
q.near('location', new Parse.GeoPoint(20, 20));
+39
View File
@@ -338,6 +338,45 @@ describe('Security Check', () => {
}
});
it('warns when LiveQuery regex timeout is disabled', async () => {
await reconfigureServer({
security: { enableCheck: true, enableCheckLog: true },
liveQuery: { classNames: ['TestObject'], regexTimeout: 0 },
});
const runner = new CheckRunner({ enableCheck: true });
const report = await runner.run();
const check = report.report.groups
.flatMap(g => g.checks)
.find(c => c.title === 'LiveQuery regex timeout enabled');
expect(check).toBeDefined();
expect(check.state).toBe(CheckState.fail);
});
it('passes when LiveQuery regex timeout is enabled', async () => {
await reconfigureServer({
security: { enableCheck: true, enableCheckLog: true },
liveQuery: { classNames: ['TestObject'], regexTimeout: 100 },
});
const runner = new CheckRunner({ enableCheck: true });
const report = await runner.run();
const check = report.report.groups
.flatMap(g => g.checks)
.find(c => c.title === 'LiveQuery regex timeout enabled');
expect(check.state).toBe(CheckState.success);
});
it('passes when LiveQuery is not configured', async () => {
await reconfigureServer({
security: { enableCheck: true, enableCheckLog: true },
});
const runner = new CheckRunner({ enableCheck: true });
const report = await runner.run();
const check = report.report.groups
.flatMap(g => g.checks)
.find(c => c.title === 'LiveQuery regex timeout enabled');
expect(check.state).toBe(CheckState.success);
});
it('does update featuresRouter', async () => {
let response = await request({
url: 'http://localhost:8378/1/serverInfo',
+38
View File
@@ -600,3 +600,41 @@ describe('Postgres regex sanitizater', () => {
expect(response.data.results.length).toBe(0);
});
});
describe('(GHSA-mf3j-86qx-cq5j) ReDoS via $regex in LiveQuery subscription', () => {
it('does not block event loop with catastrophic backtracking regex in LiveQuery', async () => {
await reconfigureServer({
liveQuery: { classNames: ['TestObject'] },
startLiveQueryServer: true,
});
const client = new Parse.LiveQueryClient({
applicationId: 'test',
serverURL: 'ws://localhost:1337',
javascriptKey: 'test',
});
client.open();
const query = new Parse.Query('TestObject');
// Set a catastrophic backtracking regex pattern directly
query._addCondition('field', '$regex', '(a+)+b');
const subscription = await client.subscribe(query);
// Create an object that would trigger regex evaluation
const obj = new Parse.Object('TestObject');
// With 30 'a's followed by 'c', an unprotected regex would hang for seconds
obj.set('field', 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaac');
// Set a timeout to detect if the event loop is blocked
const timeout = 5000;
const start = Date.now();
const savePromise = obj.save();
const eventPromise = new Promise(resolve => {
subscription.on('create', () => resolve('matched'));
setTimeout(() => resolve('timeout'), timeout);
});
await savePromise;
const result = await eventPromise;
const elapsed = Date.now() - start;
// The regex should be rejected (not match), and the operation should complete quickly
expect(result).toBe('timeout');
expect(elapsed).toBeLessThan(timeout + 1000);
client.close();
});
});
+45 -4
View File
@@ -1,6 +1,43 @@
var equalObjects = require('./equalObjects');
var Id = require('./Id');
var Parse = require('parse/node');
var vm = require('vm');
var logger = require('../logger').default;
var regexTimeout = 0;
var vmContext = vm.createContext(Object.create(null));
var scriptCache = new Map();
var SCRIPT_CACHE_MAX = 1000;
function setRegexTimeout(ms) {
regexTimeout = 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 {
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;
}
throw e;
}
}
/**
* Query Hashes are deterministic hashes for Parse Queries.
@@ -290,9 +327,12 @@ function matchesKeyConstraints(object, key, constraints) {
}
break;
}
case '$regex':
case '$regex': {
if (typeof compareTo === 'object') {
return compareTo.test(object[key]);
if (!safeRegexTest(compareTo.source, compareTo.flags, object[key])) {
return false;
}
break;
}
// JS doesn't support perl-style escaping
var expString = '';
@@ -312,11 +352,11 @@ function matchesKeyConstraints(object, key, constraints) {
escapeStart = compareTo.indexOf('\\Q', escapeEnd);
}
expString += compareTo.substring(Math.max(escapeStart, escapeEnd + 2));
var exp = new RegExp(expString, constraints.$options || '');
if (!exp.test(object[key])) {
if (!safeRegexTest(expString, constraints.$options || '', object[key])) {
return false;
}
break;
}
case '$nearSphere':
if (!compareTo || !object[key]) {
return false;
@@ -396,6 +436,7 @@ function matchesKeyConstraints(object, key, constraints) {
var QueryTools = {
queryHash: queryHash,
matchesQuery: matchesQuery,
setRegexTimeout: setRegexTimeout,
};
module.exports = QueryTools;
+6
View File
@@ -844,6 +844,12 @@ module.exports.LiveQueryOptions = {
env: 'PARSE_SERVER_LIVEQUERY_REDIS_URL',
help: "parse-server's LiveQuery redisURL",
},
regexTimeout: {
env: 'PARSE_SERVER_LIVEQUERY_REGEX_TIMEOUT',
help: 'Sets the maximum execution time in milliseconds for regular expression pattern matching in LiveQuery. This protects against Regular Expression Denial of Service (ReDoS) attacks where a malicious regex pattern could block the event loop. A regex that exceeds the timeout will be treated as non-matching.<br><br>The protection runs each regex evaluation in an isolated VM context with a timeout. This adds approximately 50 microseconds of overhead per regex evaluation. For most applications this is negligible, but it can add up if you have a very large number of LiveQuery subscriptions that use `$regex` on the same class. For example, 10,000 concurrent regex subscriptions would add approximately 500ms of processing time per object save event on that class.<br><br>Set to `0` to disable the timeout and use native regex evaluation without protection. Defaults to `100`.',
action: parsers.numberParser('regexTimeout'),
default: 100,
},
wssAdapter: {
env: 'PARSE_SERVER_LIVEQUERY_WSS_ADAPTER',
help: 'Adapter module for the WebSocketServer',
+1
View File
@@ -186,6 +186,7 @@
* @property {Adapter<PubSubAdapter>} pubSubAdapter LiveQuery pubsub adapter
* @property {Any} redisOptions parse-server's LiveQuery redisOptions
* @property {String} redisURL parse-server's LiveQuery redisURL
* @property {Number} regexTimeout Sets the maximum execution time in milliseconds for regular expression pattern matching in LiveQuery. This protects against Regular Expression Denial of Service (ReDoS) attacks where a malicious regex pattern could block the event loop. A regex that exceeds the timeout will be treated as non-matching.<br><br>The protection runs each regex evaluation in an isolated VM context with a timeout. This adds approximately 50 microseconds of overhead per regex evaluation. For most applications this is negligible, but it can add up if you have a very large number of LiveQuery subscriptions that use `$regex` on the same class. For example, 10,000 concurrent regex subscriptions would add approximately 500ms of processing time per object save event on that class.<br><br>Set to `0` to disable the timeout and use native regex evaluation without protection. Defaults to `100`.
* @property {Adapter<WSSAdapter>} wssAdapter Adapter module for the WebSocketServer
*/
+3
View File
@@ -514,6 +514,9 @@ export interface LiveQueryOptions {
redisURL: ?string;
/* LiveQuery pubsub adapter */
pubSubAdapter: ?Adapter<PubSubAdapter>;
/* Sets the maximum execution time in milliseconds for regular expression pattern matching in LiveQuery. This protects against Regular Expression Denial of Service (ReDoS) attacks where a malicious regex pattern could block the event loop. A regex that exceeds the timeout will be treated as non-matching.<br><br>The protection runs each regex evaluation in an isolated VM context with a timeout. This adds approximately 50 microseconds of overhead per regex evaluation. For most applications this is negligible, but it can add up if you have a very large number of LiveQuery subscriptions that use `$regex` on the same class. For example, 10,000 concurrent regex subscriptions would add approximately 500ms of processing time per object save event on that class.<br><br>Set to `0` to disable the timeout and use native regex evaluation without protection. Defaults to `100`.
:DEFAULT: 100 */
regexTimeout: ?number;
/* Adapter module for the WebSocketServer */
wssAdapter: ?Adapter<WSSAdapter>;
}
+2
View File
@@ -9,6 +9,7 @@ var batch = require('./batch'),
fs = require('fs');
import { ParseServerOptions, LiveQueryServerOptions } from './Options';
import { setRegexTimeout } from './LiveQuery/QueryTools';
import defaults, { DatabaseOptionDefaults } from './defaults';
import * as logging from './logger';
import Config from './Config';
@@ -138,6 +139,7 @@ class ParseServer {
this.config.masterKeyIpsStore = new Map();
this.config.maintenanceKeyIpsStore = new Map();
this.config.readOnlyMasterKeyIpsStore = new Map();
setRegexTimeout(options.liveQuery?.regexTimeout);
logging.setLogger(allControllers.loggerController);
}
@@ -134,6 +134,18 @@ class CheckGroupServerConfig extends CheckGroup {
}
},
}),
new Check({
title: 'LiveQuery regex timeout enabled',
warning:
'LiveQuery regex timeout is disabled. A malicious client can subscribe with a crafted $regex pattern that causes catastrophic backtracking, blocking the Node.js event loop and making the server unresponsive.',
solution:
"Change Parse Server configuration to 'liveQuery.regexTimeout: 100' to set a 100ms timeout for regex evaluation in LiveQuery.",
check: () => {
if (config.liveQuery?.classNames?.length > 0 && config.liveQuery?.regexTimeout === 0) {
throw 1;
}
},
}),
];
}
}