gwoe-antragspruefer/tests/test_report.py
Dotty Dotter 64cbff5286 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

195 lines
8.1 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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