mirror of
https://github.com/parse-community/parse-server
synced 2026-08-09 13:03:18 +00:00
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:
@@ -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));
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
},
|
||||
}),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user