195 lines
8.1 KiB
Python
195 lines
8.1 KiB
Python
|
|
"""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 "<script>" 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 "<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 "<script>" 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 ``"`` 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 """ 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 "<script>" in html
|
|||
|
|
assert "<img" in html
|
|||
|
|
|
|||
|
|
# Format-Redline-Marker müssen weiterhin funktionieren (Vorschlag mit **)
|
|||
|
|
assert '<span class="inserted">' in html
|