fix: decode tag names from the URL only once

The tag lookup, rename and delete routes ran urllib.parse.unquote on
match_info values that aiohttp had already percent-decoded, so a tag
literally named '%41' (requested as %2541) was handled as 'A'. The
second decode is dropped.
This commit is contained in:
Vito Sansevero
2026-10-08 18:01:34 -07:00
parent cd209131ee
commit 9db5fc6acc
2 changed files with 42 additions and 9 deletions
+9 -9
View File
@@ -387,9 +387,9 @@ class PromptRoutesMixin:
async def get_tag_prompts(self, request):
"""Get prompts for a single tag."""
try:
from urllib.parse import unquote
tag_name = unquote(request.match_info.get("tag_name", ""))
# aiohttp has already percent-decoded match_info; decoding again
# would turn a tag literally named "%41" into "A".
tag_name = request.match_info.get("tag_name", "")
if not tag_name:
return web.json_response(
{"success": False, "error": "Tag name required"}, status=400
@@ -497,9 +497,9 @@ class PromptRoutesMixin:
async def rename_tag_endpoint(self, request):
"""Rename a tag across all prompts."""
try:
from urllib.parse import unquote
tag_name = unquote(request.match_info.get("tag_name", ""))
# aiohttp has already percent-decoded match_info; decoding again
# would turn a tag literally named "%41" into "A".
tag_name = request.match_info.get("tag_name", "")
if not tag_name:
return web.json_response(
{"success": False, "error": "Tag name required"}, status=400
@@ -542,9 +542,9 @@ class PromptRoutesMixin:
async def delete_tag_endpoint(self, request):
"""Delete a tag from all prompts."""
try:
from urllib.parse import unquote
tag_name = unquote(request.match_info.get("tag_name", ""))
# aiohttp has already percent-decoded match_info; decoding again
# would turn a tag literally named "%41" into "A".
tag_name = request.match_info.get("tag_name", "")
if not tag_name:
return web.json_response(
{"success": False, "error": "Tag name required"}, status=400
+33
View File
@@ -431,6 +431,39 @@ class TestPublicPathsInPromptResponses(RouteCoverageCase):
self.assertIn("prompts.db", data["error"], route)
class TestTagNamesAreDecodedOnce(RouteCoverageCase):
"""aiohttp already decodes match_info; a tag literally named '%41' stays '%41'."""
async def setUpAsync(self):
await super().setUpAsync()
self.pid = self._save_prompt("percent tag", tags=["%41"])
async def test_tag_prompts_lookup(self):
status, data = await self._json("GET", "/prompt_manager/tags/%2541/prompts")
self.assertEqual(status, 200)
self.assertEqual(data["tag"], "%41")
self.assertEqual(len(data["prompts"]), 1)
async def test_rename_tag(self):
status, data = await self._json(
"PUT", "/prompt_manager/tags/%2541", json={"new_name": "renamed"}
)
self.assertEqual(status, 200)
self.assertEqual(data["old_name"], "%41")
self.assertEqual(data["affected_count"], 1)
self.assertEqual(self.api.db.get_prompt_by_id(self.pid)["tags"], ["renamed"])
async def test_delete_tag(self):
status, data = await self._json("DELETE", "/prompt_manager/tags/%2541")
self.assertEqual(status, 200)
self.assertEqual(data["tag_name"], "%41")
self.assertEqual(data["affected_count"], 1)
self.assertEqual(self.api.db.get_prompt_by_id(self.pid)["tags"], [])
class TestTagMutationRoutes(RouteCoverageCase):
async def test_rename_rejects_bad_json_and_empty_name(self):