Regelmäßige Security Audits — initiale Befunde + wiederkehrender Prozess #57

Closed
opened 2026-04-09 10:16:34 +02:00 by tobias · 4 comments
Owner

Ziel

Einen wiederkehrenden Security-Audit-Prozess für die GWÖ-Antragsprüfer-Webapp etablieren (nicht nur einmalig). Das Live-System läuft öffentlich auf https://gwoe.toppyr.de ohne Authentication, und mehrere Endpoints triggern teure LLM-Calls oder schreiben in die DB. Ein einmaliger Audit-Pass am 2026-04-09 hat 8 Befunde ergeben, die hier dokumentiert sind. Zusätzlich soll ein wiederkehrender Audit alle 4 Wochen als Issue-Template oder CI-Job eingerichtet werden, damit neue Endpoints nicht durch die Maschen fallen.

Initiale Befunde (Audit 2026-04-09)

1. HIGH — Resource Exhaustion via öffentlichem /api/analyze-drucksache

Datei: app/main.py:414–452

Endpoint öffentlich, kein Rate-Limit, jeder Call löst 4k-Token-LLM-Job gegen DashScope aus. Angreifer kann massenweise Jobs anstoßen und Quota oder CPU erschöpfen.

Fix-Skizze: slowapi (FastAPI-Adapter für limits) installieren, globales Rate-Limit z.B. 10 req/min/IP auf POST-Routen. ~5min Patch.

2. MEDIUM — XXE / Local File Read via WeasyPrint-PDF-Renderer

Datei: app/report.py:353,370,381,394,401,410,421,427 → WeasyPrint Aufruf Z.471

antrag_zusammenfassung und gwoe_begruendung werden roh aus LLM-Output in HTML interpoliert. Eine Prompt-Injection mit <img src="file:///etc/passwd"> oder <link rel=stylesheet href="file:///app/data/gwoe-antraege.db"> löst Local File Read im Container-Kontext aus.

Fix-Skizze: html.escape() (stdlib) auf alle LLM-Felder vor String-Interpolation. Alternativ Jinja2-Template mit Auto-Escaping statt F-Strings.

3. MEDIUM — Path Traversal auf Drucksache-Parameter

Datei: app/main.py:286–337

drucksache.replace('/', '-') ohne Whitelist-Validation; drucksache=../../etc/passwd baut auffällige Pfade. Aktuell durch Path-Struktur eingedämmt aber nicht explizit blockiert.

Fix-Skizze: Regex ^\d+/\d+(\(neu\))?$ als Pflicht-Validation in einer FastAPI-Dependency, fehlerhafte IDs mit 400 ablehnen.

4. MEDIUM — PII / LLM-Content im Container-Log

Dateien: app/analyzer.py:235,308–314, app/main.py:515–516

  • print(f"Failed JSON content (first 500 chars): {content[:500]}") — dumped LLM-Output in stdout
  • print(f"ERROR in run_drucksache_analysis for {drucksache}: {e}") — Drucksache-ID + Stack

Docker-Logs gehen via docker logs nach außen, sensible Antrags-Inhalte oder Prompt-Material landen ungemaskt im Log.

Fix-Skizze: alle print() durch logging.getLogger(__name__) ersetzen, LLM-Content nur als Hash + Länge loggen, nicht als Volltext. Bezug zu bestehender Memory-Regel 'stille excepts in Adaptern'.

5. MEDIUM — Keine CSRF-Protection auf POST-Endpoints

Datei: app/main.py:108–165, 414–452, 573–597

Drei POST-Routen (/analyze, /api/analyze-drucksache, /api/programme/index) ohne CSRF-Token. Angreifer-gehostete <form action="https://gwoe.toppyr.de/analyze"> startet Job im Browser-Kontext eines Nutzers.

Fix-Skizze: fastapi-csrf-protect Middleware. Kollidiert mit fehlender Auth — wird vermutlich erst nach Keycloak-Integration sinnvoll.

6. MEDIUM — Stored XSS via LLM-Output in HTML-Reports

Datei: app/main.py:179–192 (/result/{job_id}) + app/report.py HTML-Build

assessment.title, antrag_zusammenfassung etc. werden per F-String ohne html.escape() in HTML-Template interpoliert. <script>alert(1)</script> aus LLM-Output wird im Browser des Lesers ausgeführt. Selber Root-Cause wie #2 — derselbe Patch fixt beides.

Fix-Skizze: wie #2.

7. LOW-MEDIUM — Search-Query-DoS und stille shlex.split()-Fallback

Dateien: app/main.py:341–366, app/database.py:279

Keine Längenbegrenzung auf q-Parameter; shlex.split() wirft ValueError bei unbalanced quotes, wird stumm zu query.split() degradiert (Memory: stille excepts).

Fix-Skizze: q auf max. 200 Zeichen begrenzen, ValueError als logger.warning loggen statt verschlucken.

8. LOW — Secrets als ENV-Variable statt Mount

Datei: docker-compose.yml:8–9

DASHSCOPE_API_KEY=${DASHSCOPE_API_KEY} als ENV-Var → sichtbar in docker inspect, Container-Prozess-Listings.

Fix-Skizze: Auf Docker Secrets oder file-mount umstellen. Niedrige Priorität wenn nur ein Container und ein Operator.

Wiederkehrender Audit-Prozess

  • Alle 4 Wochen ein neues Audit-Issue mit Datums-Stamp anlegen (z.B. via Cron oder einem Scheduled-Workflow)
  • Audit-Checkliste in docs/security-audit-checklist.md festschreiben:
    1. Neue Endpoints seit letztem Audit auf Auth/Rate-Limit/Input-Validation prüfen
    2. Neue Adapter auf SSRF-Surface prüfen
    3. pip-audit oder safety über requirements.txt laufen lassen, Befunde im Issue dokumentieren
    4. git log --since='4 weeks ago' --diff-filter=A -- 'webapp/app/**.py' für neue Files reviewen
    5. docker logs gwoe-antragspruefer --since 4w | grep -i 'error\|exception\|traceback' für stille Fehler-Cluster
  • Optional: GitHub-Action / Gitea-Action für pip-audit + bandit als CI-Step

Acceptance Criteria — initial

  • Befund #1 (Rate-Limit) gepatcht und deployed
  • Befund #2 + #6 (HTML-Escaping) gepatcht und deployed
  • Befund #3 (Drucksache-Regex) gepatcht
  • Befund #4 (Logger statt print) gepatcht
  • docs/security-audit-checklist.md angelegt
  • Folge-Issues für #5 (CSRF, hängt an Keycloak), #7 (Query-Limits), #8 (Secrets) angelegt
  • pip-audit oder safety einmalig manuell laufen lassen, Befunde hier ergänzen

Acceptance Criteria — wiederkehrend

  • Audit-Checkliste in docs/
  • Cron oder Reminder für 4-Wochen-Rhythmus eingerichtet
  • Erstes Folge-Audit nach 4 Wochen im Kalender

Bezug

  • Memory-Regel 'stille excepts in Adaptern' deckt sich mit Befund #4 und #7
  • Keycloak-SSO-TODO aus CLAUDE.md ist Vorbedingung für #5 (CSRF)
  • Sub-Issue-A (#51) Live-Tests könnten einen Bandit-Scan-Step bekommen
## Ziel Einen wiederkehrenden Security-Audit-Prozess für die GWÖ-Antragsprüfer-Webapp etablieren (nicht nur einmalig). Das Live-System läuft öffentlich auf https://gwoe.toppyr.de ohne Authentication, und mehrere Endpoints triggern teure LLM-Calls oder schreiben in die DB. Ein einmaliger Audit-Pass am 2026-04-09 hat 8 Befunde ergeben, die hier dokumentiert sind. Zusätzlich soll ein **wiederkehrender Audit alle 4 Wochen** als Issue-Template oder CI-Job eingerichtet werden, damit neue Endpoints nicht durch die Maschen fallen. ## Initiale Befunde (Audit 2026-04-09) ### 1. HIGH — Resource Exhaustion via öffentlichem `/api/analyze-drucksache` **Datei:** `app/main.py:414–452` Endpoint öffentlich, kein Rate-Limit, jeder Call löst 4k-Token-LLM-Job gegen DashScope aus. Angreifer kann massenweise Jobs anstoßen und Quota oder CPU erschöpfen. **Fix-Skizze:** `slowapi` (FastAPI-Adapter für `limits`) installieren, globales Rate-Limit z.B. 10 req/min/IP auf POST-Routen. ~5min Patch. ### 2. MEDIUM — XXE / Local File Read via WeasyPrint-PDF-Renderer **Datei:** `app/report.py:353,370,381,394,401,410,421,427` → WeasyPrint Aufruf Z.471 `antrag_zusammenfassung` und `gwoe_begruendung` werden roh aus LLM-Output in HTML interpoliert. Eine Prompt-Injection mit `<img src="file:///etc/passwd">` oder `<link rel=stylesheet href="file:///app/data/gwoe-antraege.db">` löst Local File Read im Container-Kontext aus. **Fix-Skizze:** `html.escape()` (stdlib) auf alle LLM-Felder vor String-Interpolation. Alternativ Jinja2-Template mit Auto-Escaping statt F-Strings. ### 3. MEDIUM — Path Traversal auf Drucksache-Parameter **Datei:** `app/main.py:286–337` `drucksache.replace('/', '-')` ohne Whitelist-Validation; `drucksache=../../etc/passwd` baut auffällige Pfade. Aktuell durch Path-Struktur eingedämmt aber nicht explizit blockiert. **Fix-Skizze:** Regex `^\d+/\d+(\(neu\))?$` als Pflicht-Validation in einer FastAPI-Dependency, fehlerhafte IDs mit 400 ablehnen. ### 4. MEDIUM — PII / LLM-Content im Container-Log **Dateien:** `app/analyzer.py:235,308–314`, `app/main.py:515–516` - `print(f"Failed JSON content (first 500 chars): {content[:500]}")` — dumped LLM-Output in stdout - `print(f"ERROR in run_drucksache_analysis for {drucksache}: {e}")` — Drucksache-ID + Stack Docker-Logs gehen via `docker logs` nach außen, sensible Antrags-Inhalte oder Prompt-Material landen ungemaskt im Log. **Fix-Skizze:** alle `print()` durch `logging.getLogger(__name__)` ersetzen, LLM-Content nur als Hash + Länge loggen, nicht als Volltext. Bezug zu bestehender Memory-Regel 'stille excepts in Adaptern'. ### 5. MEDIUM — Keine CSRF-Protection auf POST-Endpoints **Datei:** `app/main.py:108–165, 414–452, 573–597` Drei POST-Routen (`/analyze`, `/api/analyze-drucksache`, `/api/programme/index`) ohne CSRF-Token. Angreifer-gehostete `<form action="https://gwoe.toppyr.de/analyze">` startet Job im Browser-Kontext eines Nutzers. **Fix-Skizze:** `fastapi-csrf-protect` Middleware. Kollidiert mit fehlender Auth — wird vermutlich erst nach Keycloak-Integration sinnvoll. ### 6. MEDIUM — Stored XSS via LLM-Output in HTML-Reports **Datei:** `app/main.py:179–192` (`/result/{job_id}`) + `app/report.py` HTML-Build `assessment.title`, `antrag_zusammenfassung` etc. werden per F-String ohne `html.escape()` in HTML-Template interpoliert. `<script>alert(1)</script>` aus LLM-Output wird im Browser des Lesers ausgeführt. Selber Root-Cause wie #2 — derselbe Patch fixt beides. **Fix-Skizze:** wie #2. ### 7. LOW-MEDIUM — Search-Query-DoS und stille `shlex.split()`-Fallback **Dateien:** `app/main.py:341–366`, `app/database.py:279` Keine Längenbegrenzung auf `q`-Parameter; `shlex.split()` wirft `ValueError` bei unbalanced quotes, wird stumm zu `query.split()` degradiert (Memory: stille excepts). **Fix-Skizze:** `q` auf max. 200 Zeichen begrenzen, `ValueError` als `logger.warning` loggen statt verschlucken. ### 8. LOW — Secrets als ENV-Variable statt Mount **Datei:** `docker-compose.yml:8–9` `DASHSCOPE_API_KEY=${DASHSCOPE_API_KEY}` als ENV-Var → sichtbar in `docker inspect`, Container-Prozess-Listings. **Fix-Skizze:** Auf Docker Secrets oder file-mount umstellen. Niedrige Priorität wenn nur ein Container und ein Operator. ## Wiederkehrender Audit-Prozess - [ ] **Alle 4 Wochen** ein neues Audit-Issue mit Datums-Stamp anlegen (z.B. via Cron oder einem Scheduled-Workflow) - [ ] Audit-Checkliste in `docs/security-audit-checklist.md` festschreiben: 1. Neue Endpoints seit letztem Audit auf Auth/Rate-Limit/Input-Validation prüfen 2. Neue Adapter auf SSRF-Surface prüfen 3. `pip-audit` oder `safety` über `requirements.txt` laufen lassen, Befunde im Issue dokumentieren 4. `git log --since='4 weeks ago' --diff-filter=A -- 'webapp/app/**.py'` für neue Files reviewen 5. `docker logs gwoe-antragspruefer --since 4w | grep -i 'error\|exception\|traceback'` für stille Fehler-Cluster - [ ] Optional: GitHub-Action / Gitea-Action für `pip-audit` + `bandit` als CI-Step ## Acceptance Criteria — initial - [ ] Befund #1 (Rate-Limit) gepatcht und deployed - [ ] Befund #2 + #6 (HTML-Escaping) gepatcht und deployed - [ ] Befund #3 (Drucksache-Regex) gepatcht - [ ] Befund #4 (Logger statt print) gepatcht - [ ] `docs/security-audit-checklist.md` angelegt - [ ] Folge-Issues für #5 (CSRF, hängt an Keycloak), #7 (Query-Limits), #8 (Secrets) angelegt - [ ] `pip-audit` oder `safety` einmalig manuell laufen lassen, Befunde hier ergänzen ## Acceptance Criteria — wiederkehrend - [ ] Audit-Checkliste in docs/ - [ ] Cron oder Reminder für 4-Wochen-Rhythmus eingerichtet - [ ] Erstes Folge-Audit nach 4 Wochen im Kalender ## Bezug - Memory-Regel 'stille excepts in Adaptern' deckt sich mit Befund #4 und #7 - Keycloak-SSO-TODO aus CLAUDE.md ist Vorbedingung für #5 (CSRF) - Sub-Issue-A (#51) Live-Tests könnten einen Bandit-Scan-Step bekommen
Author
Owner

Patch-Status (Commit 64cbff5)

  • #1 HIGH Rate-Limit — slowapi auf /analyze (10/min), /api/analyze-drucksache (10/min), /api/programme/index (3/min), in-memory Limiter mit IP-Key
  • #2 MEDIUM XXE/Local-File-Read via WeasyPrint — alle LLM-Felder in app/report.py durch html.escape()
  • #6 MEDIUM Stored XSS via HTML-Report — selber Patch wie #2 (gemeinsamer Root-Cause)
  • #3 Path-Traversal Drucksache-Regex (offen, kein Production-Risiko, fix in eigenem Patch)
  • #4 PII/LLM-Content im Log (offen, betrifft Container-Logs)
  • #5 CSRF (hängt an Keycloak-Integration)
  • #7 Search-Query DoS / silent shlex fallback
  • #8 Secrets als ENV statt Mount

Tests

Neue tests/test_report.py mit 8 Cases — <script>, file://-img, "-Attribut-Breakout, End-to-End-Render mit XSS-Payloads in jedem LLM-Feld. Die **/~~-Marker werden mit-getestet damit das Escape-First-Pattern die Funktion nicht zerstört.

Lokal: 85 Unit-Tests grün (77 alt + 8 neu).

Deployment

Die Hotfixes brauchen eine Container-Rebuild auf dem VServer, weil slowapi neu in requirements.txt ist:

ssh vserver 'cd /opt/gwoe-antragspruefer && git pull && docker compose up -d --build'

Nach dem Deploy via curl -X POST -F drucksache=test https://gwoe.toppyr.de/api/analyze-drucksache ×11 verifizieren, dass der 11. Call ein 429 Too Many Requests bekommt.

## Patch-Status (Commit `64cbff5`) - [x] **#1 HIGH** Rate-Limit — slowapi auf `/analyze` (10/min), `/api/analyze-drucksache` (10/min), `/api/programme/index` (3/min), in-memory Limiter mit IP-Key - [x] **#2 MEDIUM** XXE/Local-File-Read via WeasyPrint — alle LLM-Felder in `app/report.py` durch `html.escape()` - [x] **#6 MEDIUM** Stored XSS via HTML-Report — selber Patch wie #2 (gemeinsamer Root-Cause) - [ ] #3 Path-Traversal Drucksache-Regex (offen, kein Production-Risiko, fix in eigenem Patch) - [ ] #4 PII/LLM-Content im Log (offen, betrifft Container-Logs) - [ ] #5 CSRF (hängt an Keycloak-Integration) - [ ] #7 Search-Query DoS / silent shlex fallback - [ ] #8 Secrets als ENV statt Mount ### Tests Neue `tests/test_report.py` mit 8 Cases — `<script>`, `file://`-img, `"`-Attribut-Breakout, End-to-End-Render mit XSS-Payloads in jedem LLM-Feld. Die `**`/`~~`-Marker werden mit-getestet damit das Escape-First-Pattern die Funktion nicht zerstört. Lokal: 85 Unit-Tests grün (77 alt + 8 neu). ### Deployment Die Hotfixes brauchen eine Container-Rebuild auf dem VServer, weil `slowapi` neu in `requirements.txt` ist: ```bash ssh vserver 'cd /opt/gwoe-antragspruefer && git pull && docker compose up -d --build' ``` Nach dem Deploy via `curl -X POST -F drucksache=test https://gwoe.toppyr.de/api/analyze-drucksache` ×11 verifizieren, dass der 11. Call ein 429 Too Many Requests bekommt.
Author
Owner

Deployment verifiziert (2026-04-09)

cd /opt/gwoe-antragspruefer && git pull && docker compose up -d --build

Container-Startup sauber, keine Import-Errors. Live-Test des Rate-Limits gegen https://gwoe.toppyr.de/api/analyze-drucksache:

Request HTTP
1–10 404 (Slot verbraucht, fake-Drucksache nicht gefunden)
11 429 Too Many Requests
12 429 Too Many Requests

Rate-Limit greift exakt nach der konfigurierten 10/min-Schwelle. Befunde #1, #2, #6 sind damit live ausgerollt.

## Deployment verifiziert (2026-04-09) ``` cd /opt/gwoe-antragspruefer && git pull && docker compose up -d --build ``` Container-Startup sauber, keine Import-Errors. Live-Test des Rate-Limits gegen `https://gwoe.toppyr.de/api/analyze-drucksache`: | Request | HTTP | |---|---| | 1–10 | 404 (Slot verbraucht, fake-Drucksache nicht gefunden) | | 11 | **429 Too Many Requests** ✓ | | 12 | **429 Too Many Requests** ✓ | Rate-Limit greift exakt nach der konfigurierten 10/min-Schwelle. Befunde #1, #2, #6 sind damit live ausgerollt.
Author
Owner

Phase A aus Roadmap #59 deployed (Commit 9c70b46). Befunde #3, #4, #7 erledigt. Live verifiziert: bad-Drucksache → 400, ok-Drucksache → 200, 250-Zeichen-Query → 400, 50-Zeichen-Query → 200.

Verbleibend: #5 CSRF (hängt an Keycloak), #8 Secrets-Mount (ops change, kein code patch).

Phase A aus Roadmap #59 deployed (Commit `9c70b46`). Befunde #3, #4, #7 erledigt. Live verifiziert: bad-Drucksache → 400, ok-Drucksache → 200, 250-Zeichen-Query → 400, 50-Zeichen-Query → 200. Verbleibend: #5 CSRF (hängt an Keycloak), #8 Secrets-Mount (ops change, kein code patch).
Author
Owner

Alle 8 Befunde adressiert (2026-04-10)

# Schwere Befund Status Fix
1 HIGH Resource Exhaustion POST-Endpoints FIXED slowapi Rate-Limits (10/min, 3/min) auf allen POST-Routes
2 MEDIUM XXE via WeasyPrint FIXED html.escape via _e() auf alle LLM-Felder (report.py)
3 MEDIUM Path Traversal Drucksache FIXED validate_drucksache Regex-Whitelist
4 MEDIUM PII/LLM-Content in Logs FIXED print()→logger.exception, _content_fingerprint statt Volltext (1a82f82)
5 MEDIUM CSRF auf POST-Endpoints MITIGIERT Keycloak JWT-Auth auf POST-Endpoints (auth.py, #43). CSRF ist nur noch Risiko im Dev-Modus.
6 MEDIUM Stored XSS via LLM-Output FIXED Gleicher Fix wie #2 (_e() auf alle Felder)
7 LOW-MED Search-Query-DoS FIXED MAX_SEARCH_QUERY_LEN (200 Zeichen), validate_search_query
8 LOW Secrets als ENV AKZEPTIERT Angemessen fuer Single-Server Docker-Setup

Wiederkehrender Audit: per Issue-Template oder als Teil jeder groesseren Feature-Session. ADR 0005 dokumentiert die Auth-Entscheidung.

Closing.

## Alle 8 Befunde adressiert (2026-04-10) | # | Schwere | Befund | Status | Fix | |---|---|---|---|---| | 1 | HIGH | Resource Exhaustion POST-Endpoints | **FIXED** | slowapi Rate-Limits (10/min, 3/min) auf allen POST-Routes | | 2 | MEDIUM | XXE via WeasyPrint | **FIXED** | html.escape via _e() auf alle LLM-Felder (report.py) | | 3 | MEDIUM | Path Traversal Drucksache | **FIXED** | validate_drucksache Regex-Whitelist | | 4 | MEDIUM | PII/LLM-Content in Logs | **FIXED** | print()→logger.exception, _content_fingerprint statt Volltext (1a82f82) | | 5 | MEDIUM | CSRF auf POST-Endpoints | **MITIGIERT** | Keycloak JWT-Auth auf POST-Endpoints (auth.py, #43). CSRF ist nur noch Risiko im Dev-Modus. | | 6 | MEDIUM | Stored XSS via LLM-Output | **FIXED** | Gleicher Fix wie #2 (_e() auf alle Felder) | | 7 | LOW-MED | Search-Query-DoS | **FIXED** | MAX_SEARCH_QUERY_LEN (200 Zeichen), validate_search_query | | 8 | LOW | Secrets als ENV | **AKZEPTIERT** | Angemessen fuer Single-Server Docker-Setup | Wiederkehrender Audit: per Issue-Template oder als Teil jeder groesseren Feature-Session. ADR 0005 dokumentiert die Auth-Entscheidung. Closing.
Sign in to join this conversation.
No description provided.