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

@@ -241,6 +241,10 @@ pub async fn manga_coverage(
limit: i64,
offset: i64,
) -> AppResult<(Vec<MangaCoverage>, i64)> {
// LIKE-escape so `%`/`_` in the title filter match literally. No trigram
// branch here, so binding the escaped term directly (rather than appending a
// second param) is safe — $1 feeds only the ILIKE.
let search = search.map(crate::repo::escape_like);
let rows = sqlx::query_as::<_, MangaCoverage>(
r#"
SELECT m.id AS manga_id, m.title,
@@ -250,13 +254,13 @@ pub async fn manga_coverage(
JOIN chapters c ON c.manga_id = m.id
JOIN pages p ON p.chapter_id = c.id
LEFT JOIN page_analysis pa ON pa.page_id = p.id
WHERE ($1::text IS NULL OR m.title ILIKE '%' || $1 || '%')
WHERE ($1::text IS NULL OR m.title ILIKE '%' || $1 || '%' ESCAPE '\')
GROUP BY m.id, m.title
ORDER BY lower(m.title), m.id
LIMIT $2 OFFSET $3
"#,
)
.bind(search)
.bind(&search)
.bind(limit)
.bind(offset)
.fetch_all(pool)
@@ -269,12 +273,12 @@ pub async fn manga_coverage(
FROM mangas m
JOIN chapters c ON c.manga_id = m.id
JOIN pages p ON p.chapter_id = c.id
WHERE ($1::text IS NULL OR m.title ILIKE '%' || $1 || '%')
WHERE ($1::text IS NULL OR m.title ILIKE '%' || $1 || '%' ESCAPE '\')
GROUP BY m.id
) x
"#,
)
.bind(search)
.bind(&search)
.fetch_one(pool)
.await?;
Ok((rows, total))
@@ -377,7 +381,7 @@ pub async fn list_history(
) -> AppResult<(Vec<AnalysisHistoryRow>, i64)> {
let search_pat = filter
.search
.map(|s| format!("%{}%", s.trim()))
.map(|s| format!("%{}%", crate::repo::escape_like(s.trim())))
.filter(|p| p.len() > 2);
let items = sqlx::query_as::<_, AnalysisHistoryRow>(
@@ -402,7 +406,7 @@ pub async fn list_history(
WHERE pa.status <> 'pending'
AND ($1::text IS NULL OR pa.status = $1)
AND ($2::bool IS FALSE OR pa.is_nsfw)
AND ($3::text IS NULL OR m.title ILIKE $3)
AND ($3::text IS NULL OR m.title ILIKE $3 ESCAPE '\')
ORDER BY pa.analyzed_at DESC NULLS LAST, pa.page_id
LIMIT $4 OFFSET $5
"#,
@@ -425,7 +429,7 @@ pub async fn list_history(
WHERE pa.status <> 'pending'
AND ($1::text IS NULL OR pa.status = $1)
AND ($2::bool IS FALSE OR pa.is_nsfw)
AND ($3::text IS NULL OR m.title ILIKE $3)
AND ($3::text IS NULL OR m.title ILIKE $3 ESCAPE '\')
"#,
)
.bind(filter.status)