Skip to content

Commit be63bd1

Browse files
authored
Merge pull request #1030 from utopia-php/fix/regex-invalid-pattern
fix(adapters): refuse a regex the engine cannot compile as a query error
2 parents b1ab94f + 484006f commit be63bd1

12 files changed

Lines changed: 187 additions & 10 deletions

File tree

‎src/Database/Adapter/MariaDB.php‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1050,6 +1050,11 @@ protected function decodePolygon(string $wkb): array
10501050
*/
10511051
protected const array INVALID_GEOMETRY_ERRORS = [3037];
10521052

1053+
/**
1054+
* ER_REGEXP_ERROR, for a pattern PCRE cannot compile or run.
1055+
*/
1056+
protected const array INVALID_REGEX_ERRORS = [1139];
1057+
10531058
/** The session timeout last set, in milliseconds. */
10541059
private int $appliedTimeout = 0;
10551060

@@ -1614,6 +1619,10 @@ protected function processException(PDOException $e): Exception
16141619
return new LimitException('Value is out of range', $e->getCode(), $e);
16151620
}
16161621

1622+
if (isset($e->errorInfo[1]) && \in_array($e->errorInfo[1], static::INVALID_REGEX_ERRORS, true)) {
1623+
return new QueryException('Invalid regular expression', $e->getCode(), $e);
1624+
}
1625+
16171626
if (isset($e->errorInfo[1]) && \in_array($e->errorInfo[1], static::INVALID_GEOMETRY_ERRORS, true)) {
16181627
return new QueryException('Invalid spatial value', $e->getCode(), $e);
16191628
}

‎src/Database/Adapter/Memory.php‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
use Utopia\Database\Exception\Limit as LimitException;
1414
use Utopia\Database\Exception\NotFound as NotFoundException;
1515
use Utopia\Database\Exception\Operator as OperatorException;
16+
use Utopia\Database\Exception\Query as QueryException;
1617
use Utopia\Database\Exception\Unique as UniqueException;
1718
use Utopia\Database\Index;
1819
use Utopia\Database\Operator;
@@ -2783,11 +2784,16 @@ protected function tokenize(string $text): array
27832784
* Apply the supplied regex against $value. Pattern is the raw expression
27842785
* — wrap it in delimiters before passing to preg_match, mirroring how
27852786
* MariaDB's REGEXP operator accepts the pattern verbatim.
2787+
*
2788+
* @throws QueryException When the pattern does not compile
27862789
*/
27872790
protected function matchesRegex(string $value, string $pattern): bool
27882791
{
27892792
$delimited = '#'.\str_replace('#', '\\#', $pattern).'#u';
27902793
$matched = @\preg_match($delimited, $value);
2794+
if ($matched === false && \preg_last_error() === PREG_INTERNAL_ERROR) {
2795+
throw new QueryException('Invalid regular expression');
2796+
}
27912797

27922798
return $matched === 1;
27932799
}

‎src/Database/Adapter/Mongo.php‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3887,6 +3887,10 @@ protected function processException(Throwable $e): Throwable
38873887
return new TypeException('Invalid operation', $e->getCode(), $e);
38883888
}
38893889

3890+
if ($e->getCode() === 51091) {
3891+
return new QueryException('Invalid regular expression', $e->getCode(), $e);
3892+
}
3893+
38903894
if ($e->getCode() === 28764) {
38913895
return new LimitException('Value out of range', $e->getCode(), $e);
38923896
}

‎src/Database/Adapter/MySQL.php‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,14 @@ protected function processException(PDOException $e): Exception
122122
*/
123123
protected const array INVALID_GEOMETRY_ERRORS = [3037, 3616, 3617];
124124

125+
/**
126+
* ER_REGEXP_ERROR, which MySQL raises for an error ICU only names, and the errors for a pattern ICU cannot compile
127+
* or run: illegal argument, rule syntax, bad escape sequence, unimplemented, mismatched parenthesis, bad interval,
128+
* max below min, invalid back reference, look-behind limit, missing close bracket, invalid range, stack overflow,
129+
* pattern too big and invalid capture group name. ER_REGEXP_TIME_OUT is a timeout.
130+
*/
131+
protected const array INVALID_REGEX_ERRORS = [1139, 3685, 3688, 3689, 3690, 3691, 3692, 3693, 3694, 3695, 3696, 3697, 3698, 3700, 3887];
132+
125133
#[\Override]
126134
protected function dialectBuilder(): SQLBuilder&Scoping
127135
{

‎src/Database/Adapter/Postgres.php‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1946,6 +1946,10 @@ protected function processException(PDOException $e): Exception
19461946
return new LimitException('Numeric value out of range', $e->getCode(), $e);
19471947
}
19481948

1949+
if ($e->getCode() === '2201B' && isset($e->errorInfo[1]) && $e->errorInfo[1] === 7) {
1950+
return new QueryException('Invalid regular expression', $e->getCode(), $e);
1951+
}
1952+
19491953
if ($e->getCode() === '2201F' && isset($e->errorInfo[1]) && $e->errorInfo[1] === 7) {
19501954
return new LimitException('Invalid argument for power function', $e->getCode(), $e);
19511955
}

‎src/Database/Adapter/Redis.php‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3109,8 +3109,12 @@ private function matchesDocument(Document $document, Query $query): bool
31093109
}
31103110
$pattern = $this->stringOrEmpty($values[0] ?? '');
31113111
$delimited = '#'.\str_replace('#', '\\#', $pattern).'#u';
3112+
$matched = @\preg_match($delimited, $value);
3113+
if ($matched === false && \preg_last_error() === PREG_INTERNAL_ERROR) {
3114+
throw new QueryException('Invalid regular expression');
3115+
}
31123116

3113-
return @\preg_match($delimited, $value) === 1;
3117+
return $matched === 1;
31143118
}
31153119

31163120
throw new QueryException('Query method not supported by Redis adapter: '.$method->value);

‎src/Database/Adapter/SQLite.php‎

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -271,19 +271,22 @@ private function registerUserFunctions(): void
271271
}
272272

273273
if (!isset($delimitedCache[$pattern])) {
274-
if (\count($delimitedCache) >= self::REGEXP_PATTERN_CACHE_LIMIT) {
275-
\array_shift($delimitedCache);
276-
}
277274
// Use a delimiter unlikely to appear in user input (chr(1))
278275
// so we don't have to escape forward-slashes the user wrote
279276
// intentionally and risk double-escaping backslashes.
280-
$delimitedCache[$pattern] = "\x01" . $pattern . "\x01u";
277+
$delimited = "\x01" . $pattern . "\x01u";
278+
if (@\preg_match($delimited, '') === false && \preg_last_error() === PREG_INTERNAL_ERROR) {
279+
throw new QueryException('Invalid regular expression');
280+
}
281+
if (\count($delimitedCache) >= self::REGEXP_PATTERN_CACHE_LIMIT) {
282+
\array_shift($delimitedCache);
283+
}
284+
$delimitedCache[$pattern] = $delimited;
281285
}
282286
$delimited = $delimitedCache[$pattern];
283287

284-
// preg_match returns false on bad pattern / runtime error
285-
// (e.g. PREG_BACKTRACK_LIMIT_ERROR). Silently treat those as
286-
// no-match — REGEXP is a query predicate, not a validator.
288+
// preg_match returns false on a runtime error such as
289+
// PREG_BACKTRACK_LIMIT_ERROR, which reads as no match.
287290
$result = @\preg_match($delimited, $value);
288291

289292
return $result === 1 ? 1 : 0;

‎tests/e2e/Adapter/Scopes/DocumentTests.php‎

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4908,6 +4908,40 @@ public function testFindRegex(): void
49084908
$database->deleteCollection('moviesRegex');
49094909
}
49104910

4911+
/**
4912+
* A pattern the engine cannot compile failed as an unmapped PDOException on the SQL engines and a raw driver
4913+
* error on MongoDB, and matched nothing on SQLite, Memory and Redis. Every adapter refuses it as a query error.
4914+
*/
4915+
public function testInvalidRegexIsAQueryError(): void
4916+
{
4917+
$database = $this->getDatabase();
4918+
$collection = 'invalid_regex_'.\substr(\uniqid(), -6);
4919+
$database->createCollection(Collection::create(
4920+
id: $collection,
4921+
attributes: [Attribute::string(key: 'text', size: 100, required: true)],
4922+
permissions: [Permission::create(Role::any()), Permission::read(Role::any())],
4923+
));
4924+
$database->createDocument($collection, new Document([
4925+
'text' => 'target',
4926+
'$permissions' => [Permission::read(Role::any())],
4927+
]));
4928+
4929+
foreach (['((', '[', 'a{2,1}', ')', '*a'] as $pattern) {
4930+
foreach (['find' => fn () => $database->find($collection, [Query::regex('text', $pattern)]), 'count' => fn () => $database->count($collection, [Query::regex('text', $pattern)])] as $read => $call) {
4931+
try {
4932+
$call();
4933+
$this->fail("{$read}() refuses the pattern {$pattern}");
4934+
} catch (QueryException $e) {
4935+
$this->assertSame('Invalid regular expression', $e->getMessage(), "{$read}() of {$pattern}");
4936+
}
4937+
}
4938+
}
4939+
4940+
$this->assertCount(1, $database->find($collection, [Query::regex('text', '^tar')]));
4941+
4942+
$database->deleteCollection($collection);
4943+
}
4944+
49114945
public function testRegexInjection(): void
49124946
{
49134947
/** @var Database $database */

‎tests/e2e/Adapter/Scopes/MongoReadFilterTests.php‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -147,8 +147,10 @@ public function testCountReportsDriverErrors(): void
147147
try {
148148
$database->getAdapter()->count($database->getCollection($collection), [Query::regex('name', '(')]);
149149
$this->fail('count() must report the driver error for an invalid regular expression instead of 0');
150-
} catch (MongoException $e) {
151-
$this->assertNotSame(0, $e->getCode());
150+
} catch (QueryException $e) {
151+
$this->assertSame('Invalid regular expression', $e->getMessage());
152+
$this->assertInstanceOf(MongoException::class, $e->getPrevious());
153+
$this->assertNotSame(0, $e->getPrevious()->getCode());
152154
} finally {
153155
$database->deleteCollection($collection);
154156
}

‎tests/unit/EngineErrorMappingTest.php‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,28 @@ public static function invalidInputProvider(): iterable
121121
foreach ($geometries as $name => $error) {
122122
yield "MySQL {$name}" => [self::mySQL(), $error, 'Invalid spatial value'];
123123
}
124+
125+
yield 'MariaDB invalid regular expression' => [
126+
self::mariaDB(),
127+
self::engineError('42000', 1139, "SQLSTATE[42000]: Syntax error or access violation: 1139 Regex error 'missing closing parenthesis at offset 2'"),
128+
'Invalid regular expression',
129+
];
130+
$patterns = [
131+
'mismatched parenthesis' => self::engineError('HY000', 3691, 'SQLSTATE[HY000]: General error: 3691 Mismatched parenthesis in regular expression.'),
132+
'unclosed bracket' => self::engineError('HY000', 3696, 'SQLSTATE[HY000]: General error: 3696 The regular expression contains an unclosed bracket expression.'),
133+
'interval maximum below minimum' => self::engineError('HY000', 3693, 'SQLSTATE[HY000]: General error: 3693 The maximum is less than the minumum in a {min,max} interval.'),
134+
'syntax error' => self::engineError('HY000', 3688, 'SQLSTATE[HY000]: General error: 3688 Syntax error in regular expression on line 1, character 1.'),
135+
'invalid capture group name' => self::engineError('HY000', 3887, 'SQLSTATE[HY000]: General error: 3887 A capture group has an invalid name.'),
136+
'error ICU only names' => self::engineError('42000', 1139, "SQLSTATE[42000]: Syntax error or access violation: 1139 Got error 'U_ILLEGAL_CHAR_FOUND' from regexp"),
137+
];
138+
foreach ($patterns as $name => $error) {
139+
yield "MySQL {$name}" => [self::mySQL(), $error, 'Invalid regular expression'];
140+
}
141+
yield 'Postgres invalid regular expression' => [
142+
self::postgres(),
143+
self::engineError('2201B', 7, 'SQLSTATE[2201B]: Invalid regular expression: 7 ERROR: invalid regular expression: parentheses () not balanced'),
144+
'Invalid regular expression',
145+
];
124146
}
125147

126148
/**

0 commit comments

Comments
 (0)