diff --git a/src/Database/Adapter/MariaDB.php b/src/Database/Adapter/MariaDB.php index 78a09bbe5..7b86d6d00 100644 --- a/src/Database/Adapter/MariaDB.php +++ b/src/Database/Adapter/MariaDB.php @@ -1050,6 +1050,11 @@ protected function decodePolygon(string $wkb): array */ protected const array INVALID_GEOMETRY_ERRORS = [3037]; + /** + * 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; @@ -1614,6 +1619,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 (isset($e->errorInfo[1]) && \in_array($e->errorInfo[1], static::INVALID_GEOMETRY_ERRORS, true)) { return new QueryException('Invalid spatial value', $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 2350b8d0e..8a7a2fb91 100644 --- a/src/Database/Adapter/MySQL.php +++ b/src/Database/Adapter/MySQL.php @@ -122,6 +122,14 @@ protected function processException(PDOException $e): Exception */ protected const array INVALID_GEOMETRY_ERRORS = [3037, 3616, 3617]; + /** + * 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/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); } diff --git a/tests/unit/EngineErrorMappingTest.php b/tests/unit/EngineErrorMappingTest.php index 6ad0bf2cb..79f95cdb2 100644 --- a/tests/unit/EngineErrorMappingTest.php +++ b/tests/unit/EngineErrorMappingTest.php @@ -121,6 +121,28 @@ public static function invalidInputProvider(): iterable foreach ($geometries as $name => $error) { yield "MySQL {$name}" => [self::mySQL(), $error, 'Invalid spatial value']; } + + 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', + ]; } /** 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:'));