Przeglądaj źródła

Fix search encoding and quoting (#8311)

Revised the encoding approach for searches: the HTML encoding is done just before its use for DB search.
Fix also some cases with wrong quoting.
Fix https://github.com/FreshRSS/FreshRSS/pull/8306#issuecomment-3643865439
Follow-up of https://github.com/FreshRSS/FreshRSS/pull/8293
Alexandre Alapetite 3 miesięcy temu
rodzic
commit
a8a544a2a2

+ 7 - 7
app/Controllers/entryController.php

@@ -45,14 +45,14 @@ class FreshRSS_entry_Controller extends FreshRSS_ActionController {
 	 *   - is_read (default: true)
 	 */
 	public function readAction(): void {
-		$get = Minz_Request::paramString('get');
-		$next_get = Minz_Request::paramString('nextGet') ?: $get;
-		$id_max = Minz_Request::paramString('idMax');
+		$get = Minz_Request::paramString('get', plaintext: true);
+		$next_get = Minz_Request::paramString('nextGet', plaintext: true) ?: $get;
+		$id_max = Minz_Request::paramString('idMax', plaintext: true);
 		if (!ctype_digit($id_max)) {
 			$id_max = '0';
 		}
 		$is_read = Minz_Request::paramTernary('is_read') ?? true;
-		FreshRSS_Context::$search = new FreshRSS_BooleanSearch(Minz_Request::paramString('search'));
+		FreshRSS_Context::$search = new FreshRSS_BooleanSearch(Minz_Request::paramString('search', plaintext: true));
 		$maxPubDate = Minz_Request::paramInt('maxPubDate');
 		if ($maxPubDate > 0) {
 			$search = new FreshRSS_Search('');
@@ -170,8 +170,8 @@ class FreshRSS_entry_Controller extends FreshRSS_ActionController {
 			}
 		} else {
 			/** @var list<numeric-string> $idArray */
-			$idArray = Minz_Request::paramArrayString('id');
-			$idString = Minz_Request::paramString('id');
+			$idArray = Minz_Request::paramArrayString('id', plaintext: true);
+			$idString = Minz_Request::paramString('id', plaintext: true);
 			if (count($idArray) > 0) {
 				$ids = $idArray;
 			} elseif (ctype_digit($idString)) {
@@ -218,7 +218,7 @@ class FreshRSS_entry_Controller extends FreshRSS_ActionController {
 	 * If id is false, nothing happened.
 	 */
 	public function bookmarkAction(): void {
-		$id = Minz_Request::paramString('id');
+		$id = Minz_Request::paramString('id', plaintext: true);
 		$is_favourite = Minz_Request::paramTernary('is_favorite') ?? true;
 		if ($id != '' && ctype_digit($id)) {
 			$entryDAO = FreshRSS_Factory::createEntryDao();

+ 1 - 11
app/Models/BooleanSearch.php

@@ -26,16 +26,6 @@ class FreshRSS_BooleanSearch implements \Stringable {
 		if ($input === '') {
 			return;
 		}
-		if ($level === 0) {
-			$input = preg_replace('/:&quot;(.*?)&quot;/', ':"\1"', $input);
-			if (!is_string($input)) {
-				return;
-			}
-			$input = preg_replace('/(?<=[\s(!-]|^)&quot;(.*?)&quot;/', '"\1"', $input);
-			if (!is_string($input)) {
-				return;
-			}
-		}
 		$this->raw_input = $input;
 
 		if ($level === 0) {
@@ -517,7 +507,7 @@ class FreshRSS_BooleanSearch implements \Stringable {
 			if ($part === '') {
 				continue;
 			}
-			$operator = $search instanceof FreshRSS_BooleanSearch ? $search->operator() : 'OR';
+			$operator = $search instanceof FreshRSS_BooleanSearch ? $search->operator : 'OR';
 
 			if ((str_contains($part, ' ') || str_starts_with($part, '-')) && (count($this->searches) > 1 || in_array($operator, ['OR NOT', 'AND NOT'], true))) {
 				$part = '(' . $part . ')';

+ 1 - 1
app/Models/Context.php

@@ -248,7 +248,7 @@ final class FreshRSS_Context {
 			}
 		}
 
-		self::$search = new FreshRSS_BooleanSearch(Minz_Request::paramString('search'));
+		self::$search = new FreshRSS_BooleanSearch(Minz_Request::paramString('search', plaintext: true));
 		$order = Minz_Request::paramString('order', plaintext: true) ?: FreshRSS_Context::userConf()->sort_order;
 		self::$order = in_array($order, ['ASC', 'DESC'], true) ? $order : 'DESC';
 		$sort = Minz_Request::paramString('sort', plaintext: true) ?: FreshRSS_Context::userConf()->sort;

+ 128 - 130
app/Models/Search.php

@@ -155,13 +155,19 @@ class FreshRSS_Search implements \Stringable {
 	}
 
 	private static function quote(string $s): string {
-		if (str_contains($s, ' ') || $s === '') {
+		if (strpbrk($s, ' "\'\\') !== false || $s === '') {
 			return '"' . addcslashes($s, '\\"') . '"';
 		}
 		return $s;
 	}
 
-	private static function dateIntervalToString(?int $min, ?int $max): string {
+	private static function dateIntervalToString(int|false|null $min, int|false|null $max): string {
+		if ($min === false) {
+			$min = null;
+		}
+		if ($max === false) {
+			$max = null;
+		}
 		if ($min === null && $max === null) {
 			return '';
 		}
@@ -261,184 +267,184 @@ class FreshRSS_Search implements \Stringable {
 	public function __toString(): string {
 		$result = '';
 
-		if ($this->getEntryIds() !== null) {
-			$result .= ' e:' . implode(',', $this->getEntryIds());
+		if ($this->entry_ids !== null) {
+			$result .= ' e:' . implode(',', $this->entry_ids);
 		}
-		if ($this->getFeedIds() !== null) {
-			$result .= ' f:' . implode(',', $this->getFeedIds());
+		if ($this->feed_ids !== null) {
+			$result .= ' f:' . implode(',', $this->feed_ids);
 		}
-		if ($this->getCategoryIds() !== null) {
-			$result .= ' c:' . implode(',', $this->getCategoryIds());
+		if ($this->category_ids !== null) {
+			$result .= ' c:' . implode(',', $this->category_ids);
 		}
-		if ($this->getLabelIds() !== null) {
-			foreach ($this->getLabelIds() as $ids) {
+		if ($this->label_ids !== null) {
+			foreach ($this->label_ids as $ids) {
 				$result .= ' L:' . (is_array($ids) ? implode(',', $ids) : $ids);
 			}
 		}
-		if ($this->getLabelNames() !== null) {
-			foreach ($this->getLabelNames() as $names) {
+		if ($this->label_names !== null) {
+			foreach ($this->label_names as $names) {
 				$result .= ' labels:' . self::quote(implode(',', $names));
 			}
 		}
 
-		if ($this->getMinUserdate() !== null || $this->getMaxUserdate() !== null) {
-			$result .= ' userdate:' . self::dateIntervalToString($this->getMinUserdate(), $this->getMaxUserdate());
+		if ($this->min_userdate !== null || $this->max_userdate !== null) {
+			$result .= ' userdate:' . self::dateIntervalToString($this->min_userdate, $this->max_userdate);
 		}
-		if ($this->getMinPubdate() !== null || $this->getMaxPubdate() !== null) {
-			$result .= ' pubdate:' . self::dateIntervalToString($this->getMinPubdate(), $this->getMaxPubdate());
+		if ($this->min_pubdate !== null || $this->max_pubdate !== null) {
+			$result .= ' pubdate:' . self::dateIntervalToString($this->min_pubdate, $this->max_pubdate);
 		}
-		if ($this->getMinDate() !== null || $this->getMaxDate() !== null) {
-			$result .= ' date:' . self::dateIntervalToString($this->getMinDate(), $this->getMaxDate());
+		if ($this->min_date !== null || $this->max_date !== null) {
+			$result .= ' date:' . self::dateIntervalToString($this->min_date, $this->max_date);
 		}
 
-		if ($this->getIntitleRegex() !== null) {
-			foreach ($this->getIntitleRegex() as $s) {
+		if ($this->intitle_regex !== null) {
+			foreach ($this->intitle_regex as $s) {
 				$result .= ' intitle:' . $s;
 			}
 		}
-		if ($this->getIntitle() !== null) {
-			foreach ($this->getIntitle() as $s) {
+		if ($this->intitle !== null) {
+			foreach ($this->intitle as $s) {
 				$result .= ' intitle:' . self::quote($s);
 			}
 		}
-		if ($this->getIntextRegex() !== null) {
-			foreach ($this->getIntextRegex() as $s) {
+		if ($this->intext_regex !== null) {
+			foreach ($this->intext_regex as $s) {
 				$result .= ' intext:' . $s;
 			}
 		}
-		if ($this->getIntext() !== null) {
-			foreach ($this->getIntext() as $s) {
+		if ($this->intext !== null) {
+			foreach ($this->intext as $s) {
 				$result .= ' intext:' . self::quote($s);
 			}
 		}
-		if ($this->getAuthorRegex() !== null) {
-			foreach ($this->getAuthorRegex() as $s) {
+		if ($this->author_regex !== null) {
+			foreach ($this->author_regex as $s) {
 				$result .= ' author:' . $s;
 			}
 		}
-		if ($this->getAuthor() !== null) {
-			foreach ($this->getAuthor() as $s) {
+		if ($this->author !== null) {
+			foreach ($this->author as $s) {
 				$result .= ' author:' . self::quote($s);
 			}
 		}
-		if ($this->getInurlRegex() !== null) {
-			foreach ($this->getInurlRegex() as $s) {
+		if ($this->inurl_regex !== null) {
+			foreach ($this->inurl_regex as $s) {
 				$result .= ' inurl:' . $s;
 			}
 		}
-		if ($this->getInurl() !== null) {
-			foreach ($this->getInurl() as $s) {
+		if ($this->inurl !== null) {
+			foreach ($this->inurl as $s) {
 				$result .= ' inurl:' . self::quote($s);
 			}
 		}
-		if ($this->getTagsRegex() !== null) {
-			foreach ($this->getTagsRegex() as $s) {
+		if ($this->tags_regex !== null) {
+			foreach ($this->tags_regex as $s) {
 				$result .= ' #' . $s;
 			}
 		}
-		if ($this->getTags() !== null) {
-			foreach ($this->getTags() as $s) {
+		if ($this->tags !== null) {
+			foreach ($this->tags as $s) {
 				$result .= ' #' . self::quote($s);
 			}
 		}
-		if ($this->getSearchRegex() !== null) {
-			foreach ($this->getSearchRegex() as $s) {
+		if ($this->search_regex !== null) {
+			foreach ($this->search_regex as $s) {
 				$result .= ' ' . $s;
 			}
 		}
-		if ($this->getSearch() !== null) {
-			foreach ($this->getSearch() as $s) {
+		if ($this->search !== null) {
+			foreach ($this->search as $s) {
 				$result .= ' ' . self::quote($s);
 			}
 		}
 
-		if ($this->getNotEntryIds() !== null) {
-			$result .= ' -e:' . implode(',', $this->getNotEntryIds());
+		if ($this->not_entry_ids !== null) {
+			$result .= ' -e:' . implode(',', $this->not_entry_ids);
 		}
-		if ($this->getNotFeedIds() !== null) {
-			$result .= ' -f:' . implode(',', $this->getNotFeedIds());
+		if ($this->not_feed_ids !== null) {
+			$result .= ' -f:' . implode(',', $this->not_feed_ids);
 		}
-		if ($this->getNotCategoryIds() !== null) {
-			$result .= ' -c:' . implode(',', $this->getNotCategoryIds());
+		if ($this->not_category_ids !== null) {
+			$result .= ' -c:' . implode(',', $this->not_category_ids);
 		}
-		if ($this->getNotLabelIds() !== null) {
-			foreach ($this->getNotLabelIds() as $ids) {
+		if ($this->not_label_ids !== null) {
+			foreach ($this->not_label_ids as $ids) {
 				$result .= ' -L:' . (is_array($ids) ? implode(',', $ids) : $ids);
 			}
 		}
-		if ($this->getNotLabelNames() !== null) {
-			foreach ($this->getNotLabelNames() as $names) {
+		if ($this->not_label_names !== null) {
+			foreach ($this->not_label_names as $names) {
 				$result .= ' -labels:' . self::quote(implode(',', $names));
 			}
 		}
 
-		if ($this->getNotMinUserdate() !== null || $this->getNotMaxUserdate() !== null) {
-			$result .= ' -userdate:' . self::dateIntervalToString($this->getNotMinUserdate(), $this->getNotMaxUserdate());
+		if ($this->not_min_userdate !== null || $this->not_max_userdate !== null) {
+			$result .= ' -userdate:' . self::dateIntervalToString($this->not_min_userdate, $this->not_max_userdate);
 		}
-		if ($this->getNotMinPubdate() !== null || $this->getNotMaxPubdate() !== null) {
-			$result .= ' -pubdate:' . self::dateIntervalToString($this->getNotMinPubdate(), $this->getNotMaxPubdate());
+		if ($this->not_min_pubdate !== null || $this->not_max_pubdate !== null) {
+			$result .= ' -pubdate:' . self::dateIntervalToString($this->not_min_pubdate, $this->not_max_pubdate);
 		}
-		if ($this->getNotMinDate() !== null || $this->getNotMaxDate() !== null) {
-			$result .= ' -date:' . self::dateIntervalToString($this->getNotMinDate(), $this->getNotMaxDate());
+		if ($this->not_min_date !== null || $this->not_max_date !== null) {
+			$result .= ' -date:' . self::dateIntervalToString($this->not_min_date, $this->not_max_date);
 		}
 
-		if ($this->getNotIntitleRegex() !== null) {
-			foreach ($this->getNotIntitleRegex() as $s) {
+		if ($this->not_intitle_regex !== null) {
+			foreach ($this->not_intitle_regex as $s) {
 				$result .= ' -intitle:' . $s;
 			}
 		}
-		if ($this->getNotIntitle() !== null) {
-			foreach ($this->getNotIntitle() as $s) {
+		if ($this->not_intitle !== null) {
+			foreach ($this->not_intitle as $s) {
 				$result .= ' -intitle:' . self::quote($s);
 			}
 		}
-		if ($this->getNotIntextRegex() !== null) {
-			foreach ($this->getNotIntextRegex() as $s) {
+		if ($this->not_intext_regex !== null) {
+			foreach ($this->not_intext_regex as $s) {
 				$result .= ' -intext:' . $s;
 			}
 		}
-		if ($this->getNotIntext() !== null) {
-			foreach ($this->getNotIntext() as $s) {
+		if ($this->not_intext !== null) {
+			foreach ($this->not_intext as $s) {
 				$result .= ' -intext:' . self::quote($s);
 			}
 		}
-		if ($this->getNotAuthorRegex() !== null) {
-			foreach ($this->getNotAuthorRegex() as $s) {
+		if ($this->not_author_regex !== null) {
+			foreach ($this->not_author_regex as $s) {
 				$result .= ' -author:' . $s;
 			}
 		}
-		if ($this->getNotAuthor() !== null) {
-			foreach ($this->getNotAuthor() as $s) {
+		if ($this->not_author !== null) {
+			foreach ($this->not_author as $s) {
 				$result .= ' -author:' . self::quote($s);
 			}
 		}
-		if ($this->getNotInurlRegex() !== null) {
-			foreach ($this->getNotInurlRegex() as $s) {
+		if ($this->not_inurl_regex !== null) {
+			foreach ($this->not_inurl_regex as $s) {
 				$result .= ' -inurl:' . $s;
 			}
 		}
-		if ($this->getNotInurl() !== null) {
-			foreach ($this->getNotInurl() as $s) {
+		if ($this->not_inurl !== null) {
+			foreach ($this->not_inurl as $s) {
 				$result .= ' -inurl:' . self::quote($s);
 			}
 		}
-		if ($this->getNotTagsRegex() !== null) {
-			foreach ($this->getNotTagsRegex() as $s) {
+		if ($this->not_tags_regex !== null) {
+			foreach ($this->not_tags_regex as $s) {
 				$result .= ' -#' . $s;
 			}
 		}
-		if ($this->getNotTags() !== null) {
-			foreach ($this->getNotTags() as $s) {
+		if ($this->not_tags !== null) {
+			foreach ($this->not_tags as $s) {
 				$result .= ' -#' . self::quote($s);
 			}
 		}
-		if ($this->getNotSearchRegex() !== null) {
-			foreach ($this->getNotSearchRegex() as $s) {
+		if ($this->not_search_regex !== null) {
+			foreach ($this->not_search_regex as $s) {
 				$result .= ' -' . $s;
 			}
 		}
-		if ($this->getNotSearch() !== null) {
-			foreach ($this->getNotSearch() as $s) {
+		if ($this->not_search !== null) {
+			foreach ($this->not_search as $s) {
 				$result .= ' -' . self::quote($s);
 			}
 		}
@@ -486,25 +492,25 @@ class FreshRSS_Search implements \Stringable {
 		return $this->not_label_ids;
 	}
 	/** @return list<list<string>>|null */
-	public function getLabelNames(): ?array {
-		return $this->label_names;
+	public function getLabelNames(bool $plaintext = false): ?array {
+		return $plaintext ? $this->label_names : Minz_Helper::htmlspecialchars_utf8($this->label_names, ENT_NOQUOTES);
 	}
 	/** @return list<list<string>>|null */
-	public function getNotLabelNames(): ?array {
-		return $this->not_label_names;
+	public function getNotLabelNames(bool $plaintext = false): ?array {
+		return $plaintext ? $this->not_label_names : Minz_Helper::htmlspecialchars_utf8($this->not_label_names, ENT_NOQUOTES);
 	}
 
 	/** @return list<string>|null */
-	public function getIntitle(): ?array {
-		return $this->intitle;
+	public function getIntitle(bool $plaintext = false): ?array {
+		return $plaintext ? $this->intitle : Minz_Helper::htmlspecialchars_utf8($this->intitle, ENT_NOQUOTES);
 	}
 	/** @return list<string>|null */
 	public function getIntitleRegex(): ?array {
 		return $this->intitle_regex;
 	}
 	/** @return list<string>|null */
-	public function getNotIntitle(): ?array {
-		return $this->not_intitle;
+	public function getNotIntitle(bool $plaintext = false): ?array {
+		return $plaintext ? $this->not_intitle : Minz_Helper::htmlspecialchars_utf8($this->not_intitle, ENT_NOQUOTES);
 	}
 	/** @return list<string>|null */
 	public function getNotIntitleRegex(): ?array {
@@ -512,16 +518,16 @@ class FreshRSS_Search implements \Stringable {
 	}
 
 	/** @return list<string>|null */
-	public function getIntext(): ?array {
-		return $this->intext;
+	public function getIntext(bool $plaintext = false): ?array {
+		return $plaintext ? $this->intext : Minz_Helper::htmlspecialchars_utf8($this->intext, ENT_NOQUOTES);
 	}
 	/** @return list<string>|null */
 	public function getIntextRegex(): ?array {
 		return $this->intext_regex;
 	}
 	/** @return list<string>|null */
-	public function getNotIntext(): ?array {
-		return $this->not_intext;
+	public function getNotIntext(bool $plaintext = false): ?array {
+		return $plaintext ? $this->not_intext : Minz_Helper::htmlspecialchars_utf8($this->not_intext, ENT_NOQUOTES);
 	}
 	/** @return list<string>|null */
 	public function getNotIntextRegex(): ?array {
@@ -580,16 +586,16 @@ class FreshRSS_Search implements \Stringable {
 	}
 
 	/** @return list<string>|null */
-	public function getInurl(): ?array {
-		return $this->inurl;
+	public function getInurl(bool $plaintext = false): ?array {
+		return $plaintext ? $this->inurl : Minz_Helper::htmlspecialchars_utf8($this->inurl, ENT_NOQUOTES);
 	}
 	/** @return list<string>|null */
 	public function getInurlRegex(): ?array {
 		return $this->inurl_regex;
 	}
 	/** @return list<string>|null */
-	public function getNotInurl(): ?array {
-		return $this->not_inurl;
+	public function getNotInurl(bool $plaintext = false): ?array {
+		return $plaintext ? $this->not_inurl : Minz_Helper::htmlspecialchars_utf8($this->not_inurl, ENT_NOQUOTES);
 	}
 	/** @return list<string>|null */
 	public function getNotInurlRegex(): ?array {
@@ -597,16 +603,16 @@ class FreshRSS_Search implements \Stringable {
 	}
 
 	/** @return list<string>|null */
-	public function getAuthor(): ?array {
-		return $this->author;
+	public function getAuthor(bool $plaintext = false): ?array {
+		return $plaintext ? $this->author : Minz_Helper::htmlspecialchars_utf8($this->author, ENT_NOQUOTES);
 	}
 	/** @return list<string>|null */
 	public function getAuthorRegex(): ?array {
 		return $this->author_regex;
 	}
 	/** @return list<string>|null */
-	public function getNotAuthor(): ?array {
-		return $this->not_author;
+	public function getNotAuthor(bool $plaintext = false): ?array {
+		return $plaintext ? $this->not_author : Minz_Helper::htmlspecialchars_utf8($this->not_author, ENT_NOQUOTES);
 	}
 	/** @return list<string>|null */
 	public function getNotAuthorRegex(): ?array {
@@ -614,16 +620,16 @@ class FreshRSS_Search implements \Stringable {
 	}
 
 	/** @return list<string>|null */
-	public function getTags(): ?array {
-		return $this->tags;
+	public function getTags(bool $plaintext = false): ?array {
+		return $plaintext ? $this->tags : Minz_Helper::htmlspecialchars_utf8($this->tags, ENT_NOQUOTES);
 	}
 	/** @return list<string>|null */
 	public function getTagsRegex(): ?array {
 		return $this->tags_regex;
 	}
 	/** @return list<string>|null */
-	public function getNotTags(): ?array {
-		return $this->not_tags;
+	public function getNotTags(bool $plaintext = false): ?array {
+		return $plaintext ? $this->not_tags : Minz_Helper::htmlspecialchars_utf8($this->not_tags, ENT_NOQUOTES);
 	}
 	/** @return list<string>|null */
 	public function getNotTagsRegex(): ?array {
@@ -631,16 +637,16 @@ class FreshRSS_Search implements \Stringable {
 	}
 
 	/** @return list<string>|null */
-	public function getSearch(): ?array {
-		return $this->search;
+	public function getSearch(bool $plaintext = false): ?array {
+		return $plaintext ? $this->search : Minz_Helper::htmlspecialchars_utf8($this->search, ENT_NOQUOTES);
 	}
 	/** @return list<string>|null */
 	public function getSearchRegex(): ?array {
 		return $this->search_regex;
 	}
 	/** @return list<string>|null */
-	public function getNotSearch(): ?array {
-		return $this->not_search;
+	public function getNotSearch(bool $plaintext = false): ?array {
+		return $plaintext ? $this->not_search : Minz_Helper::htmlspecialchars_utf8($this->not_search, ENT_NOQUOTES);
 	}
 	/** @return list<string>|null */
 	public function getNotSearchRegex(): ?array {
@@ -671,14 +677,6 @@ class FreshRSS_Search implements \Stringable {
 		return $value;
 	}
 
-	/**
-	 * @param list<string> $strings
-	 * @return list<string>
-	 */
-	private static function htmlspecialchars_decodes(array $strings): array {
-		return array_map(static fn(string $s) => htmlspecialchars_decode($s, ENT_QUOTES), $strings);
-	}
-
 	/**
 	 * Parse the search string to find entry (article) IDs.
 	 */
@@ -890,7 +888,7 @@ class FreshRSS_Search implements \Stringable {
 	 */
 	private function parseIntitleSearch(string $input): string {
 		if (preg_match_all('#\\bintitle:(?P<search>/.*?(?<!\\\\)/[im]*)#', $input, $matches)) {
-			$this->intitle_regex = self::htmlspecialchars_decodes($matches['search']);
+			$this->intitle_regex = $matches['search'];
 			$input = str_replace($matches[0], '', $input);
 		}
 		if (preg_match_all('/\\bintitle:(?P<delim>[\'"])(?P<search>.*)(?P=delim)/U', $input, $matches)) {
@@ -910,7 +908,7 @@ class FreshRSS_Search implements \Stringable {
 
 	private function parseNotIntitleSearch(string $input): string {
 		if (preg_match_all('#(?<=[\\s(]|^)[!-]intitle:(?P<search>/.*?(?<!\\\\)/[im]*)#', $input, $matches)) {
-			$this->not_intitle_regex = self::htmlspecialchars_decodes($matches['search']);
+			$this->not_intitle_regex = $matches['search'];
 			$input = str_replace($matches[0], '', $input);
 		}
 		if (preg_match_all('/(?<=[\\s(]|^)[!-]intitle:(?P<delim>[\'"])(?P<search>.*)(?P=delim)/U', $input, $matches)) {
@@ -933,7 +931,7 @@ class FreshRSS_Search implements \Stringable {
 	 */
 	private function parseIntextSearch(string $input): string {
 		if (preg_match_all('#\\bintext:(?P<search>/.*?(?<!\\\\)/[im]*)#', $input, $matches)) {
-			$this->intext_regex = self::htmlspecialchars_decodes($matches['search']);
+			$this->intext_regex = $matches['search'];
 			$input = str_replace($matches[0], '', $input);
 		}
 		if (preg_match_all('/\\bintext:(?P<delim>[\'"])(?P<search>.*)(?P=delim)/U', $input, $matches)) {
@@ -953,7 +951,7 @@ class FreshRSS_Search implements \Stringable {
 
 	private function parseNotIntextSearch(string $input): string {
 		if (preg_match_all('#(?<=[\\s(]|^)[!-]intext:(?P<search>/.*?(?<!\\\\)/[im]*)#', $input, $matches)) {
-			$this->not_intext_regex = self::htmlspecialchars_decodes($matches['search']);
+			$this->not_intext_regex = $matches['search'];
 			$input = str_replace($matches[0], '', $input);
 		}
 		if (preg_match_all('/(?<=[\\s(]|^)[!-]intext:(?P<delim>[\'"])(?P<search>.*)(?P=delim)/U', $input, $matches)) {
@@ -978,7 +976,7 @@ class FreshRSS_Search implements \Stringable {
 	 */
 	private function parseAuthorSearch(string $input): string {
 		if (preg_match_all('#\\bauthor:(?P<search>/.*?(?<!\\\\)/[im]*)#', $input, $matches)) {
-			$this->author_regex = self::htmlspecialchars_decodes($matches['search']);
+			$this->author_regex = $matches['search'];
 			$input = str_replace($matches[0], '', $input);
 		}
 		if (preg_match_all('/\\bauthor:(?P<delim>[\'"])(?P<search>.*)(?P=delim)/U', $input, $matches)) {
@@ -998,7 +996,7 @@ class FreshRSS_Search implements \Stringable {
 
 	private function parseNotAuthorSearch(string $input): string {
 		if (preg_match_all('#(?<=[\\s(]|^)[!-]author:(?P<search>/.*?(?<!\\\\)/[im]*)#', $input, $matches)) {
-			$this->not_author_regex = self::htmlspecialchars_decodes($matches['search']);
+			$this->not_author_regex = $matches['search'];
 			$input = str_replace($matches[0], '', $input);
 		}
 		if (preg_match_all('/(?<=[\\s(]|^)[!-]author:(?P<delim>[\'"])(?P<search>.*)(?P=delim)/U', $input, $matches)) {
@@ -1022,7 +1020,7 @@ class FreshRSS_Search implements \Stringable {
 	 */
 	private function parseInurlSearch(string $input): string {
 		if (preg_match_all('#\\binurl:(?P<search>/.*?(?<!\\\\)/[im]*)#', $input, $matches)) {
-			$this->inurl_regex = self::htmlspecialchars_decodes($matches['search']);
+			$this->inurl_regex = $matches['search'];
 			$input = str_replace($matches[0], '', $input);
 		}
 		if (preg_match_all('/\\binurl:(?P<delim>[\'"])(?P<search>.*)(?P=delim)/U', $input, $matches)) {
@@ -1042,7 +1040,7 @@ class FreshRSS_Search implements \Stringable {
 
 	private function parseNotInurlSearch(string $input): string {
 		if (preg_match_all('#(?<=[\\s(]|^)[!-]inurl:(?P<search>/.*?(?<!\\\\)/[im]*)#', $input, $matches)) {
-			$this->not_inurl_regex = self::htmlspecialchars_decodes($matches['search']);
+			$this->not_inurl_regex = $matches['search'];
 			$input = str_replace($matches[0], '', $input);
 		}
 		if (preg_match_all('/(?<=[\\s(]|^)[!-]inurl:(?P<delim>[\'"])(?P<search>.*)(?P=delim)/U', $input, $matches)) {
@@ -1146,7 +1144,7 @@ class FreshRSS_Search implements \Stringable {
 	 */
 	private function parseTagsSearch(string $input): string {
 		if (preg_match_all('%#(?P<search>/.*?(?<!\\\\)/[im]*)%', $input, $matches)) {
-			$this->tags_regex = self::htmlspecialchars_decodes($matches['search']);
+			$this->tags_regex = $matches['search'];
 			$input = str_replace($matches[0], '', $input);
 		}
 		if (preg_match_all('/#(?P<delim>[\'"])(?P<search>.*)(?P=delim)/U', $input, $matches)) {
@@ -1168,7 +1166,7 @@ class FreshRSS_Search implements \Stringable {
 
 	private function parseNotTagsSearch(string $input): string {
 		if (preg_match_all('%(?<=[\\s(]|^)[!-]#(?P<search>/.*?(?<!\\\\)/[im]*)%', $input, $matches)) {
-			$this->not_tags_regex = self::htmlspecialchars_decodes($matches['search']);
+			$this->not_tags_regex = $matches['search'];
 			$input = str_replace($matches[0], '', $input);
 		}
 		if (preg_match_all('/(?<=[\\s(]|^)[!-]#(?P<delim>[\'"])(?P<search>.*)(?P=delim)/U', $input, $matches)) {
@@ -1199,7 +1197,7 @@ class FreshRSS_Search implements \Stringable {
 			return '';
 		}
 		if (preg_match_all('#(?<=[\\s(]|^)(?<![!-\\\\])(?P<search>/.*?(?<!\\\\)/[im]*)#', $input, $matches)) {
-			$this->search_regex = self::htmlspecialchars_decodes($matches['search']);
+			$this->search_regex = $matches['search'];
 			//TODO: Replace all those str_replace with PREG_OFFSET_CAPTURE
 			$input = str_replace($matches[0], '', $input);
 		}
@@ -1234,7 +1232,7 @@ class FreshRSS_Search implements \Stringable {
 			return '';
 		}
 		if (preg_match_all('#(?<=[\\s(]|^)[!-](?P<search>(?<!\\\\)/.*?(?<!\\\\)/[im]*)#', $input, $matches)) {
-			$this->not_search_regex = self::htmlspecialchars_decodes($matches['search']);
+			$this->not_search_regex = $matches['search'];
 			$input = str_replace($matches[0], '', $input);
 		}
 		if (preg_match_all('/(?<=[\\s(]|^)[!-](?P<delim>[\'"])(?P<search>.*)(?P=delim)/U', $input, $matches)) {

+ 3 - 3
lib/Minz/Helper.php

@@ -19,13 +19,13 @@ final class Minz_Helper {
 	 * @phpstan-param T $var
 	 * @phpstan-return T
 	 */
-	public static function htmlspecialchars_utf8(mixed $var): mixed {
+	public static function htmlspecialchars_utf8(mixed $var, int $flags = ENT_COMPAT): mixed {
 		if (is_array($var)) {
 			// @phpstan-ignore return.type
-			return array_map([self::class, 'htmlspecialchars_utf8'], $var);
+			return array_map(fn($v) => self::htmlspecialchars_utf8($v, $flags), $var);
 		} elseif (is_string($var)) {
 			// @phpstan-ignore return.type
-			return htmlspecialchars($var, ENT_COMPAT, 'UTF-8');
+			return htmlspecialchars($var, $flags, 'UTF-8');
 		} else {
 			return $var;
 		}

+ 9 - 9
p/api/query.php

@@ -8,21 +8,21 @@ require LIB_PATH . '/lib_rss.php';	//Includes class autoloader
 
 Minz_Request::init();
 
-$token = Minz_Request::paramString('t');
+$token = Minz_Request::paramString('t', plaintext: true);
 if (!ctype_alnum($token)) {
 	header('HTTP/1.1 422 Unprocessable Entity');
 	header('Content-Type: text/plain; charset=UTF-8');
 	die('Invalid token `t`!' . $token);
 }
 
-$format = Minz_Request::paramString('f');
+$format = Minz_Request::paramString('f', plaintext: true);
 if (!in_array($format, ['atom', 'greader', 'html', 'json', 'opml', 'rss'], true)) {
 	header('HTTP/1.1 422 Unprocessable Entity');
 	header('Content-Type: text/plain; charset=UTF-8');
 	die('Invalid format `f`!');
 }
 
-$user = Minz_Request::paramString('user');
+$user = Minz_Request::paramString('user', plaintext: true);
 if (!FreshRSS_user_Controller::checkUsername($user)) {
 	header('HTTP/1.1 422 Unprocessable Entity');
 	header('Content-Type: text/plain; charset=UTF-8');
@@ -87,19 +87,19 @@ foreach (FreshRSS_Context::userConf()->queries as $raw_query) {
 		}
 		$query = new FreshRSS_UserQuery($raw_query, FreshRSS_Context::categories(), FreshRSS_Context::labels());
 		Minz_Request::_param('get', $query->getGet());
-		if (Minz_Request::paramString('order') === '') {
+		if (Minz_Request::paramString('order', plaintext: true) === '') {
 			Minz_Request::_param('order', $query->getOrder());
 		}
 		Minz_Request::_param('state', (string)$query->getState());
 
-		$search = $query->getSearch()->getRawInput();
+		$search = $query->getSearch()->__toString();
 		// Note: we disallow references to user queries in public user search to avoid sniffing internal user queries
-		$userSearch = new FreshRSS_BooleanSearch(Minz_Request::paramString('search'), 0, 'AND', allowUserQueries: false);
-		if ($userSearch->getRawInput() !== '') {
+		$userSearch = new FreshRSS_BooleanSearch(Minz_Request::paramString('search', plaintext: true), 0, 'AND', allowUserQueries: false);
+		if ($userSearch->__toString() !== '') {
 			if ($search === '') {
-				$search = $userSearch->getRawInput();
+				$search = $userSearch->__toString();
 			} else {
-				$search .= ' (' . $userSearch->getRawInput() . ')';
+				$search .= ' (' . $userSearch->__toString() . ')';
 			}
 		}
 		Minz_Request::_param('search', $search);

+ 8 - 8
tests/app/Models/SearchTest.php

@@ -65,6 +65,7 @@ final class SearchTest extends \PHPUnit\Framework\TestCase {
 			["intitle:'word1 word2' word3'", ['word1 word2'], ["word3'"]],
 			['intitle:"word1 word2\' word3"', ["word1 word2' word3"], null],
 			["intitle:'word1 word2\" word3'", ['word1 word2" word3'], null],
+			['intitle:"< & >"', ['&lt; &amp; &gt;'], null],
 			["intitle:word1 'word2 word3' word4", ['word1'], ['word2 word3', 'word4']],
 			['intitle:word1+word2', ['word1+word2'], null],
 		];
@@ -561,10 +562,9 @@ final class SearchTest extends \PHPUnit\Framework\TestCase {
 				['%ab%', '%ab%', '%cd%', '%cd%', '%ef%', '%ef%', '%gh%', '%ij%', '%ij%', '%kl%', '%kl%']
 			],
 			[
-				'&quot;ab&quot; &quot;cd&quot; (&quot;ef&quot;) intitle:&quot;gh&quot; !&quot;ij&quot; -&quot;kl&quot;',
-				'(((e.title LIKE ? OR e.content LIKE ?) AND (e.title LIKE ? OR e.content LIKE ?) )) AND (((e.title LIKE ? OR e.content LIKE ?) )) ' .
-					'AND ((e.title LIKE ? AND e.title NOT LIKE ? AND e.content NOT LIKE ? AND e.title NOT LIKE ? AND e.content NOT LIKE ? ))',
-				['%ab%', '%ab%', '%cd%', '%cd%', '%ef%', '%ef%', '%gh%', '%ij%', '%ij%', '%kl%', '%kl%']
+				'intitle:"é & \' è" intext:/<&>/ \'< & " >\'',
+				'(e.title LIKE ? AND e.content ~ ? AND (e.title LIKE ? OR e.content LIKE ?) )',
+				['%é &amp; \' è%', '<&>', '%&lt; &amp; " &gt;%', '%&lt; &amp; " &gt;%']
 			],
 			[
 				'/^(ab|cd) [(] \\) (ef|gh)/',
@@ -934,8 +934,8 @@ final class SearchTest extends \PHPUnit\Framework\TestCase {
 					userdate:2025-01-01T00:00:00/2026-01-01T00:00:00
 					pubdate:2025-02-01T00:00:00/2026-01-01T00:00:00
 					date:2025-03-01T00:00:00/2026-01-01T00:00:00
-					intitle:/Interesting/i intitle:good
-					intext:/Interesting/i intext:good
+					intitle:/<Inter&sting>/i intitle:"g ' & d"
+					intext:/<Inter&sting>/i intext:g&d
 					author:/Bob/ author:Alice
 					inurl:/https/ inurl:example.net
 					#/tag2/ #tag1
@@ -944,8 +944,8 @@ final class SearchTest extends \PHPUnit\Framework\TestCase {
 					-userdate:2025-06-01T00:00:00/2025-09-01T00:00:00
 					-pubdate:2025-06-01T00:00:00/2025-09-01T00:00:00
 					-date:2025-06-01T00:00:00/2025-09-01T00:00:00
-					-intitle:/Spam/i -intitle:bad
-					-intext:/Spam/i -intext:bad
+					-intitle:/Spam/i -intitle:"'bad"
+					-intext:/Spam/i -intext:"'bad"
 					-author:/Dave/i -author:Charlie
 					-inurl:/ftp/ -inurl:example.com
 					-#/tag4/ -#tag3