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

@@ -30,6 +30,47 @@ fn first_author_id(manga: &Value) -> String {
manga["authors"][0]["id"].as_str().unwrap().to_string()
}
#[sqlx::test(migrations = "./migrations")]
async fn search_treats_like_wildcards_literally(pool: PgPool) {
let h = common::harness(pool);
let (_, cookie) = common::register_user(&h.app).await;
// Long author names differing only at one position, short search term — so
// the trigram OR (which keeps the raw term by design) stays under threshold
// and the ILIKE branch is what decides. Unescaped `%a_b%` matches both the
// "axb" and "a_b" names; escaped, only the literal "a_b" name.
create_manga(
&h.app,
&cookie,
json!({ "title": "M1", "authors": ["The Quick Brown Fox axb Jumps Over"] }),
)
.await;
create_manga(
&h.app,
&cookie,
json!({ "title": "M2", "authors": ["The Quick Brown Fox a_b Jumps Over"] }),
)
.await;
let resp = h
.app
.oneshot(common::get("/api/v1/authors?search=a_b"))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = common::body_json(resp).await;
let names: Vec<&str> = body
.as_array()
.unwrap()
.iter()
.map(|a| a["name"].as_str().unwrap())
.collect();
assert_eq!(
names,
vec!["The Quick Brown Fox a_b Jumps Over"],
"the `_` in the search term must match literally, not as a wildcard"
);
}
#[sqlx::test(migrations = "./migrations")]
async fn get_returns_name_and_manga_count(pool: PgPool) {
let h = common::harness(pool);