From 46758ee8ffac158edf6adb60b870072468b48555 Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Thu, 15 Oct 2026 22:44:03 +1300 Subject: [PATCH 1/2] fix(adapters): refuse a regex the engine cannot compile as a query error A regex filter with a pattern such as `((` or `[` failed with an unmapped PDOException on MariaDB (1139), MySQL (3685 to 3700, 3887) and PostgreSQL (2201B), and a raw driver error on MongoDB (51091), which consumers report as a server error. SQLite, Memory and Redis match with PCRE and read such a pattern as matching nothing. 7.4.2 maps none of them either. Each engine's invalid pattern errors map to Exception\Query, and the PCRE adapters refuse a pattern that does not compile with the same error. The pattern is not checked up front: each engine has its own dialect (PCRE2, ICU, ARE), so a pattern valid on one may not compile on another. Co-Authored-By: Claude Opus 5.5 --- src/Database/Adapter/MariaDB.php | 9 +++ src/Database/Adapter/Memory.php | 6 ++ src/Database/Adapter/Mongo.php | 4 ++ src/Database/Adapter/MySQL.php | 8 +++ src/Database/Adapter/Postgres.php | 4 ++ src/Database/Adapter/Redis.php | 6 +- src/Database/Adapter/SQLite.php | 17 +++--- tests/e2e/Adapter/Scopes/DocumentTests.php | 34 +++++++++++ tests/unit/EngineErrorMappingTest.php | 37 ++++++++++++ tests/unit/MemoryRegexTest.php | 66 ++++++++++++++++++++++ tests/unit/SQLiteRegexTest.php | 15 +++++ 11 files changed, 198 insertions(+), 8 deletions(-) create mode 100644 tests/unit/MemoryRegexTest.php diff --git a/src/Database/Adapter/MariaDB.php b/src/Database/Adapter/MariaDB.php index 140b24864..54324f0d0 100644 --- a/src/Database/Adapter/MariaDB.php +++ b/src/Database/Adapter/MariaDB.php @@ -1045,6 +1045,11 @@ protected function decodePolygon(string $wkb): array private const string TIMEOUT_SETTING = 'timeout'; + /** + * ER_REGEXP_ERROR, for a pattern PCRE cannot compile or run. + */ + protected const array INVALID_REGEX_ERRORS = [1139]; + /** The session timeout last set, in milliseconds. */ private int $appliedTimeout = 0; @@ -1609,6 +1614,10 @@ protected function processException(PDOException $e): Exception return new LimitException('Value is out of range', $e->getCode(), $e); } + if (isset($e->errorInfo[1]) && \in_array($e->errorInfo[1], static::INVALID_REGEX_ERRORS, true)) { + return new QueryException('Invalid regular expression', $e->getCode(), $e); + } + if ($e->getCode() === '42000' && isset($e->errorInfo[1]) && $e->errorInfo[1] === 1049) { return new NotFoundException('Database not found', $e->getCode(), $e); } diff --git a/src/Database/Adapter/Memory.php b/src/Database/Adapter/Memory.php index d44e929f4..8ba136a0c 100644 --- a/src/Database/Adapter/Memory.php +++ b/src/Database/Adapter/Memory.php @@ -13,6 +13,7 @@ use Utopia\Database\Exception\Limit as LimitException; use Utopia\Database\Exception\NotFound as NotFoundException; use Utopia\Database\Exception\Operator as OperatorException; +use Utopia\Database\Exception\Query as QueryException; use Utopia\Database\Exception\Unique as UniqueException; use Utopia\Database\Index; use Utopia\Database\Operator; @@ -2783,11 +2784,16 @@ protected function tokenize(string $text): array * Apply the supplied regex against $value. Pattern is the raw expression * — wrap it in delimiters before passing to preg_match, mirroring how * MariaDB's REGEXP operator accepts the pattern verbatim. + * + * @throws QueryException When the pattern does not compile */ protected function matchesRegex(string $value, string $pattern): bool { $delimited = '#'.\str_replace('#', '\\#', $pattern).'#u'; $matched = @\preg_match($delimited, $value); + if ($matched === false && \preg_last_error() === PREG_INTERNAL_ERROR) { + throw new QueryException('Invalid regular expression'); + } return $matched === 1; } diff --git a/src/Database/Adapter/Mongo.php b/src/Database/Adapter/Mongo.php index afd8aeaf7..e43499183 100644 --- a/src/Database/Adapter/Mongo.php +++ b/src/Database/Adapter/Mongo.php @@ -3887,6 +3887,10 @@ protected function processException(Throwable $e): Throwable return new TypeException('Invalid operation', $e->getCode(), $e); } + if ($e->getCode() === 51091) { + return new QueryException('Invalid regular expression', $e->getCode(), $e); + } + if ($e->getCode() === 28764) { return new LimitException('Value out of range', $e->getCode(), $e); } diff --git a/src/Database/Adapter/MySQL.php b/src/Database/Adapter/MySQL.php index d024ade55..627c90119 100644 --- a/src/Database/Adapter/MySQL.php +++ b/src/Database/Adapter/MySQL.php @@ -117,6 +117,14 @@ protected function processException(PDOException $e): Exception return parent::processException($e); } + /** + * ER_REGEXP_ERROR, which MySQL raises for an error ICU only names, and the errors for a pattern ICU cannot compile + * or run: illegal argument, rule syntax, bad escape sequence, unimplemented, mismatched parenthesis, bad interval, + * max below min, invalid back reference, look-behind limit, missing close bracket, invalid range, stack overflow, + * pattern too big and invalid capture group name. ER_REGEXP_TIME_OUT is a timeout. + */ + protected const array INVALID_REGEX_ERRORS = [1139, 3685, 3688, 3689, 3690, 3691, 3692, 3693, 3694, 3695, 3696, 3697, 3698, 3700, 3887]; + #[\Override] protected function dialectBuilder(): SQLBuilder&Scoping { diff --git a/src/Database/Adapter/Postgres.php b/src/Database/Adapter/Postgres.php index 70fbdf49e..24e3536e0 100644 --- a/src/Database/Adapter/Postgres.php +++ b/src/Database/Adapter/Postgres.php @@ -1946,6 +1946,10 @@ protected function processException(PDOException $e): Exception return new LimitException('Numeric value out of range', $e->getCode(), $e); } + if ($e->getCode() === '2201B' && isset($e->errorInfo[1]) && $e->errorInfo[1] === 7) { + return new QueryException('Invalid regular expression', $e->getCode(), $e); + } + if ($e->getCode() === '2201F' && isset($e->errorInfo[1]) && $e->errorInfo[1] === 7) { return new LimitException('Invalid argument for power function', $e->getCode(), $e); } diff --git a/src/Database/Adapter/Redis.php b/src/Database/Adapter/Redis.php index efcc16259..429dc767a 100644 --- a/src/Database/Adapter/Redis.php +++ b/src/Database/Adapter/Redis.php @@ -3109,8 +3109,12 @@ private function matchesDocument(Document $document, Query $query): bool } $pattern = $this->stringOrEmpty($values[0] ?? ''); $delimited = '#'.\str_replace('#', '\\#', $pattern).'#u'; + $matched = @\preg_match($delimited, $value); + if ($matched === false && \preg_last_error() === PREG_INTERNAL_ERROR) { + throw new QueryException('Invalid regular expression'); + } - return @\preg_match($delimited, $value) === 1; + return $matched === 1; } throw new QueryException('Query method not supported by Redis adapter: '.$method->value); diff --git a/src/Database/Adapter/SQLite.php b/src/Database/Adapter/SQLite.php index 238dbe27e..1922b4b75 100644 --- a/src/Database/Adapter/SQLite.php +++ b/src/Database/Adapter/SQLite.php @@ -271,19 +271,22 @@ private function registerUserFunctions(): void } if (!isset($delimitedCache[$pattern])) { - if (\count($delimitedCache) >= self::REGEXP_PATTERN_CACHE_LIMIT) { - \array_shift($delimitedCache); - } // Use a delimiter unlikely to appear in user input (chr(1)) // so we don't have to escape forward-slashes the user wrote // intentionally and risk double-escaping backslashes. - $delimitedCache[$pattern] = "\x01" . $pattern . "\x01u"; + $delimited = "\x01" . $pattern . "\x01u"; + if (@\preg_match($delimited, '') === false && \preg_last_error() === PREG_INTERNAL_ERROR) { + throw new QueryException('Invalid regular expression'); + } + if (\count($delimitedCache) >= self::REGEXP_PATTERN_CACHE_LIMIT) { + \array_shift($delimitedCache); + } + $delimitedCache[$pattern] = $delimited; } $delimited = $delimitedCache[$pattern]; - // preg_match returns false on bad pattern / runtime error - // (e.g. PREG_BACKTRACK_LIMIT_ERROR). Silently treat those as - // no-match — REGEXP is a query predicate, not a validator. + // preg_match returns false on a runtime error such as + // PREG_BACKTRACK_LIMIT_ERROR, which reads as no match. $result = @\preg_match($delimited, $value); return $result === 1 ? 1 : 0; diff --git a/tests/e2e/Adapter/Scopes/DocumentTests.php b/tests/e2e/Adapter/Scopes/DocumentTests.php index bab056537..8cd3ffc45 100644 --- a/tests/e2e/Adapter/Scopes/DocumentTests.php +++ b/tests/e2e/Adapter/Scopes/DocumentTests.php @@ -4908,6 +4908,40 @@ public function testFindRegex(): void $database->deleteCollection('moviesRegex'); } + /** + * A pattern the engine cannot compile failed as an unmapped PDOException on the SQL engines and a raw driver + * error on MongoDB, and matched nothing on SQLite, Memory and Redis. Every adapter refuses it as a query error. + */ + public function testInvalidRegexIsAQueryError(): void + { + $database = $this->getDatabase(); + $collection = 'invalid_regex_'.\substr(\uniqid(), -6); + $database->createCollection(Collection::create( + id: $collection, + attributes: [Attribute::string(key: 'text', size: 100, required: true)], + permissions: [Permission::create(Role::any()), Permission::read(Role::any())], + )); + $database->createDocument($collection, new Document([ + 'text' => 'target', + '$permissions' => [Permission::read(Role::any())], + ])); + + foreach (['((', '[', 'a{2,1}', ')', '*a'] as $pattern) { + foreach (['find' => fn () => $database->find($collection, [Query::regex('text', $pattern)]), 'count' => fn () => $database->count($collection, [Query::regex('text', $pattern)])] as $read => $call) { + try { + $call(); + $this->fail("{$read}() refuses the pattern {$pattern}"); + } catch (QueryException $e) { + $this->assertSame('Invalid regular expression', $e->getMessage(), "{$read}() of {$pattern}"); + } + } + } + + $this->assertCount(1, $database->find($collection, [Query::regex('text', '^tar')])); + + $database->deleteCollection($collection); + } + public function testRegexInjection(): void { /** @var Database $database */ diff --git a/tests/unit/EngineErrorMappingTest.php b/tests/unit/EngineErrorMappingTest.php index 4f7f0144e..3d5613622 100644 --- a/tests/unit/EngineErrorMappingTest.php +++ b/tests/unit/EngineErrorMappingTest.php @@ -105,6 +105,43 @@ public function testLockConflictsMissingTablesAndBadCharactersAreMapped(Closure $this->assertMapped($map, $error, $expected, $message); } + /** + * @return iterable + */ + public static function invalidInputProvider(): iterable + { + yield 'MariaDB invalid regular expression' => [ + self::mariaDB(), + self::engineError('42000', 1139, "SQLSTATE[42000]: Syntax error or access violation: 1139 Regex error 'missing closing parenthesis at offset 2'"), + 'Invalid regular expression', + ]; + $patterns = [ + 'mismatched parenthesis' => self::engineError('HY000', 3691, 'SQLSTATE[HY000]: General error: 3691 Mismatched parenthesis in regular expression.'), + 'unclosed bracket' => self::engineError('HY000', 3696, 'SQLSTATE[HY000]: General error: 3696 The regular expression contains an unclosed bracket expression.'), + '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.'), + 'syntax error' => self::engineError('HY000', 3688, 'SQLSTATE[HY000]: General error: 3688 Syntax error in regular expression on line 1, character 1.'), + 'invalid capture group name' => self::engineError('HY000', 3887, 'SQLSTATE[HY000]: General error: 3887 A capture group has an invalid name.'), + 'error ICU only names' => self::engineError('42000', 1139, "SQLSTATE[42000]: Syntax error or access violation: 1139 Got error 'U_ILLEGAL_CHAR_FOUND' from regexp"), + ]; + foreach ($patterns as $name => $error) { + yield "MySQL {$name}" => [self::mySQL(), $error, 'Invalid regular expression']; + } + yield 'Postgres invalid regular expression' => [ + self::postgres(), + self::engineError('2201B', 7, 'SQLSTATE[2201B]: Invalid regular expression: 7 ERROR: invalid regular expression: parentheses () not balanced'), + 'Invalid regular expression', + ]; + } + + /** + * @param Closure(PDOException): Throwable $map + */ + #[DataProvider('invalidInputProvider')] + public function testInvalidInputTheEngineRefusesIsAQueryError(Closure $map, PDOException $error, string $message): void + { + $this->assertMapped($map, $error, QueryException::class, $message); + } + /** * @return array, 2: string}> */ diff --git a/tests/unit/MemoryRegexTest.php b/tests/unit/MemoryRegexTest.php new file mode 100644 index 000000000..8b7f180cc --- /dev/null +++ b/tests/unit/MemoryRegexTest.php @@ -0,0 +1,66 @@ +setDatabase('regex') + ->setNamespace('regex') + ->setAuthorization(new Authorization()); + $database->create(); + + $database->createCollection(Collection::create( + id: self::COLLECTION, + attributes: [Attribute::string('name', size: 32)], + permissions: [Permission::create(Role::any()), Permission::read(Role::any())], + )); + foreach (['abc', 'axc'] as $name) { + $database->createDocument(self::COLLECTION, new Document(['name' => $name])); + } + + return $database; + } + + public function testAPatternThatDoesNotCompileIsAQueryError(): void + { + $database = $this->database(); + + foreach (['((', '[', 'a{2,1}'] as $pattern) { + try { + $database->find(self::COLLECTION, [Query::regex('name', $pattern)]); + $this->fail("The pattern {$pattern} is refused"); + } catch (QueryException $e) { + $this->assertSame('Invalid regular expression', $e->getMessage()); + } + } + } + + public function testAPatternThatCompilesStillMatches(): void + { + $this->assertCount(1, $this->database()->find(self::COLLECTION, [Query::regex('name', '^a[b]c$')])); + } +} diff --git a/tests/unit/SQLiteRegexTest.php b/tests/unit/SQLiteRegexTest.php index b22acf5ae..48efce508 100644 --- a/tests/unit/SQLiteRegexTest.php +++ b/tests/unit/SQLiteRegexTest.php @@ -12,6 +12,7 @@ use Utopia\Database\Collection; use Utopia\Database\Database; use Utopia\Database\Document; +use Utopia\Database\Exception\Query as QueryException; use Utopia\Database\PDO as DatabasePDO; use Utopia\Database\Permission; use Utopia\Database\Query; @@ -43,6 +44,20 @@ public function testRegexMatchesThroughTheUserFunction(): void $this->assertSame(3, $database->count(self::COLLECTION, [Query::regex('name', self::PATTERN)])); } + public function testAPatternThatDoesNotCompileIsAQueryError(): void + { + $database = $this->database(); + + foreach (['((', '[', 'a{2,1}'] as $pattern) { + try { + $database->find(self::COLLECTION, [Query::regex('name', $pattern)]); + $this->fail("The pattern {$pattern} is refused"); + } catch (QueryException $e) { + $this->assertSame('Invalid regular expression', $e->getMessage()); + } + } + } + public function testRegexNeedsTheUserFunction(): void { $database = $this->database(new PDO('sqlite::memory:')); From c13a7509f2509309f230b4e231e1c43890bea2f6 Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Fri, 16 Oct 2026 16:56:29 +1300 Subject: [PATCH 2/2] test(mongo): expect a regex the driver cannot compile as a query error testCountReportsDriverErrors pinned the raw driver exception to prove count() reports the error rather than answering 0. It now arrives as Exception\Query with the driver error as its previous, which still proves that. Co-Authored-By: Claude Opus 5.5 --- tests/e2e/Adapter/Scopes/MongoReadFilterTests.php | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/e2e/Adapter/Scopes/MongoReadFilterTests.php b/tests/e2e/Adapter/Scopes/MongoReadFilterTests.php index 8ede7b8b2..ed4584c01 100644 --- a/tests/e2e/Adapter/Scopes/MongoReadFilterTests.php +++ b/tests/e2e/Adapter/Scopes/MongoReadFilterTests.php @@ -147,8 +147,10 @@ public function testCountReportsDriverErrors(): void try { $database->getAdapter()->count($database->getCollection($collection), [Query::regex('name', '(')]); $this->fail('count() must report the driver error for an invalid regular expression instead of 0'); - } catch (MongoException $e) { - $this->assertNotSame(0, $e->getCode()); + } catch (QueryException $e) { + $this->assertSame('Invalid regular expression', $e->getMessage()); + $this->assertInstanceOf(MongoException::class, $e->getPrevious()); + $this->assertNotSame(0, $e->getPrevious()->getCode()); } finally { $database->deleteCollection($collection); }