Skip to content

Commit 93a0575

Browse files
github-actions[bot]claude-bot
authored andcommitted
fix(sqlite): bound REGEXP cache and tighten error handling
- REGEXP UDF: cap pattern length at 512 chars and FIFO-evict the delimited-pattern cache at 256 entries. Switch the delimiter to chr(1) so user-supplied forward slashes / backslashes aren't re-escaped. Long-lived adapters can no longer be grown without bound by distinct user patterns. - resolveFulltextTableById: narrow the blanket Throwable catch to NotFoundException so genuine PDO errors don't fall through to the single-candidate FTS5 drop and tear down the wrong table. - setTenant / setNamespace / setSharedTables: invalidate the FTS table cache after the parent setter so a thrown validation doesn't leave us with a cleared cache against the prior value. - getSQLIndexType: throw on Database::INDEX_FULLTEXT instead of silently returning 'INDEX' — the FTS5 path goes through createFulltextIndex; reaching that case is a programmer error that should surface, not be papered over.
1 parent 464a47d commit 93a0575

1 file changed

Lines changed: 59 additions & 15 deletions

File tree

‎src/Database/Adapter/SQLite.php‎

Lines changed: 59 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -106,45 +106,86 @@ public function getEmulateMySQL(): bool
106106

107107
public function setTenant(int|string|null $tenant): bool
108108
{
109-
if ($this->tenant !== $tenant) {
109+
$changed = $this->tenant !== $tenant;
110+
$result = parent::setTenant($tenant);
111+
if ($changed) {
112+
// Invalidate after the parent setter so a validation failure
113+
// doesn't leave us with a cleared cache against the prior tenant.
110114
$this->ftsTableCache = [];
111115
}
112116

113-
return parent::setTenant($tenant);
117+
return $result;
114118
}
115119

116120
public function setNamespace(string $namespace): static
117121
{
122+
// Invalidate after the parent setter so a thrown validation
123+
// doesn't leave a cleared cache against the prior namespace.
124+
parent::setNamespace($namespace);
118125
$this->ftsTableCache = [];
119126

120-
return parent::setNamespace($namespace);
127+
return $this;
121128
}
122129

123130
public function setSharedTables(bool $sharedTables): bool
124131
{
125-
if ($this->sharedTables !== $sharedTables) {
132+
$changed = $this->sharedTables !== $sharedTables;
133+
$result = parent::setSharedTables($sharedTables);
134+
if ($changed) {
126135
$this->ftsTableCache = [];
127136
}
128137

129-
return parent::setSharedTables($sharedTables);
138+
return $result;
130139
}
131140

141+
/**
142+
* Reject patterns over this size to bound ReDoS exposure — the UDF runs
143+
* once per candidate row, so a pathological pattern is amplified by
144+
* table cardinality.
145+
*/
146+
private const REGEXP_MAX_PATTERN_LENGTH = 512;
147+
148+
/**
149+
* Cap on cached delimited patterns. Long-lived adapters processing many
150+
* distinct user patterns would otherwise grow this map without bound.
151+
*/
152+
private const REGEXP_PATTERN_CACHE_LIMIT = 256;
153+
132154
/**
133155
* Register a preg_match-backed REGEXP UDF so the inherited REGEXP
134156
* path resolves. Best-effort — non-SQLite PDOs simply skip it.
135157
*/
136158
private function registerUserFunctions(): void
137159
{
138-
// SQLite invokes the UDF once per candidate row; cache the
139-
// delimited pattern.
160+
// SQLite invokes the UDF once per candidate row, so cache the
161+
// delimited pattern across rows. FIFO-evict at REGEXP_PATTERN_CACHE_LIMIT
162+
// entries so distinct user patterns can't grow this without bound.
140163
$delimitedCache = [];
141164
$pcre = static function (?string $pattern, ?string $value) use (&$delimitedCache): int {
142165
if ($pattern === null || $value === null) {
143166
return 0;
144167
}
145-
$delimited = $delimitedCache[$pattern] ??= '/' . \str_replace('/', '\/', $pattern) . '/u';
168+
if (\strlen($pattern) > self::REGEXP_MAX_PATTERN_LENGTH) {
169+
return 0;
170+
}
171+
172+
if (!isset($delimitedCache[$pattern])) {
173+
if (\count($delimitedCache) >= self::REGEXP_PATTERN_CACHE_LIMIT) {
174+
\array_shift($delimitedCache);
175+
}
176+
// Use a delimiter unlikely to appear in user input (chr(1))
177+
// so we don't have to escape forward-slashes the user wrote
178+
// intentionally and risk double-escaping backslashes.
179+
$delimitedCache[$pattern] = "\x01" . $pattern . "\x01u";
180+
}
181+
$delimited = $delimitedCache[$pattern];
182+
183+
// preg_match returns false on bad pattern / runtime error
184+
// (e.g. PREG_BACKTRACK_LIMIT_ERROR). Silently treat those as
185+
// no-match — REGEXP is a query predicate, not a validator.
186+
$result = @\preg_match($delimited, $value);
146187

147-
return @\preg_match($delimited, $value) === 1 ? 1 : 0;
188+
return $result === 1 ? 1 : 0;
148189
};
149190

150191
try {
@@ -1016,7 +1057,11 @@ protected function resolveFulltextTableById(string $collection, string $id, arra
10161057
try {
10171058
$metadataCollection = new Document(['$id' => Database::METADATA]);
10181059
$collectionDoc = $this->getDocument($metadataCollection, $collection);
1019-
} catch (\Throwable) {
1060+
} catch (NotFoundException) {
1061+
// Metadata not yet seeded (collection drop during bootstrap).
1062+
// Anything else surfaces — masking PDO errors here would silently
1063+
// fall through to the single-candidate drop path and tear down
1064+
// the wrong table.
10201065
return null;
10211066
}
10221067

@@ -1674,11 +1719,10 @@ protected function getSQLIndexType(string $type): string
16741719
return 'UNIQUE INDEX';
16751720

16761721
case Database::INDEX_FULLTEXT:
1677-
// Fulltext is handled via FTS5 virtual tables, not regular
1678-
// CREATE INDEX, so this branch should never reach SQL
1679-
// generation. Returning a placeholder keeps the contract
1680-
// satisfied for any introspection callers.
1681-
return 'INDEX';
1722+
// Fulltext is handled via FTS5 virtual tables in
1723+
// createFulltextIndex; reaching this codepath means a
1724+
// caller bypassed that route and would emit invalid SQL.
1725+
throw new DatabaseException('Fulltext indexes use createFulltextIndex(), not getSQLIndexType');
16821726

16831727
default:
16841728
throw new DatabaseException('Unknown index type: ' . $type . '. Must be one of ' . Database::INDEX_KEY . ', ' . Database::INDEX_UNIQUE . ', ' . Database::INDEX_FULLTEXT);

0 commit comments

Comments
 (0)