gwoe-antragspruefer/tests/test_report.py

195 lines
8.1 KiB
Python
Raw Normal View History

Security hotfixes #1, #2, #6 from audit (#57) Drei akute Befunde aus dem Live-System-Audit (Issue #57): - **#1 HIGH** — Resource Exhaustion via öffentlichem POST: slowapi Limiter (in-memory, IP-key) auf /analyze (10/min), /api/analyze-drucksache (10/min) und /api/programme/index (3/min). Verhindert, dass ein unauthentifizierter Client mit einer Schleife die DashScope-Quota oder die CPU des Containers leerziehen kann. Default-Storage reicht solange wir auf einem einzigen Worker laufen. - **#2 MEDIUM** + **#6 MEDIUM** (selber Root-Cause) — XXE/Local-File-Read via WeasyPrint und Stored XSS via Browser-Rendering: alle LLM-getragenen Felder in app/report.py laufen jetzt durch html.escape() bevor sie in die HTML-Template interpoliert werden. format_redline_html escape-first und ersetzt dann die Markdown-Marker durch von uns kontrollierte <span>-Tags. build_matrix_html escaped das aspect-Attribut, sodass ein nacktes " den title="..."-Wert nicht mehr beenden und einen Event- Handler injizieren kann. Toter jinja2-Import in report.py entfernt (war never used, blockierte nur den lokalen Test). - **Tests** — neue tests/test_report.py mit 8 Cases, die direkt die Bug-Klasse verifizieren: <script>, file://-img, "-attribut-breakout in Title und ein End-to-End-Render mit XSS-Payloads in jedem LLM-Feld. Die Marker-Funktionalität (** und ~~) wird mit-getestet, damit der Escape-First-Ansatz das nicht versehentlich kaputt macht. 77 alte Unit-Tests + 8 neue → 85 grün. Rate-Limit-Verifikation per TestClient ist Integration-Scope und folgt in tests/integration/test_main_security.py als separates Folge-Item. Refs: #57 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 10:45:43 +02:00
"""Tests für app.report — insbesondere XSS/XXE-Escape-Verhalten.
Abdeckung der Bug-Klasse aus Issue #57 (Security Audit, Befunde #2 und #6):
das LLM-Output landet direkt in der HTML-Vorlage, die von WeasyPrint
gerendert wird. Ohne Escape kann eine Prompt-Injection mit einem rohen
``<img src="file:///etc/passwd">`` Local File Read im Container auslösen
oder ``<script>``-Payloads im Browser des Lesers ausführen, der den
HTML-Report über ``/result/{job_id}`` öffnet.
Diese Tests bauen ein Assessment mit kontrollierten XSS-Payloads in jedem
LLM-getragenen Feld und verifizieren, dass die generierte HTML die rohen
Payloads nicht enthält und die escapeden Varianten dafür schon.
"""
from __future__ import annotations
import asyncio
from pathlib import Path
import pytest
from app.models import (
Assessment,
Empfehlung,
FraktionScores,
MatrixEntry,
ProgrammScore,
Verbesserung,
Verbesserungspotenzial,
)
from app.report import build_matrix_html, format_redline_html, generate_html_report
# ─────────────────────────────────────────────────────────────────────────────
# format_redline_html unit tests
# ─────────────────────────────────────────────────────────────────────────────
class TestFormatRedlineHtml:
"""``format_redline_html`` muss escape-first-then-replace fahren, sonst
überleben rohe HTML-Tags aus dem LLM die Konvertierung."""
def test_plain_text_passes_through(self):
assert format_redline_html("hallo welt") == "hallo welt"
def test_inserted_marker_becomes_span(self):
assert format_redline_html("**neu**") == '<span class="inserted">neu</span>'
def test_deleted_marker_becomes_span(self):
assert format_redline_html("~~weg~~") == '<span class="deleted">weg</span>'
def test_script_tag_in_input_is_escaped(self):
out = format_redline_html("vor <script>alert(1)</script> nach")
assert "<script>" not in out
assert "&lt;script&gt;" in out
def test_local_file_image_in_input_is_escaped(self):
out = format_redline_html('<img src="file:///etc/passwd">')
assert '<img src="file:///etc/passwd">' not in out
assert "&lt;img" in out
def test_marker_inside_escaped_tag_still_renders(self):
# Edge: ein Angreifer kann seine Payload in **markern** einbetten,
# die Marker müssen weiterhin als spans rendern, der HTML-Inhalt
# aber escaped sein.
out = format_redline_html("**<script>x</script>**")
assert "<span class=\"inserted\">" in out
assert "<script>" not in out
assert "&lt;script&gt;" in out
# ─────────────────────────────────────────────────────────────────────────────
# build_matrix_html title-attribute escape
# ─────────────────────────────────────────────────────────────────────────────
def _minimal_assessment(**overrides) -> Assessment:
"""Build a smallest-possible Assessment with all required fields,
plus deep-link overrides for the field under test."""
base = dict(
drucksache="18/12345",
title="Demo-Antrag",
fraktionen=["CDU"],
datum="2026-04-09",
link=None,
gwoe_score=5.0,
gwoe_begruendung="Begründung.",
gwoe_matrix=[
MatrixEntry(field="A1", label="Lieferant:innen × 1", aspect="kein Aspekt", rating=0),
],
gwoe_schwerpunkt=["Solidarität"],
wahlprogramm_scores=[
FraktionScores(
fraktion="CDU",
wahlprogramm=ProgrammScore(score=5.0, begruendung="ok", zitate=[]),
parteiprogramm=ProgrammScore(score=5.0, begruendung="ok", zitate=[]),
),
],
verbesserungen=[],
staerken=["eine stärke"],
schwaechen=["eine schwäche"],
empfehlung=Empfehlung.UEBERARBEITEN,
verbesserungspotenzial=Verbesserungspotenzial.MITTEL,
themen=[],
antrag_zusammenfassung="zusammenfassung",
antrag_kernpunkte=["punkt eins"],
)
base.update(overrides)
return Assessment(**base)
class TestBuildMatrixHtml:
def test_aspect_with_quote_does_not_break_title_attribute(self):
"""Ein nacktes ``"`` in ``aspect`` darf das ``title="..."``-Attribut
nicht beenden sonst kann ein folgender ``onmouseover=...``-Token
zu einem echten Event-Handler werden. Entscheidender Check: das
rohe ``"`` ist im Output durch ``&quot;`` ersetzt, sodass das
Attribut geschlossen bleibt."""
a = _minimal_assessment(
gwoe_matrix=[
MatrixEntry(
field="A1",
label="Lieferant:innen × 1",
aspect='" onmouseover="alert(1)',
rating=1,
),
],
)
html = build_matrix_html(a)
# Das rohe " aus dem aspect darf nicht mehr im title-Wert stehen
assert '"" onmouseover' not in html # Attribut-Breakout
assert '" onmouseover="alert' not in html
# Stattdessen muss die escapete Form vorhanden sein
assert "&quot;" in html
# ─────────────────────────────────────────────────────────────────────────────
# generate_html_report end-to-end XSS escape
# ─────────────────────────────────────────────────────────────────────────────
_XSS = "<script>alert('xss')</script>"
_XXE = '<img src="file:///etc/passwd">'
def _build_xss_assessment() -> Assessment:
return _minimal_assessment(
title=f"Antrag {_XSS}",
gwoe_begruendung=f"Begründung mit {_XSS}",
gwoe_schwerpunkt=[f"Schwerpunkt {_XXE}"],
antrag_zusammenfassung=f"Zusammenfassung mit {_XXE}",
antrag_kernpunkte=[f"Kernpunkt mit {_XSS}"],
staerken=[f"Stärke mit {_XSS}"],
schwaechen=[f"Schwäche mit {_XXE}"],
gwoe_matrix=[
MatrixEntry(field="A1", label="Lieferant:innen × 1",
aspect=f"Aspekt {_XSS}", rating=2),
],
wahlprogramm_scores=[
FraktionScores(
fraktion=f"Fraktion {_XSS}",
wahlprogramm=ProgrammScore(score=5.0, begruendung=f"WP {_XXE}", zitate=[]),
parteiprogramm=ProgrammScore(score=5.0, begruendung=f"PP {_XSS}", zitate=[]),
),
],
verbesserungen=[
Verbesserung(
original=f"Original {_XSS}",
vorschlag=f"Vorschlag **mit {_XSS} markup**",
begruendung=f"Begründung {_XXE}",
),
],
)
def test_generate_html_report_escapes_all_llm_payloads(tmp_path: Path):
a = _build_xss_assessment()
out = tmp_path / "report.html"
asyncio.run(generate_html_report(a, out, bundesland="NRW"))
html = out.read_text()
# Negative: keine rohen Angriffs-Strings
assert "<script>" not in html, "rohes <script> aus LLM-Felder im Output"
assert "alert('xss')" not in html
assert 'src="file:///etc/passwd"' not in html, (
"rohes file:// in HTML würde WeasyPrint zum Local-File-Read verleiten"
)
# Positive: escapete Form ist vorhanden (also wurde der Payload überhaupt
# mit-gerendert, nur eben sicher)
assert "&lt;script&gt;" in html
assert "&lt;img" in html
# Format-Redline-Marker müssen weiterhin funktionieren (Vorschlag mit **)
assert '<span class="inserted">' in html