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

@@ -147,6 +147,46 @@ async fn list_filters_by_substring_search(pool: PgPool) {
assert_eq!(body["page"]["total"], 1);
}
#[sqlx::test(migrations = "./migrations")]
async fn search_treats_like_wildcards_literally(pool: PgPool) {
let h = common::harness(pool.clone());
let (_admin_name, cookie, _) = seed_admin(&pool, &h.app).await;
// Two usernames differing only at one position (underscores are legal in
// usernames). `_` is a LIKE single-char wildcard: unescaped, `%a_b%` matches
// BOTH; escaped, only the literal "a_b" username. Admin user search has no
// trigram OR, so length/similarity don't matter here.
for username in ["axbfindme", "a_bfindme"] {
let resp = h
.app
.clone()
.oneshot(common::post_json(
"/api/v1/auth/register",
json!({ "username": username, "password": "hunter2hunter2" }),
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::CREATED);
}
let resp = h
.app
.oneshot(common::get_with_cookie(
"/api/v1/admin/users?search=a_b",
&cookie,
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = common::body_json(resp).await;
let items = body["items"].as_array().unwrap();
assert_eq!(
items.len(),
1,
"the `_` must match literally: only a_bfindme, not axbfindme"
);
assert_eq!(items[0]["username"], "a_bfindme");
}
// ---- self-protection -------------------------------------------------------
#[sqlx::test(migrations = "./migrations")]