Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions src/Database/Adapter/MariaDB.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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);
}
Expand Down
6 changes: 6 additions & 0 deletions src/Database/Adapter/Memory.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}
Expand Down
4 changes: 4 additions & 0 deletions src/Database/Adapter/Mongo.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
8 changes: 8 additions & 0 deletions src/Database/Adapter/MySQL.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand Down
4 changes: 4 additions & 0 deletions src/Database/Adapter/Postgres.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
6 changes: 5 additions & 1 deletion src/Database/Adapter/Redis.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
17 changes: 10 additions & 7 deletions src/Database/Adapter/SQLite.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
34 changes: 34 additions & 0 deletions tests/e2e/Adapter/Scopes/DocumentTests.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand Down
6 changes: 4 additions & 2 deletions tests/e2e/Adapter/Scopes/MongoReadFilterTests.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
22 changes: 22 additions & 0 deletions tests/unit/EngineErrorMappingTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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',
];
}

/**
Expand Down
66 changes: 66 additions & 0 deletions tests/unit/MemoryRegexTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
<?php

namespace Tests\Unit;

use PHPUnit\Framework\TestCase;
use Utopia\Cache\Adapter\None;
use Utopia\Cache\Cache;
use Utopia\Database\Adapter\Memory;
use Utopia\Database\Attribute;
use Utopia\Database\Collection;
use Utopia\Database\Database;
use Utopia\Database\Document;
use Utopia\Database\Exception\Query as QueryException;
use Utopia\Database\Permission;
use Utopia\Database\Query;
use Utopia\Database\Role;
use Utopia\Database\Validator\Authorization;

/**
* The Memory adapter matches a regex with PCRE, and refuses a pattern PCRE cannot compile as the engines do instead
* of reading it as matching nothing.
*/
final class MemoryRegexTest extends TestCase
{
private const string COLLECTION = 'words';

private function database(): Database
{
$database = new Database(new Memory(), new Cache(new None()));
$database
->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$')]));
}
}
15 changes: 15 additions & 0 deletions tests/unit/SQLiteRegexTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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:'));
Expand Down
Loading