diff --git a/sanctions/src/main.py b/sanctions/src/main.py index 0f8023d..8108c74 100644 --- a/sanctions/src/main.py +++ b/sanctions/src/main.py @@ -4,6 +4,7 @@ Sprint 1: Name screening against OpenSanctions (yente) with LLM enhancement. """ +import html as html_lib import json import os @@ -70,6 +71,9 @@ async def search_ui(request: Request, q: str = "", threshold: float = 0.7): error = None raw_json = "" + def escape_text(value: object) -> str: + return html_lib.escape(str(value), quote=True) + if q: try: results = await _query_yente(q, threshold) @@ -106,17 +110,20 @@ def dataset_badge(ds: str) -> str: "peps": ("bg-orange-100 text-orange-800", "PEP"), } cls, label = colors.get(ds, ("bg-gray-100 text-gray-700", ds.upper())) - return f'{label}' + return ( + f'' + f"{escape_text(label)}" + ) def prop_row(key: str, vals: list) -> str: if not vals: return "" - joined = " · ".join(str(v) for v in vals[:5]) + joined = " · ".join(escape_text(v) for v in vals[:5]) if len(vals) > 5: joined += f" +{len(vals)-5} weitere" return f""" - {key} + {escape_text(key)} {joined} """ @@ -130,13 +137,15 @@ def prop_row(key: str, vals: list) -> str: color = score_color(m["score"]) props = m.get("properties", {}) rows = "".join(prop_row(k, props.get(k, [])) for k in SHOW_PROPS if props.get(k)) + safe_name = escape_text(m["name"]) + safe_id = escape_text(m["id"]) match_cards += f"""
-

{m['name']}

-

ID: {m['id']}

+

{safe_name}

+

ID: {safe_id}

{score_pct}%
@@ -156,22 +165,27 @@ def prop_row(key: str, vals: list) -> str: {match_cards if matches else ""}""" if error: - result_section = f'
⚠️ {error}
' + result_section = ( + '
' + f"⚠️ {escape_text(error)}
" + ) json_section = "" if raw_json: + safe_raw_json = escape_text(raw_json) json_section = f"""

JSON Output

-
{raw_json}
+
{safe_raw_json}
""" + safe_query = escape_text(q) threshold_options = "".join( f'' for v in [0.5, 0.6, 0.7, 0.8, 0.9] ) - html = f""" + page_html = f""" @@ -199,7 +213,7 @@ def prop_row(key: str, vals: list) -> str: str: """ - return HTMLResponse(content=html) + return HTMLResponse(content=page_html) @app.post("/api/screen", response_model=ScreenResponse) diff --git a/sanctions/tests/test_main.py b/sanctions/tests/test_main.py index a6abc55..6ac1c77 100644 --- a/sanctions/tests/test_main.py +++ b/sanctions/tests/test_main.py @@ -3,6 +3,7 @@ Sprint 1: Grundlegende Unit-Tests ohne externe Services. """ +import html from unittest.mock import AsyncMock, patch import httpx @@ -36,6 +37,31 @@ def test_search_ui_with_query(): assert "text/html" in response.headers["content-type"] +def test_search_ui_escapes_query_and_result_fields(): + """Query und Trefferdaten werden HTML-escaped gerendert.""" + payload = '\">' + mock_results = [ + { + "id": "", + "caption": "Bad", + "score": 0.9, + "datasets": ["sanctions"], + "properties": {"notes": ['']}, + } + ] + + with patch("sanctions.src.main._query_yente", new=AsyncMock(return_value=mock_results)): + response = client.get("/search", params={"q": payload}) + + assert response.status_code == 200 + assert payload not in response.text + assert "Bad" not in response.text + assert "" not in response.text + assert html.escape(payload, quote=True) in response.text + assert "<b>Bad</b>" in response.text + assert "<img src=x onerror=alert(1)>" in response.text + + @pytest.mark.asyncio async def test_screen_endpoint_mocked(): """POST /api/screen mit gemocktem yente-Aufruf."""