fix: escape LIKE wildcards in user-search ILIKE queries

A `%` or `_` in a search term silently acted as a LIKE wildcard rather than
matching literally (`50%` matched everything, `a_b` matched `axb`). Not
injection — terms are bound — but a search-correctness bug across every
user-facing ILIKE site.

- repo::escape_like: shared pub(crate) escaper (promoted from page_tag), unit-tested.
- Trigram-entangled sites (manga, tag, author) append a separate escaped param
  used only by the ILIKE branch; the trigram/similarity branches keep the raw term.
- Pattern-built sites (admin manga list, admin users, analysis coverage + history,
  crawler search incl. JSONB payload title) escape inside format!("%{}%", ..) and
  pair each ILIKE with ESCAPE '\'.

Tests: escape_like unit tests + integration tests on public manga search, author
autocomplete, and admin user search proving `_` matches literally.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-07-13 19:57:06 +02:00
parent 5784483a57
commit c570e0cc37
15 changed files with 202 additions and 55 deletions

View File

@@ -214,7 +214,7 @@ pub async fn list_mangas_with_sync_state(
let search_pat = q
.search
.as_ref()
.map(|s| format!("%{}%", s.trim()))
.map(|s| format!("%{}%", crate::repo::escape_like(s.trim())))
.filter(|p| p.len() > 2);
// sqlx::Type → text: bind the snake_case representation manually so
// the SQL can compare it as text without an explicit cast.
@@ -235,7 +235,7 @@ pub async fn list_mangas_with_sync_state(
(SELECT MAX(last_seen_at) FROM manga_sources ms
WHERE ms.manga_id = m.id AND ms.dropped_at IS NULL) AS latest_seen_at
FROM mangas m
WHERE ($1::text IS NULL OR m.title ILIKE $1)
WHERE ($1::text IS NULL OR m.title ILIKE $1 ESCAPE '\')
)
SELECT * FROM classified
WHERE ($2::text IS NULL OR sync_state = $2)
@@ -261,7 +261,7 @@ pub async fn list_mangas_with_sync_state(
WITH classified AS (
SELECT {case} AS sync_state
FROM mangas m
WHERE ($1::text IS NULL OR m.title ILIKE $1)
WHERE ($1::text IS NULL OR m.title ILIKE $1 ESCAPE '\')
)
SELECT COUNT(*) FROM classified
WHERE ($2::text IS NULL OR sync_state = $2)