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:
2
backend/Cargo.lock
generated
2
backend/Cargo.lock
generated
@@ -1558,7 +1558,7 @@ checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mangalord"
|
name = "mangalord"
|
||||||
version = "0.128.5"
|
version = "0.128.6"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"argon2",
|
"argon2",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "mangalord"
|
name = "mangalord"
|
||||||
version = "0.128.5"
|
version = "0.128.6"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
default-run = "mangalord"
|
default-run = "mangalord"
|
||||||
|
|
||||||
|
|||||||
@@ -214,7 +214,7 @@ pub async fn list_mangas_with_sync_state(
|
|||||||
let search_pat = q
|
let search_pat = q
|
||||||
.search
|
.search
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|s| format!("%{}%", s.trim()))
|
.map(|s| format!("%{}%", crate::repo::escape_like(s.trim())))
|
||||||
.filter(|p| p.len() > 2);
|
.filter(|p| p.len() > 2);
|
||||||
// sqlx::Type → text: bind the snake_case representation manually so
|
// sqlx::Type → text: bind the snake_case representation manually so
|
||||||
// the SQL can compare it as text without an explicit cast.
|
// 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
|
(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
|
WHERE ms.manga_id = m.id AND ms.dropped_at IS NULL) AS latest_seen_at
|
||||||
FROM mangas m
|
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
|
SELECT * FROM classified
|
||||||
WHERE ($2::text IS NULL OR sync_state = $2)
|
WHERE ($2::text IS NULL OR sync_state = $2)
|
||||||
@@ -261,7 +261,7 @@ pub async fn list_mangas_with_sync_state(
|
|||||||
WITH classified AS (
|
WITH classified AS (
|
||||||
SELECT {case} AS sync_state
|
SELECT {case} AS sync_state
|
||||||
FROM mangas m
|
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
|
SELECT COUNT(*) FROM classified
|
||||||
WHERE ($2::text IS NULL OR sync_state = $2)
|
WHERE ($2::text IS NULL OR sync_state = $2)
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ pub async fn list(
|
|||||||
SELECT id, name, created_at
|
SELECT id, name, created_at
|
||||||
FROM authors
|
FROM authors
|
||||||
WHERE $1::text IS NULL
|
WHERE $1::text IS NULL
|
||||||
OR name ILIKE '%' || $1 || '%'
|
OR name ILIKE '%' || $4 || '%' ESCAPE '\'
|
||||||
OR name % $1
|
OR name % $1
|
||||||
ORDER BY CASE WHEN $1::text IS NULL THEN 0 ELSE similarity(name, $1) END DESC,
|
ORDER BY CASE WHEN $1::text IS NULL THEN 0 ELSE similarity(name, $1) END DESC,
|
||||||
lower(name) ASC
|
lower(name) ASC
|
||||||
@@ -91,6 +91,9 @@ pub async fn list(
|
|||||||
.bind(search)
|
.bind(search)
|
||||||
.bind(limit)
|
.bind(limit)
|
||||||
.bind(offset)
|
.bind(offset)
|
||||||
|
// $4: LIKE-escaped term for the ILIKE branch so `%`/`_` match literally; the
|
||||||
|
// trigram/similarity branches keep the raw $1.
|
||||||
|
.bind(search.map(crate::repo::escape_like))
|
||||||
.fetch_all(pool)
|
.fetch_all(pool)
|
||||||
.await?;
|
.await?;
|
||||||
Ok(rows)
|
Ok(rows)
|
||||||
|
|||||||
@@ -720,7 +720,7 @@ pub async fn list_dead_jobs(
|
|||||||
offset: i64,
|
offset: i64,
|
||||||
) -> sqlx::Result<(Vec<DeadJob>, i64)> {
|
) -> sqlx::Result<(Vec<DeadJob>, i64)> {
|
||||||
let search_pat = search
|
let search_pat = search
|
||||||
.map(|s| format!("%{}%", s.trim()))
|
.map(|s| format!("%{}%", crate::repo::escape_like(s.trim())))
|
||||||
.filter(|p| p.len() > 2);
|
.filter(|p| p.len() > 2);
|
||||||
|
|
||||||
let items: Vec<DeadJob> = sqlx::query_as(
|
let items: Vec<DeadJob> = sqlx::query_as(
|
||||||
@@ -743,7 +743,7 @@ pub async fn list_dead_jobs(
|
|||||||
LEFT JOIN chapters c ON c.id = (cj.payload->>'chapter_id')::uuid
|
LEFT JOIN chapters c ON c.id = (cj.payload->>'chapter_id')::uuid
|
||||||
LEFT JOIN mangas m ON m.id = c.manga_id
|
LEFT JOIN mangas m ON m.id = c.manga_id
|
||||||
WHERE cj.state = 'dead'
|
WHERE cj.state = 'dead'
|
||||||
AND ($1::text IS NULL OR m.title ILIKE $1 OR cj.payload->>'title' ILIKE $1)
|
AND ($1::text IS NULL OR m.title ILIKE $1 ESCAPE '\' OR cj.payload->>'title' ILIKE $1 ESCAPE '\')
|
||||||
ORDER BY cj.updated_at DESC
|
ORDER BY cj.updated_at DESC
|
||||||
LIMIT $2 OFFSET $3
|
LIMIT $2 OFFSET $3
|
||||||
"#,
|
"#,
|
||||||
@@ -761,7 +761,7 @@ pub async fn list_dead_jobs(
|
|||||||
LEFT JOIN chapters c ON c.id = (cj.payload->>'chapter_id')::uuid
|
LEFT JOIN chapters c ON c.id = (cj.payload->>'chapter_id')::uuid
|
||||||
LEFT JOIN mangas m ON m.id = c.manga_id
|
LEFT JOIN mangas m ON m.id = c.manga_id
|
||||||
WHERE cj.state = 'dead'
|
WHERE cj.state = 'dead'
|
||||||
AND ($1::text IS NULL OR m.title ILIKE $1 OR cj.payload->>'title' ILIKE $1)
|
AND ($1::text IS NULL OR m.title ILIKE $1 ESCAPE '\' OR cj.payload->>'title' ILIKE $1 ESCAPE '\')
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
.bind(&search_pat)
|
.bind(&search_pat)
|
||||||
@@ -797,7 +797,7 @@ pub async fn list_active_jobs(
|
|||||||
offset: i64,
|
offset: i64,
|
||||||
) -> sqlx::Result<(Vec<ActiveJob>, i64)> {
|
) -> sqlx::Result<(Vec<ActiveJob>, i64)> {
|
||||||
let search_pat = search
|
let search_pat = search
|
||||||
.map(|s| format!("%{}%", s.trim()))
|
.map(|s| format!("%{}%", crate::repo::escape_like(s.trim())))
|
||||||
.filter(|p| p.len() > 2);
|
.filter(|p| p.len() > 2);
|
||||||
|
|
||||||
let items: Vec<ActiveJob> = sqlx::query_as(
|
let items: Vec<ActiveJob> = sqlx::query_as(
|
||||||
@@ -817,7 +817,7 @@ pub async fn list_active_jobs(
|
|||||||
LEFT JOIN mangas m ON m.id = c.manga_id
|
LEFT JOIN mangas m ON m.id = c.manga_id
|
||||||
WHERE cj.state IN ('pending','running')
|
WHERE cj.state IN ('pending','running')
|
||||||
AND cj.payload->>'kind' = 'sync_chapter_content'
|
AND cj.payload->>'kind' = 'sync_chapter_content'
|
||||||
AND ($1::text IS NULL OR m.title ILIKE $1)
|
AND ($1::text IS NULL OR m.title ILIKE $1 ESCAPE '\')
|
||||||
ORDER BY (cj.state = 'running') DESC, cj.scheduled_at, cj.created_at
|
ORDER BY (cj.state = 'running') DESC, cj.scheduled_at, cj.created_at
|
||||||
LIMIT $2 OFFSET $3
|
LIMIT $2 OFFSET $3
|
||||||
"#,
|
"#,
|
||||||
@@ -836,7 +836,7 @@ pub async fn list_active_jobs(
|
|||||||
LEFT JOIN mangas m ON m.id = c.manga_id
|
LEFT JOIN mangas m ON m.id = c.manga_id
|
||||||
WHERE cj.state IN ('pending','running')
|
WHERE cj.state IN ('pending','running')
|
||||||
AND cj.payload->>'kind' = 'sync_chapter_content'
|
AND cj.payload->>'kind' = 'sync_chapter_content'
|
||||||
AND ($1::text IS NULL OR m.title ILIKE $1)
|
AND ($1::text IS NULL OR m.title ILIKE $1 ESCAPE '\')
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
.bind(&search_pat)
|
.bind(&search_pat)
|
||||||
@@ -913,7 +913,7 @@ pub async fn list_job_history(
|
|||||||
) -> sqlx::Result<(Vec<JobHistoryRow>, i64)> {
|
) -> sqlx::Result<(Vec<JobHistoryRow>, i64)> {
|
||||||
let search_pat = filter
|
let search_pat = filter
|
||||||
.search
|
.search
|
||||||
.map(|s| format!("%{}%", s.trim()))
|
.map(|s| format!("%{}%", crate::repo::escape_like(s.trim())))
|
||||||
.filter(|p| p.len() > 2);
|
.filter(|p| p.len() > 2);
|
||||||
|
|
||||||
// The same FROM/JOIN/WHERE drives both the page slice and the count, so
|
// The same FROM/JOIN/WHERE drives both the page slice and the count, so
|
||||||
@@ -951,7 +951,7 @@ pub async fn list_job_history(
|
|||||||
) cm ON true
|
) cm ON true
|
||||||
WHERE ($1::text IS NULL OR cj.state = $1)
|
WHERE ($1::text IS NULL OR cj.state = $1)
|
||||||
AND ($2::text IS NULL OR cj.payload->>'kind' = $2)
|
AND ($2::text IS NULL OR cj.payload->>'kind' = $2)
|
||||||
AND ($3::text IS NULL OR m.title ILIKE $3 OR cj.payload->>'title' ILIKE $3)
|
AND ($3::text IS NULL OR m.title ILIKE $3 ESCAPE '\' OR cj.payload->>'title' ILIKE $3 ESCAPE '\')
|
||||||
ORDER BY cj.updated_at DESC
|
ORDER BY cj.updated_at DESC
|
||||||
LIMIT $4 OFFSET $5
|
LIMIT $4 OFFSET $5
|
||||||
"#,
|
"#,
|
||||||
@@ -973,7 +973,7 @@ pub async fn list_job_history(
|
|||||||
LEFT JOIN mangas m ON m.id = COALESCE(c.manga_id, (cj.payload->>'manga_id')::uuid)
|
LEFT JOIN mangas m ON m.id = COALESCE(c.manga_id, (cj.payload->>'manga_id')::uuid)
|
||||||
WHERE ($1::text IS NULL OR cj.state = $1)
|
WHERE ($1::text IS NULL OR cj.state = $1)
|
||||||
AND ($2::text IS NULL OR cj.payload->>'kind' = $2)
|
AND ($2::text IS NULL OR cj.payload->>'kind' = $2)
|
||||||
AND ($3::text IS NULL OR m.title ILIKE $3 OR cj.payload->>'title' ILIKE $3)
|
AND ($3::text IS NULL OR m.title ILIKE $3 ESCAPE '\' OR cj.payload->>'title' ILIKE $3 ESCAPE '\')
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
.bind(filter.state)
|
.bind(filter.state)
|
||||||
@@ -1018,7 +1018,7 @@ pub async fn list_missing_cover_mangas(
|
|||||||
offset: i64,
|
offset: i64,
|
||||||
) -> sqlx::Result<(Vec<MissingCoverRow>, i64)> {
|
) -> sqlx::Result<(Vec<MissingCoverRow>, i64)> {
|
||||||
let search_pat = search
|
let search_pat = search
|
||||||
.map(|s| format!("%{}%", s.trim()))
|
.map(|s| format!("%{}%", crate::repo::escape_like(s.trim())))
|
||||||
.filter(|p| p.len() > 2);
|
.filter(|p| p.len() > 2);
|
||||||
|
|
||||||
let items: Vec<MissingCoverRow> = sqlx::query_as(
|
let items: Vec<MissingCoverRow> = sqlx::query_as(
|
||||||
@@ -1030,7 +1030,7 @@ pub async fn list_missing_cover_mangas(
|
|||||||
SELECT 1 FROM manga_sources ms
|
SELECT 1 FROM manga_sources ms
|
||||||
WHERE ms.manga_id = m.id AND ms.dropped_at IS NULL
|
WHERE ms.manga_id = m.id AND ms.dropped_at IS NULL
|
||||||
)
|
)
|
||||||
AND ($1::text IS NULL OR m.title ILIKE $1)
|
AND ($1::text IS NULL OR m.title ILIKE $1 ESCAPE '\')
|
||||||
ORDER BY m.updated_at DESC
|
ORDER BY m.updated_at DESC
|
||||||
LIMIT $2 OFFSET $3
|
LIMIT $2 OFFSET $3
|
||||||
"#,
|
"#,
|
||||||
@@ -1049,7 +1049,7 @@ pub async fn list_missing_cover_mangas(
|
|||||||
SELECT 1 FROM manga_sources ms
|
SELECT 1 FROM manga_sources ms
|
||||||
WHERE ms.manga_id = m.id AND ms.dropped_at IS NULL
|
WHERE ms.manga_id = m.id AND ms.dropped_at IS NULL
|
||||||
)
|
)
|
||||||
AND ($1::text IS NULL OR m.title ILIKE $1)
|
AND ($1::text IS NULL OR m.title ILIKE $1 ESCAPE '\')
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
.bind(&search_pat)
|
.bind(&search_pat)
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ use uuid::Uuid;
|
|||||||
|
|
||||||
use crate::domain::manga::{Manga, MangaCard, MangaDetail};
|
use crate::domain::manga::{Manga, MangaCard, MangaDetail};
|
||||||
use crate::error::{AppError, AppResult};
|
use crate::error::{AppError, AppResult};
|
||||||
use crate::repo;
|
use crate::repo::{self, escape_like};
|
||||||
|
|
||||||
/// Status values mirror the CHECK constraint in 0009. Centralized so
|
/// Status values mirror the CHECK constraint in 0009. Centralized so
|
||||||
/// the API layer can validate uploads against the same vocabulary.
|
/// the API layer can validate uploads against the same vocabulary.
|
||||||
@@ -110,13 +110,13 @@ fn manga_cols(alias: &str) -> String {
|
|||||||
/// true.
|
/// true.
|
||||||
const FILTER_WHERE: &str = r#"
|
const FILTER_WHERE: &str = r#"
|
||||||
($1::text IS NULL
|
($1::text IS NULL
|
||||||
OR title ILIKE '%' || $1 || '%'
|
OR title ILIKE '%' || $8 || '%' ESCAPE '\'
|
||||||
OR title % $1
|
OR title % $1
|
||||||
OR EXISTS (
|
OR EXISTS (
|
||||||
SELECT 1 FROM manga_authors ma
|
SELECT 1 FROM manga_authors ma
|
||||||
JOIN authors a ON a.id = ma.author_id
|
JOIN authors a ON a.id = ma.author_id
|
||||||
WHERE ma.manga_id = mangas.id
|
WHERE ma.manga_id = mangas.id
|
||||||
AND (a.name ILIKE '%' || $1 || '%' OR a.name % $1)
|
AND (a.name ILIKE '%' || $8 || '%' ESCAPE '\' OR a.name % $1)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
AND ($2::text IS NULL OR status = $2)
|
AND ($2::text IS NULL OR status = $2)
|
||||||
@@ -196,11 +196,15 @@ pub async fn list(pool: &PgPool, query: &ListQuery) -> AppResult<(Vec<Manga>, Op
|
|||||||
FROM mangas
|
FROM mangas
|
||||||
WHERE {FILTER_WHERE}
|
WHERE {FILTER_WHERE}
|
||||||
ORDER BY {order_by}
|
ORDER BY {order_by}
|
||||||
LIMIT $8 OFFSET $9
|
LIMIT $9 OFFSET $10
|
||||||
"#,
|
"#,
|
||||||
cols = manga_cols(""),
|
cols = manga_cols(""),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// $8 is the LIKE-escaped search term used by the ILIKE branches so `%`/`_`
|
||||||
|
// in the term match literally; the trigram `%` branches keep the raw $1.
|
||||||
|
let search_escaped = search.map(escape_like);
|
||||||
|
|
||||||
let rows = sqlx::query_as::<_, Manga>(&list_sql)
|
let rows = sqlx::query_as::<_, Manga>(&list_sql)
|
||||||
.bind(search)
|
.bind(search)
|
||||||
.bind(status)
|
.bind(status)
|
||||||
@@ -209,6 +213,7 @@ pub async fn list(pool: &PgPool, query: &ListQuery) -> AppResult<(Vec<Manga>, Op
|
|||||||
.bind(&query.tag_ids)
|
.bind(&query.tag_ids)
|
||||||
.bind(&query.cw_include)
|
.bind(&query.cw_include)
|
||||||
.bind(&query.cw_exclude)
|
.bind(&query.cw_exclude)
|
||||||
|
.bind(&search_escaped)
|
||||||
.bind(query.limit)
|
.bind(query.limit)
|
||||||
.bind(query.offset)
|
.bind(query.offset)
|
||||||
.fetch_all(pool)
|
.fetch_all(pool)
|
||||||
@@ -235,6 +240,7 @@ pub async fn list(pool: &PgPool, query: &ListQuery) -> AppResult<(Vec<Manga>, Op
|
|||||||
.bind(&query.tag_ids)
|
.bind(&query.tag_ids)
|
||||||
.bind(&query.cw_include)
|
.bind(&query.cw_include)
|
||||||
.bind(&query.cw_exclude)
|
.bind(&query.cw_exclude)
|
||||||
|
.bind(&search_escaped)
|
||||||
.fetch_one(pool)
|
.fetch_one(pool)
|
||||||
.await?;
|
.await?;
|
||||||
Some(total)
|
Some(total)
|
||||||
|
|||||||
@@ -21,3 +21,45 @@ pub mod tag;
|
|||||||
pub mod upload_history;
|
pub mod upload_history;
|
||||||
pub mod user;
|
pub mod user;
|
||||||
pub mod user_preferences;
|
pub mod user_preferences;
|
||||||
|
|
||||||
|
/// Escape the LIKE/ILIKE metacharacters (`%`, `_`, and the escape char `\`
|
||||||
|
/// itself) in a user-supplied search term so they match literally rather than
|
||||||
|
/// as wildcards. Pair the resulting value with `ESCAPE '\'` in the SQL — a
|
||||||
|
/// single backslash, which under `standard_conforming_strings` (Postgres
|
||||||
|
/// default) is one backslash in a single-quoted literal.
|
||||||
|
///
|
||||||
|
/// This is a search-correctness fix, not an injection fix: every term is
|
||||||
|
/// already a bound parameter, so `%`/`_` can never break out of the string —
|
||||||
|
/// they were just silently acting as wildcards (`50%` matching everything,
|
||||||
|
/// `a_b` matching `axb`). Callers that build a substring pattern wrap the
|
||||||
|
/// escaped term themselves, e.g. `format!("%{}%", escape_like(term))`.
|
||||||
|
pub(crate) fn escape_like(s: &str) -> String {
|
||||||
|
let mut out = String::with_capacity(s.len());
|
||||||
|
for ch in s.chars() {
|
||||||
|
if matches!(ch, '\\' | '%' | '_') {
|
||||||
|
out.push('\\');
|
||||||
|
}
|
||||||
|
out.push(ch);
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::escape_like;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn escapes_wildcards_and_the_escape_char() {
|
||||||
|
assert_eq!(escape_like("50%"), r"50\%");
|
||||||
|
assert_eq!(escape_like("a_b"), r"a\_b");
|
||||||
|
assert_eq!(escape_like(r"back\slash"), r"back\\slash");
|
||||||
|
// A already-escaped-looking input is double-escaped so it stays literal.
|
||||||
|
assert_eq!(escape_like(r"\%"), r"\\\%");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn leaves_ordinary_text_untouched() {
|
||||||
|
assert_eq!(escape_like("naruto"), "naruto");
|
||||||
|
assert_eq!(escape_like(""), "");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -241,6 +241,10 @@ pub async fn manga_coverage(
|
|||||||
limit: i64,
|
limit: i64,
|
||||||
offset: i64,
|
offset: i64,
|
||||||
) -> AppResult<(Vec<MangaCoverage>, 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>(
|
let rows = sqlx::query_as::<_, MangaCoverage>(
|
||||||
r#"
|
r#"
|
||||||
SELECT m.id AS manga_id, m.title,
|
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 chapters c ON c.manga_id = m.id
|
||||||
JOIN pages p ON p.chapter_id = c.id
|
JOIN pages p ON p.chapter_id = c.id
|
||||||
LEFT JOIN page_analysis pa ON pa.page_id = p.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
|
GROUP BY m.id, m.title
|
||||||
ORDER BY lower(m.title), m.id
|
ORDER BY lower(m.title), m.id
|
||||||
LIMIT $2 OFFSET $3
|
LIMIT $2 OFFSET $3
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
.bind(search)
|
.bind(&search)
|
||||||
.bind(limit)
|
.bind(limit)
|
||||||
.bind(offset)
|
.bind(offset)
|
||||||
.fetch_all(pool)
|
.fetch_all(pool)
|
||||||
@@ -269,12 +273,12 @@ pub async fn manga_coverage(
|
|||||||
FROM mangas m
|
FROM mangas m
|
||||||
JOIN chapters c ON c.manga_id = m.id
|
JOIN chapters c ON c.manga_id = m.id
|
||||||
JOIN pages p ON p.chapter_id = c.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
|
GROUP BY m.id
|
||||||
) x
|
) x
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
.bind(search)
|
.bind(&search)
|
||||||
.fetch_one(pool)
|
.fetch_one(pool)
|
||||||
.await?;
|
.await?;
|
||||||
Ok((rows, total))
|
Ok((rows, total))
|
||||||
@@ -377,7 +381,7 @@ pub async fn list_history(
|
|||||||
) -> AppResult<(Vec<AnalysisHistoryRow>, i64)> {
|
) -> AppResult<(Vec<AnalysisHistoryRow>, i64)> {
|
||||||
let search_pat = filter
|
let search_pat = filter
|
||||||
.search
|
.search
|
||||||
.map(|s| format!("%{}%", s.trim()))
|
.map(|s| format!("%{}%", crate::repo::escape_like(s.trim())))
|
||||||
.filter(|p| p.len() > 2);
|
.filter(|p| p.len() > 2);
|
||||||
|
|
||||||
let items = sqlx::query_as::<_, AnalysisHistoryRow>(
|
let items = sqlx::query_as::<_, AnalysisHistoryRow>(
|
||||||
@@ -402,7 +406,7 @@ pub async fn list_history(
|
|||||||
WHERE pa.status <> 'pending'
|
WHERE pa.status <> 'pending'
|
||||||
AND ($1::text IS NULL OR pa.status = $1)
|
AND ($1::text IS NULL OR pa.status = $1)
|
||||||
AND ($2::bool IS FALSE OR pa.is_nsfw)
|
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
|
ORDER BY pa.analyzed_at DESC NULLS LAST, pa.page_id
|
||||||
LIMIT $4 OFFSET $5
|
LIMIT $4 OFFSET $5
|
||||||
"#,
|
"#,
|
||||||
@@ -425,7 +429,7 @@ pub async fn list_history(
|
|||||||
WHERE pa.status <> 'pending'
|
WHERE pa.status <> 'pending'
|
||||||
AND ($1::text IS NULL OR pa.status = $1)
|
AND ($1::text IS NULL OR pa.status = $1)
|
||||||
AND ($2::bool IS FALSE OR pa.is_nsfw)
|
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)
|
.bind(filter.status)
|
||||||
|
|||||||
@@ -120,27 +120,11 @@ pub async fn list_for_page(
|
|||||||
Ok(rows.into_iter().map(|(t,)| t).collect())
|
Ok(rows.into_iter().map(|(t,)| t).collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Escape a string for use as a LIKE pattern fragment: `%`, `_`, and
|
// LIKE-escaping the autocomplete prefix is defence-in-depth: the public API
|
||||||
/// `\` get a leading backslash so they're matched literally rather
|
// already rejects `%`/`_`/`\` in `normalize_tag` before they reach this repo, so
|
||||||
/// than as wildcards / escapes. The matching queries below pair this
|
// a stray wildcard can only arrive from a future internal caller (worker, CLI)
|
||||||
/// with `ESCAPE '\'` for explicitness — a single backslash, since the
|
// that bypasses the normalizer. Shared with the other search sites.
|
||||||
/// SQL lives in a raw string and Postgres treats `\\` in a single-
|
use crate::repo::escape_like as escape_like_prefix;
|
||||||
/// quoted literal as one backslash under `standard_conforming_strings`.
|
|
||||||
///
|
|
||||||
/// The public API rejects `%`/`_`/`\` in `normalize_tag` before
|
|
||||||
/// they reach this repo, so this is defence-in-depth — a future
|
|
||||||
/// internal caller (worker, CLI) that bypasses the normalizer can't
|
|
||||||
/// turn a prefix filter into a wildcard search by accident.
|
|
||||||
fn escape_like_prefix(s: &str) -> String {
|
|
||||||
let mut out = String::with_capacity(s.len());
|
|
||||||
for ch in s.chars() {
|
|
||||||
if matches!(ch, '\\' | '%' | '_') {
|
|
||||||
out.push('\\');
|
|
||||||
}
|
|
||||||
out.push(ch);
|
|
||||||
}
|
|
||||||
out
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Paged list of `user_id`'s tagged pages, with breadcrumb. When
|
/// Paged list of `user_id`'s tagged pages, with breadcrumb. When
|
||||||
/// `tag_filter` is `Some(_)`, restrict to that exact tag (used by the
|
/// `tag_filter` is `Some(_)`, restrict to that exact tag (used by the
|
||||||
|
|||||||
@@ -124,7 +124,7 @@ pub async fn list(
|
|||||||
SELECT id, name, created_at
|
SELECT id, name, created_at
|
||||||
FROM tags
|
FROM tags
|
||||||
WHERE $1::text IS NULL
|
WHERE $1::text IS NULL
|
||||||
OR name ILIKE '%' || $1 || '%'
|
OR name ILIKE '%' || $3 || '%' ESCAPE '\'
|
||||||
OR name % $1
|
OR name % $1
|
||||||
ORDER BY CASE WHEN $1::text IS NULL THEN 0 ELSE similarity(name, $1) END DESC,
|
ORDER BY CASE WHEN $1::text IS NULL THEN 0 ELSE similarity(name, $1) END DESC,
|
||||||
lower(name) ASC
|
lower(name) ASC
|
||||||
@@ -133,6 +133,9 @@ pub async fn list(
|
|||||||
)
|
)
|
||||||
.bind(search)
|
.bind(search)
|
||||||
.bind(limit)
|
.bind(limit)
|
||||||
|
// $3: LIKE-escaped term for the ILIKE branch so `%`/`_` match literally; the
|
||||||
|
// trigram/similarity branches keep the raw $1.
|
||||||
|
.bind(search.map(crate::repo::escape_like))
|
||||||
.fetch_all(pool)
|
.fetch_all(pool)
|
||||||
.await?;
|
.await?;
|
||||||
Ok(rows)
|
Ok(rows)
|
||||||
|
|||||||
@@ -106,14 +106,14 @@ pub async fn list_with_total(
|
|||||||
let pat = q
|
let pat = q
|
||||||
.search
|
.search
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|s| format!("%{}%", s.trim()))
|
.map(|s| format!("%{}%", crate::repo::escape_like(s.trim())))
|
||||||
.filter(|p| p.len() > 2);
|
.filter(|p| p.len() > 2);
|
||||||
|
|
||||||
let items = sqlx::query_as::<_, User>(
|
let items = sqlx::query_as::<_, User>(
|
||||||
r#"
|
r#"
|
||||||
SELECT id, username, password_hash, created_at, is_admin
|
SELECT id, username, password_hash, created_at, is_admin
|
||||||
FROM users
|
FROM users
|
||||||
WHERE ($1::text IS NULL OR username ILIKE $1)
|
WHERE ($1::text IS NULL OR username ILIKE $1 ESCAPE '\')
|
||||||
ORDER BY username
|
ORDER BY username
|
||||||
LIMIT $2 OFFSET $3
|
LIMIT $2 OFFSET $3
|
||||||
"#,
|
"#,
|
||||||
@@ -125,7 +125,7 @@ pub async fn list_with_total(
|
|||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
let total: i64 = sqlx::query_scalar(
|
let total: i64 = sqlx::query_scalar(
|
||||||
"SELECT COUNT(*) FROM users WHERE ($1::text IS NULL OR username ILIKE $1)",
|
"SELECT COUNT(*) FROM users WHERE ($1::text IS NULL OR username ILIKE $1 ESCAPE '\\')",
|
||||||
)
|
)
|
||||||
.bind(&pat)
|
.bind(&pat)
|
||||||
.fetch_one(pool)
|
.fetch_one(pool)
|
||||||
|
|||||||
@@ -147,6 +147,46 @@ async fn list_filters_by_substring_search(pool: PgPool) {
|
|||||||
assert_eq!(body["page"]["total"], 1);
|
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 -------------------------------------------------------
|
// ---- self-protection -------------------------------------------------------
|
||||||
|
|
||||||
#[sqlx::test(migrations = "./migrations")]
|
#[sqlx::test(migrations = "./migrations")]
|
||||||
|
|||||||
@@ -30,6 +30,47 @@ fn first_author_id(manga: &Value) -> String {
|
|||||||
manga["authors"][0]["id"].as_str().unwrap().to_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")]
|
#[sqlx::test(migrations = "./migrations")]
|
||||||
async fn get_returns_name_and_manga_count(pool: PgPool) {
|
async fn get_returns_name_and_manga_count(pool: PgPool) {
|
||||||
let h = common::harness(pool);
|
let h = common::harness(pool);
|
||||||
|
|||||||
@@ -135,6 +135,30 @@ async fn list_total_is_computed_only_on_the_first_page(pool: PgPool) {
|
|||||||
assert_eq!(body1["items"].as_array().unwrap().len(), 1);
|
assert_eq!(body1["items"].as_array().unwrap().len(), 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[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;
|
||||||
|
// Two long titles differing only at one position. `_` is a LIKE single-char
|
||||||
|
// wildcard: unescaped, `%a_b%` matches BOTH ("axb" and "a_b"); escaped, only
|
||||||
|
// the literal "a_b" title matches. The titles are long and the term short so
|
||||||
|
// trigram similarity stays under threshold — the ILIKE branch decides.
|
||||||
|
seed(&h.app, &cookie, "The Quick Brown Fox Jumps axb Over The Lazy Dog").await;
|
||||||
|
seed(&h.app, &cookie, "The Quick Brown Fox Jumps a_b Over The Lazy Dog").await;
|
||||||
|
|
||||||
|
let resp = h
|
||||||
|
.app
|
||||||
|
.oneshot(common::get("/api/v1/mangas?search=a_b"))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let body = common::body_json(resp).await;
|
||||||
|
assert_eq!(
|
||||||
|
title_list(&body),
|
||||||
|
vec!["The Quick Brown Fox Jumps a_b Over The Lazy Dog"],
|
||||||
|
"the `_` in the search term must match literally, not as a wildcard"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[sqlx::test(migrations = "./migrations")]
|
#[sqlx::test(migrations = "./migrations")]
|
||||||
async fn search_via_trigram_tolerates_typos(pool: PgPool) {
|
async fn search_via_trigram_tolerates_typos(pool: PgPool) {
|
||||||
let h = common::harness(pool);
|
let h = common::harness(pool);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "mangalord-frontend",
|
"name": "mangalord-frontend",
|
||||||
"version": "0.128.5",
|
"version": "0.128.6",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
Reference in New Issue
Block a user