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 "<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
|
test(#134): report.py Coverage 44.3% → 52.7%
- TestGetScoreColor: alle 5 Branches (>=7 blue, >=4 green, >=2 yellow,
>=1 orange, sonst red)
- TestGetRatingSymbol: alle 5 Symbole (++, +, ○, −, −−)
Verbleibend (Lines 487-641): WeasyPrint-PDF-Render-Pfade — brauchen
echtes WeasyPrint-Setup, gehoeren in tests/integration/.
Total: 53.2% → 53.4%, 777 → 787 Tests.
2026-04-28 11:13:20 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ─── Coverage-Backfill (#134) ────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestGetScoreColor:
|
|
|
|
|
|
def test_high_score_blue(self):
|
|
|
|
|
|
from app.report import get_score_color
|
|
|
|
|
|
assert get_score_color(8.5).lower().startswith("#")
|
|
|
|
|
|
assert get_score_color(8.5) == get_score_color(7.0) # gleiche Klasse
|
|
|
|
|
|
|
|
|
|
|
|
def test_mid_score_green(self):
|
|
|
|
|
|
from app.report import get_score_color, COLORS
|
|
|
|
|
|
assert get_score_color(5.0) == COLORS["green"]
|
|
|
|
|
|
|
|
|
|
|
|
def test_low_yellow(self):
|
|
|
|
|
|
from app.report import get_score_color
|
|
|
|
|
|
assert get_score_color(2.5) == "#FFC20E"
|
|
|
|
|
|
|
|
|
|
|
|
def test_very_low_orange(self):
|
|
|
|
|
|
from app.report import get_score_color, COLORS
|
|
|
|
|
|
assert get_score_color(1.5) == COLORS["orange"]
|
|
|
|
|
|
|
|
|
|
|
|
def test_zero_red(self):
|
|
|
|
|
|
from app.report import get_score_color, COLORS
|
|
|
|
|
|
assert get_score_color(0.0) == COLORS["red"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestGetRatingSymbol:
|
|
|
|
|
|
def test_strong_positive(self):
|
|
|
|
|
|
from app.report import get_rating_symbol
|
|
|
|
|
|
assert get_rating_symbol(2) == "++"
|
|
|
|
|
|
assert get_rating_symbol(5) == "++"
|
|
|
|
|
|
|
|
|
|
|
|
def test_positive(self):
|
|
|
|
|
|
from app.report import get_rating_symbol
|
|
|
|
|
|
assert get_rating_symbol(1) == "+"
|
|
|
|
|
|
|
|
|
|
|
|
def test_neutral(self):
|
|
|
|
|
|
from app.report import get_rating_symbol
|
|
|
|
|
|
assert get_rating_symbol(0) == "○"
|
|
|
|
|
|
|
|
|
|
|
|
def test_negative(self):
|
|
|
|
|
|
from app.report import get_rating_symbol
|
|
|
|
|
|
assert get_rating_symbol(-1) == "−"
|
|
|
|
|
|
|
|
|
|
|
|
def test_strong_negative(self):
|
|
|
|
|
|
from app.report import get_rating_symbol
|
|
|
|
|
|
assert get_rating_symbol(-2) == "−−"
|
|
|
|
|
|
assert get_rating_symbol(-5) == "−−"
|