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)) (#10120)
This commit is contained in:
@@ -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));
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
},
|
||||
}),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user