From b00c36e9d19c11f90d6b8b221d2368e563002360 Mon Sep 17 00:00:00 2001 From: Niruta Neupane Date: Thu, 13 Aug 2026 07:51:38 +0545 Subject: [PATCH] fix(query): escape LIKE wildcards in contributor search The leaderboard `q` search interpolated user input straight into a LIKE pattern, so `_` matched any character and `%` any sequence: q=a_il returned "Anil Basnet" and a bare q=% returned the entire leaderboard instead of nothing. Not injection, the query was already parameterised. Escape `%`, `_` and the escape character itself, paired with ESCAPE at the call site. Adds a regression test; it fails on the parent commit. --- osmsg/query.py | 11 +++++++++-- tests/test_query.py | 8 ++++++++ 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/osmsg/query.py b/osmsg/query.py index b297b4e..6ee6306 100644 --- a/osmsg/query.py +++ b/osmsg/query.py @@ -48,6 +48,13 @@ def _rows(result) -> list[dict[str, Any]]: return [dict(zip(cols, r, strict=True)) for r in result.fetchall()] +def _like_escape(value: str) -> str: + """Escape LIKE metacharacters so a contributor search matches them literally. Without this `_` + matches any character and `%` any sequence, so searching `a_il` returns `Anil` and `%` returns + everyone. Paired with `ESCAPE '\\'` at the call site.""" + return value.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") + + def _prefixes(hashtag: str | list[str]) -> list[tuple[str, str]]: """Normalize one hashtag or many into deduped `(lo, hi)` prefix-range pairs, case-insensitive and order-preserving. Each hashtag matches as a prefix; the scope is the union across them.""" @@ -412,8 +419,8 @@ def leaderboard( rrel, rp = _recent_leaderboard(s, prefixes, start, end) search_pred, search_params = "", [] if q: - search_pred = " WHERE lower(name) LIKE ?" - search_params = [f"%{q.strip().lower()}%"] + search_pred = " WHERE lower(name) LIKE ? ESCAPE '\\'" + search_params = [f"%{_like_escape(q.strip().lower())}%"] con.execute( f""" CREATE OR REPLACE TEMP TABLE _lb_agg AS diff --git a/tests/test_query.py b/tests/test_query.py index dc7a527..7bba2de 100644 --- a/tests/test_query.py +++ b/tests/test_query.py @@ -87,6 +87,14 @@ def test_leaderboard_page_size_and_sort(con, sources): assert found["total"] == 1 and found["items"][0]["name"] == "alice" +def test_leaderboard_search_escapes_like_wildcards(con, sources): + # `_` and `%` are LIKE metacharacters: unescaped, `a_ice` matched `alice` and a bare `%` returned + # every contributor. They must match literally. + assert query.leaderboard(con, "hotosm", sources, q="ali")["total"] == 1 + assert query.leaderboard(con, "hotosm", sources, q="a_ice")["total"] == 0 + assert query.leaderboard(con, "hotosm", sources, q="%")["total"] == 0 + + def test_leaderboard_includes_per_user_tag_stats(con, sources): # The frontend reads per-user `tag_stats` (nested {key: {value: {c, m}}}) to show building/highway # per contributor; regression guard that leaderboard rows carry it across the history+recent seam.