Skip to content

Commit 529912a

Browse files
committed
Merge remote-tracking branch 'upstream/alpha' into alpha
2 parents b98cce5 + 98dc65b commit 529912a

File tree

12 files changed

+268
-7
lines changed

12 files changed

+268
-7
lines changed

changelogs/CHANGELOG_alpha.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,10 @@
1+
# [9.5.0-alpha.14](https://github.com/parse-community/parse-server/compare/9.5.0-alpha.13...9.5.0-alpha.14) (2026-03-07)
2+
3+
4+
### Bug Fixes
5+
6+
* 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](https://github.com/parse-community/parse-server/issues/10118)) ([5e113c2](https://github.com/parse-community/parse-server/commit/5e113c2128239b26551f77e127d0120502dc152a))
7+
18
# [9.5.0-alpha.13](https://github.com/parse-community/parse-server/compare/9.5.0-alpha.12...9.5.0-alpha.13) (2026-03-06)
29

310

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "parse-server",
3-
"version": "9.5.0-alpha.13",
3+
"version": "9.5.0-alpha.14",
44
"description": "An express module providing a Parse-compatible API server",
55
"main": "lib/index.js",
66
"repository": {

spec/QueryTools.spec.js

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -445,6 +445,118 @@ describe('matchesQuery', function () {
445445
expect(matchesQuery(player, q)).toBe(false);
446446
});
447447

448+
it('rejects $regex with catastrophic backtracking pattern (string)', function () {
449+
const { setRegexTimeout } = require('../lib/LiveQuery/QueryTools');
450+
setRegexTimeout(100);
451+
try {
452+
const player = {
453+
id: new Id('Player', 'P1'),
454+
name: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaac',
455+
};
456+
457+
// (a+)+b - classic catastrophic backtracking pattern
458+
let q = new Parse.Query('Player');
459+
q._addCondition('name', '$regex', '(a+)+b');
460+
expect(matchesQuery(player, q)).toBe(false);
461+
462+
// (a|a)+b - exponential alternation
463+
q = new Parse.Query('Player');
464+
q._addCondition('name', '$regex', '(a|a)+b');
465+
expect(matchesQuery(player, q)).toBe(false);
466+
467+
// (a+){2,}b - nested quantifiers
468+
q = new Parse.Query('Player');
469+
q._addCondition('name', '$regex', '(a+){2,}b');
470+
expect(matchesQuery(player, q)).toBe(false);
471+
} finally {
472+
setRegexTimeout(0);
473+
}
474+
});
475+
476+
it('rejects $regex with catastrophic backtracking pattern (RegExp object)', function () {
477+
const { setRegexTimeout } = require('../lib/LiveQuery/QueryTools');
478+
setRegexTimeout(100);
479+
try {
480+
const player = {
481+
id: new Id('Player', 'P1'),
482+
name: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaac',
483+
};
484+
485+
const q = new Parse.Query('Player');
486+
q.matches('name', /(a+)+b/);
487+
expect(matchesQuery(player, q)).toBe(false);
488+
} finally {
489+
setRegexTimeout(0);
490+
}
491+
});
492+
493+
it('still matches safe $regex patterns with regexTimeout enabled', function () {
494+
const { setRegexTimeout } = require('../lib/LiveQuery/QueryTools');
495+
setRegexTimeout(100);
496+
try {
497+
const player = {
498+
id: new Id('Player', 'P1'),
499+
name: 'Player 1',
500+
};
501+
502+
// Safe string regex
503+
let q = new Parse.Query('Player');
504+
q.startsWith('name', 'Play');
505+
expect(matchesQuery(player, q)).toBe(true);
506+
507+
q = new Parse.Query('Player');
508+
q.endsWith('name', ' 1');
509+
expect(matchesQuery(player, q)).toBe(true);
510+
511+
q = new Parse.Query('Player');
512+
q.contains('name', 'ayer');
513+
expect(matchesQuery(player, q)).toBe(true);
514+
515+
// Safe RegExp object
516+
q = new Parse.Query('Player');
517+
q.matches('name', /Play.*/);
518+
expect(matchesQuery(player, q)).toBe(true);
519+
520+
// Case-insensitive
521+
q = new Parse.Query('Player');
522+
q._addCondition('name', '$regex', 'player');
523+
q._addCondition('name', '$options', 'i');
524+
expect(matchesQuery(player, q)).toBe(true);
525+
} finally {
526+
setRegexTimeout(0);
527+
}
528+
});
529+
530+
it('matches $regex with backreferences when regexTimeout is enabled', function () {
531+
const { setRegexTimeout } = require('../lib/LiveQuery/QueryTools');
532+
setRegexTimeout(100);
533+
try {
534+
const player = {
535+
id: new Id('Player', 'P1'),
536+
name: 'aa',
537+
};
538+
539+
const q = new Parse.Query('Player');
540+
q._addCondition('name', '$regex', '(a)\\1');
541+
expect(matchesQuery(player, q)).toBe(true);
542+
} finally {
543+
setRegexTimeout(0);
544+
}
545+
});
546+
547+
it('uses native RegExp when regexTimeout is 0 (disabled)', function () {
548+
const { setRegexTimeout } = require('../lib/LiveQuery/QueryTools');
549+
setRegexTimeout(0);
550+
const player = {
551+
id: new Id('Player', 'P1'),
552+
name: 'Player 1',
553+
};
554+
555+
const q = new Parse.Query('Player');
556+
q.startsWith('name', 'Play');
557+
expect(matchesQuery(player, q)).toBe(true);
558+
});
559+
448560
it('matches $nearSphere queries', function () {
449561
let q = new Parse.Query('Checkin');
450562
q.near('location', new Parse.GeoPoint(20, 20));

spec/SecurityCheck.spec.js

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -338,6 +338,45 @@ describe('Security Check', () => {
338338
}
339339
});
340340

341+
it('warns when LiveQuery regex timeout is disabled', async () => {
342+
await reconfigureServer({
343+
security: { enableCheck: true, enableCheckLog: true },
344+
liveQuery: { classNames: ['TestObject'], regexTimeout: 0 },
345+
});
346+
const runner = new CheckRunner({ enableCheck: true });
347+
const report = await runner.run();
348+
const check = report.report.groups
349+
.flatMap(g => g.checks)
350+
.find(c => c.title === 'LiveQuery regex timeout enabled');
351+
expect(check).toBeDefined();
352+
expect(check.state).toBe(CheckState.fail);
353+
});
354+
355+
it('passes when LiveQuery regex timeout is enabled', async () => {
356+
await reconfigureServer({
357+
security: { enableCheck: true, enableCheckLog: true },
358+
liveQuery: { classNames: ['TestObject'], regexTimeout: 100 },
359+
});
360+
const runner = new CheckRunner({ enableCheck: true });
361+
const report = await runner.run();
362+
const check = report.report.groups
363+
.flatMap(g => g.checks)
364+
.find(c => c.title === 'LiveQuery regex timeout enabled');
365+
expect(check.state).toBe(CheckState.success);
366+
});
367+
368+
it('passes when LiveQuery is not configured', async () => {
369+
await reconfigureServer({
370+
security: { enableCheck: true, enableCheckLog: true },
371+
});
372+
const runner = new CheckRunner({ enableCheck: true });
373+
const report = await runner.run();
374+
const check = report.report.groups
375+
.flatMap(g => g.checks)
376+
.find(c => c.title === 'LiveQuery regex timeout enabled');
377+
expect(check.state).toBe(CheckState.success);
378+
});
379+
341380
it('does update featuresRouter', async () => {
342381
let response = await request({
343382
url: 'http://localhost:8378/1/serverInfo',

spec/vulnerabilities.spec.js

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -600,3 +600,41 @@ describe('Postgres regex sanitizater', () => {
600600
expect(response.data.results.length).toBe(0);
601601
});
602602
});
603+
604+
describe('(GHSA-mf3j-86qx-cq5j) ReDoS via $regex in LiveQuery subscription', () => {
605+
it('does not block event loop with catastrophic backtracking regex in LiveQuery', async () => {
606+
await reconfigureServer({
607+
liveQuery: { classNames: ['TestObject'] },
608+
startLiveQueryServer: true,
609+
});
610+
const client = new Parse.LiveQueryClient({
611+
applicationId: 'test',
612+
serverURL: 'ws://localhost:1337',
613+
javascriptKey: 'test',
614+
});
615+
client.open();
616+
const query = new Parse.Query('TestObject');
617+
// Set a catastrophic backtracking regex pattern directly
618+
query._addCondition('field', '$regex', '(a+)+b');
619+
const subscription = await client.subscribe(query);
620+
// Create an object that would trigger regex evaluation
621+
const obj = new Parse.Object('TestObject');
622+
// With 30 'a's followed by 'c', an unprotected regex would hang for seconds
623+
obj.set('field', 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaac');
624+
// Set a timeout to detect if the event loop is blocked
625+
const timeout = 5000;
626+
const start = Date.now();
627+
const savePromise = obj.save();
628+
const eventPromise = new Promise(resolve => {
629+
subscription.on('create', () => resolve('matched'));
630+
setTimeout(() => resolve('timeout'), timeout);
631+
});
632+
await savePromise;
633+
const result = await eventPromise;
634+
const elapsed = Date.now() - start;
635+
// The regex should be rejected (not match), and the operation should complete quickly
636+
expect(result).toBe('timeout');
637+
expect(elapsed).toBeLessThan(timeout + 1000);
638+
client.close();
639+
});
640+
});

src/LiveQuery/QueryTools.js

Lines changed: 45 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,43 @@
11
var equalObjects = require('./equalObjects');
22
var Id = require('./Id');
33
var Parse = require('parse/node');
4+
var vm = require('vm');
5+
var logger = require('../logger').default;
6+
7+
var regexTimeout = 0;
8+
var vmContext = vm.createContext(Object.create(null));
9+
var scriptCache = new Map();
10+
var SCRIPT_CACHE_MAX = 1000;
11+
12+
function setRegexTimeout(ms) {
13+
regexTimeout = ms;
14+
}
15+
16+
function safeRegexTest(pattern, flags, input) {
17+
if (!regexTimeout) {
18+
var re = new RegExp(pattern, flags);
19+
return re.test(input);
20+
}
21+
var cacheKey = flags + ':' + pattern;
22+
var script = scriptCache.get(cacheKey);
23+
if (!script) {
24+
if (scriptCache.size >= SCRIPT_CACHE_MAX) { scriptCache.clear(); }
25+
script = new vm.Script('new RegExp(pattern, flags).test(input)');
26+
scriptCache.set(cacheKey, script);
27+
}
28+
vmContext.pattern = pattern;
29+
vmContext.flags = flags;
30+
vmContext.input = input;
31+
try {
32+
return script.runInContext(vmContext, { timeout: regexTimeout });
33+
} catch (e) {
34+
if (e.code === 'ERR_SCRIPT_EXECUTION_TIMEOUT') {
35+
logger.warn(`Regex timeout: pattern "${pattern}" with flags "${flags}" exceeded ${regexTimeout}ms limit`);
36+
return false;
37+
}
38+
throw e;
39+
}
40+
}
441

542
/**
643
* Query Hashes are deterministic hashes for Parse Queries.
@@ -290,9 +327,12 @@ function matchesKeyConstraints(object, key, constraints) {
290327
}
291328
break;
292329
}
293-
case '$regex':
330+
case '$regex': {
294331
if (typeof compareTo === 'object') {
295-
return compareTo.test(object[key]);
332+
if (!safeRegexTest(compareTo.source, compareTo.flags, object[key])) {
333+
return false;
334+
}
335+
break;
296336
}
297337
// JS doesn't support perl-style escaping
298338
var expString = '';
@@ -312,11 +352,11 @@ function matchesKeyConstraints(object, key, constraints) {
312352
escapeStart = compareTo.indexOf('\\Q', escapeEnd);
313353
}
314354
expString += compareTo.substring(Math.max(escapeStart, escapeEnd + 2));
315-
var exp = new RegExp(expString, constraints.$options || '');
316-
if (!exp.test(object[key])) {
355+
if (!safeRegexTest(expString, constraints.$options || '', object[key])) {
317356
return false;
318357
}
319358
break;
359+
}
320360
case '$nearSphere':
321361
if (!compareTo || !object[key]) {
322362
return false;
@@ -396,6 +436,7 @@ function matchesKeyConstraints(object, key, constraints) {
396436
var QueryTools = {
397437
queryHash: queryHash,
398438
matchesQuery: matchesQuery,
439+
setRegexTimeout: setRegexTimeout,
399440
};
400441

401442
module.exports = QueryTools;

src/Options/Definitions.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -844,6 +844,12 @@ module.exports.LiveQueryOptions = {
844844
env: 'PARSE_SERVER_LIVEQUERY_REDIS_URL',
845845
help: "parse-server's LiveQuery redisURL",
846846
},
847+
regexTimeout: {
848+
env: 'PARSE_SERVER_LIVEQUERY_REGEX_TIMEOUT',
849+
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`.',
850+
action: parsers.numberParser('regexTimeout'),
851+
default: 100,
852+
},
847853
wssAdapter: {
848854
env: 'PARSE_SERVER_LIVEQUERY_WSS_ADAPTER',
849855
help: 'Adapter module for the WebSocketServer',

src/Options/docs.js

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/Options/index.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -514,6 +514,9 @@ export interface LiveQueryOptions {
514514
redisURL: ?string;
515515
/* LiveQuery pubsub adapter */
516516
pubSubAdapter: ?Adapter<PubSubAdapter>;
517+
/* 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`.
518+
:DEFAULT: 100 */
519+
regexTimeout: ?number;
517520
/* Adapter module for the WebSocketServer */
518521
wssAdapter: ?Adapter<WSSAdapter>;
519522
}

0 commit comments

Comments
 (0)