From 1e57aa83236494bd4ec791f9ac073f9f72894557 Mon Sep 17 00:00:00 2001 From: chihlasm Date: Fri, 6 Feb 2026 00:26:35 -0500 Subject: [PATCH] fix: escape SQL wildcards in tag search autocomplete The % and _ characters in user search input are now escaped before the LIKE query, preventing unintended wildcard matching in tag search. Co-Authored-By: Claude Opus 4.6 --- backend/app/api/endpoints/tags.py | 3 ++- backend/tests/test_trees.py | 31 +++++++++++++++++++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/backend/app/api/endpoints/tags.py b/backend/app/api/endpoints/tags.py index e7c41460..4f764544 100644 --- a/backend/app/api/endpoints/tags.py +++ b/backend/app/api/endpoints/tags.py @@ -62,8 +62,9 @@ async def search_tags( Searches tag names for the query string. Returns matching tags ordered by usage count. """ + escaped_q = q.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") query = select(TreeTag).where( - TreeTag.name.ilike(f"%{q}%") + TreeTag.name.ilike(f"%{escaped_q}%", escape="\\") ) # Filter by visibility diff --git a/backend/tests/test_trees.py b/backend/tests/test_trees.py index cc74230f..4b8a2f16 100644 --- a/backend/tests/test_trees.py +++ b/backend/tests/test_trees.py @@ -275,6 +275,37 @@ class TestTrees: ) assert len(tag_rows_after.fetchall()) == 0 + @pytest.mark.asyncio + async def test_tag_search_escapes_wildcards( + self, client: AsyncClient, admin_auth_headers: dict + ): + """Test that SQL wildcards in tag search are escaped, not interpreted.""" + # Create tags as admin (can create global tags) + resp1 = await client.post( + "/api/v1/tags", + json={"name": "test_underscore"}, + headers=admin_auth_headers + ) + assert resp1.status_code == 201 + + resp2 = await client.post( + "/api/v1/tags", + json={"name": "testXunderscore"}, + headers=admin_auth_headers + ) + assert resp2.status_code == 201 + + # Search for literal underscore — should only match the first tag + response = await client.get( + "/api/v1/tags/search", + params={"q": "test_under"}, + headers=admin_auth_headers + ) + assert response.status_code == 200 + names = [t["name"] for t in response.json()] + assert "test_underscore" in names + assert "testXunderscore" not in names + @pytest.mark.asyncio async def test_create_tree_unauthorized(self, client: AsyncClient): """Test that creating a tree without auth fails."""