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)) (#10120)

This commit is contained in:
Manuel
2026-03-07 02:14:34 +00:00
committed by GitHub
parent 72af528d53
commit 42bd2f07bd
9 changed files with 232 additions and 4 deletions
+98
View File
@@ -4,6 +4,7 @@ const Id = require('../lib/LiveQuery/Id');
const QueryTools = require('../lib/LiveQuery/QueryTools');
const queryHash = QueryTools.queryHash;
const matchesQuery = QueryTools.matchesQuery;
const setRegexTimeout = QueryTools.setRegexTimeout;
const Item = Parse.Object.extend('Item');
@@ -445,6 +446,103 @@ describe('matchesQuery', function () {
expect(matchesQuery(player, q)).toBe(false);
});
it('rejects $regex with catastrophic backtracking pattern (string)', function () {
setRegexTimeout(100);
try {
const player = {
id: new Id('Player', 'P1'),
name: 'a'.repeat(30),
score: 12,
};
// (a+)+b - classic catastrophic backtracking
expect(matchesQuery(player, { name: { $regex: '(a+)+b' } })).toBe(false);
// (a|a)+b - alternation variant
expect(matchesQuery(player, { name: { $regex: '(a|a)+b' } })).toBe(false);
// (a+){2,}b - quantifier variant
expect(matchesQuery(player, { name: { $regex: '(a+){2,}b' } })).toBe(false);
} finally {
setRegexTimeout(0);
}
});
it('rejects $regex with catastrophic backtracking pattern (RegExp object)', function () {
setRegexTimeout(100);
try {
const player = {
id: new Id('Player', 'P1'),
name: 'a'.repeat(30),
score: 12,
};
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 () {
setRegexTimeout(100);
try {
const player = {
id: new Id('Player', 'P1'),
name: 'Player 1',
score: 12,
};
// startsWith
let q = new Parse.Query('Player');
q.startsWith('name', 'Play');
expect(matchesQuery(player, q)).toBe(true);
// endsWith
q = new Parse.Query('Player');
q.endsWith('name', ' 1');
expect(matchesQuery(player, q)).toBe(true);
// contains
player.name = 'Android-7';
q = new Parse.Query('Player');
q.contains('name', 'd-7');
expect(matchesQuery(player, q)).toBe(true);
// matches
q = new Parse.Query('Player');
q.matches('name', /A.d/);
expect(matchesQuery(player, q)).toBe(true);
// case insensitive
q = new Parse.Query('Player');
q.matches('name', /android/i);
expect(matchesQuery(player, q)).toBe(true);
} finally {
setRegexTimeout(0);
}
});
it('matches $regex with backreferences when regexTimeout is enabled', function () {
setRegexTimeout(100);
try {
const player = {
id: new Id('Player', 'P1'),
name: 'aa',
score: 12,
};
expect(matchesQuery(player, { name: { $regex: '(a)\\1' } })).toBe(true);
player.name = 'ab';
expect(matchesQuery(player, { name: { $regex: '(a)\\1' } })).toBe(false);
} finally {
setRegexTimeout(0);
}
});
it('uses native RegExp when regexTimeout is 0 (disabled)', function () {
setRegexTimeout(0);
const player = {
id: new Id('Player', 'P1'),
name: 'Player 1',
score: 12,
};
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',
+25
View File
@@ -478,6 +478,31 @@ describe('Vulnerabilities', () => {
});
});
describe('(GHSA-mf3j-86qx-cq5j) ReDoS via $regex in LiveQuery subscription', () => {
it('should prevent ReDoS via catastrophic backtracking in LiveQuery $regex', async () => {
await reconfigureServer({
liveQuery: {
classNames: ['TestObject'],
regexTimeout: 100,
},
startLiveQueryServer: true,
});
const query = new Parse.Query('TestObject');
query.matches('field', /(a+)+b/);
const subscription = await query.subscribe();
const createPromise = new Promise(resolve => {
subscription.on('create', () => resolve('should_not_match'));
setTimeout(() => resolve('timeout'), 3000);
});
const obj = new Parse.Object('TestObject');
obj.set('field', 'a'.repeat(30));
await obj.save();
const result = await createPromise;
expect(result).toBe('timeout');
subscription.unsubscribe();
});
});
describe('Malformed $regex information disclosure', () => {
it('should not leak database error internals for invalid regex pattern in class query', async () => {
const logger = require('../lib/logger').default;
+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;
+7
View File
@@ -901,6 +901,13 @@ 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
@@ -489,6 +489,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 from './defaults';
import * as logging from './logger';
import Config from './Config';
@@ -139,6 +140,7 @@ class ParseServer {
this.config.masterKeyIpsStore = new Map();
this.config.maintenanceKeyIpsStore = new Map();
logging.setLogger(allControllers.loggerController);
setRegexTimeout(options.liveQuery?.regexTimeout);
}
/**
@@ -105,6 +105,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;
}
},
}),
];
}
}