Root cause
Injection & unsafe execution introduced
fix: use pipe character as LIKE ESCAPE to work across all databases
How AI contributed: Direct introductionPublished Aug 4, 2026 in
thorsten/phpmyfaqAll 2 findings in this repository
When phpMyFAQ uses PostgreSQL through the native pgsql PHP extension, public FAQ search interpolates the unauthenticated search parameter into Pgsql::search() as (%s) ILIKE ('%%%s%%') after SearchDatabase::escapeLikeWildcards(). That helper prefixes user % and _ with |, which only disables LIKE wildcards if the SQL also declares ESCAPE '|'. Native Pgsql.php did not: the commit that introduced the | escaping declared no ESCAPE clause on this statement, and a later 4.1 merge left it declaring ESCAPE '=' (PostgreSQL's default LIKE escape is backslash). In both states | is a literal, so attacker-supplied %/_ stay active wildcards, forcing maximally broad ILIKE matches and expensive sequential scans (availability DoS). Quotes are still passed through getDb()->escape(), so this is not quote-breaking SQL injection. The PDO sibling PdoPgsql.php declares ESCAPE '|' and is not affected.
Root cause
fix: use pipe character as LIKE ESCAPE to work across all databases
Fix
fix(search): align native pgsql LIKE ESCAPE character with the wildcard escaper
Fix by Thorsten Rinne · no AI marker found
Case facts
@@ -264,7 +264,7 @@ public function getMatchClause(string $searchTerm = ''): string } $where = sprintf(- "%s%s LIKE '%%%s%%' ESCAPE '\\\\'",+ "%s%s LIKE '%%%s%%' ESCAPE '|'", $where, $this->matchingColumns[$j], self::escapeLikeWildcards($this->configuration->getDb()->escape($keys[$i])),@@ -292,6 +292,6 @@ public function disableRelevance(): void */ protected static function escapeLikeWildcards(string $term): string {- return str_replace(['\\', '%', '_'], ['\\\\', '\\%', '\\_'], $term);+ return str_replace(['|', '%', '_'], ['||', '|%', '|_'], $term); } }@@ -74,7 +74,7 @@ public function search(string $searchTerm): mixed FROM %s %s %s %s WHERE- (%s) ILIKE ('%%%s%%') ESCAPE '\\\\'+ (%s) ILIKE ('%%%s%%') ESCAPE '|' %s %s", $columns,@@ -247,9 +247,9 @@ private function searchCustomPages(string $searchTerm, bool $allLanguages = true } // Escape LIKE metacharacters (%, _) to prevent wildcard injection- $escapedWord = str_replace(['\\', '%', '_'], ['\\\\', '\\%', '\\_'], $word);+ $escapedWord = str_replace(['|', '%', '_'], ['||', '|%', '|_'], $word); $searchConditions[] = sprintf(- "(page_title LIKE '%%%s%%' ESCAPE '\\\\' OR content LIKE '%%%s%%' ESCAPE '\\\\')",+ "(page_title LIKE '%%%s%%' ESCAPE '|' OR content LIKE '%%%s%%' ESCAPE '|')", $escapedWord, $escapedWord, );@@ -28,6 +28,13 @@ */ class SearchDatabase extends AbstractSearch implements SearchInterface {+ /**+ * LIKE/ILIKE escape character. Every ESCAPE clause built by this class or+ * its driver subclasses must use this character, otherwise the wildcard+ * escaping in escapeLikeWildcards() is silently disabled.+ */+ protected const string LIKE_ESCAPE_CHARACTER = '|';+ /** * Searching database table. */@@ -282,10 +289,11 @@ public function getMatchClause(string $searchTerm = ''): string } $where = sprintf(- "%s%s LIKE '%%%s%%' ESCAPE '|'",+ "%s%s LIKE '%%%s%%' ESCAPE '%s'", $where, $this->matchingColumns[$j], self::escapeLikeWildcards($this->configuration->getDb()->escape((string) $keys[$i])),+ self::LIKE_ESCAPE_CHARACTER, ); } @@ -310,6 +318,8 @@ public function disableRelevance(): void */ protected static function escapeLikeWildcards(string $term): string {- return str_replace(['|', '%', '_'], ['||', '|%', '|_'], $term);+ $escape = self::LIKE_ESCAPE_CHARACTER;++ return str_replace([$escape, '%', '_'], [$escape . $escape, $escape . '%', $escape . '_'], $term); } }@@ -74,7 +74,7 @@ public function search(string $searchTerm): mixed FROM %s %s %s %s WHERE- (%s) ILIKE ('%%%s%%') ESCAPE '='+ (%s) ILIKE ('%%%s%%') ESCAPE '%s' %s %s", $columns,@@ -86,6 +86,7 @@ public function search(string $searchTerm): mixed : '', $this->getMatchingColumns(), self::escapeLikeWildcards($this->configuration->getDb()->escape($searchTerm)),+ self::LIKE_ESCAPE_CHARACTER, $this->getConditions(), $orderBy, );@@ -74,7 +74,7 @@ public function search(string $searchTerm): mixed FROM %s %s %s %s WHERE- (%s) ILIKE ('%%%s%%') ESCAPE '|'+ (%s) ILIKE ('%%%s%%') ESCAPE '%s' %s %s", $columns,@@ -86,6 +86,7 @@ public function search(string $searchTerm): mixed : '', $this->getMatchingColumns(), self::escapeLikeWildcards($this->configuration->getDb()->escape($searchTerm)),+ self::LIKE_ESCAPE_CHARACTER, $this->getConditions(), $orderBy, );AI-assisted change 95699c648d832676e803ff14f93ca21bf1360df23223fd70a959d4b02316a649 · Fix 5644cac5dae8bf767eca57f778f2da851ad635a60bd8ac388495aea9b9cfd1ce