# Compliance-Audit – Shinkan Jinkendo > **Status:** Entwurf — technischer Audit, kein Rechtsanwalt > **Datum:** 2026-05-09 > **Auditor:** Claude Code > **App-Version:** 0.8.65 > **Rechtlicher Hinweis:** Dieses Dokument ist eine technische Analyse. Es ersetzt keine Rechtsberatung. Alle als „juristisch zu prüfen" markierten Punkte müssen durch einen Rechtsanwalt oder Datenschutzbeauftragten bewertet werden. Kein Code wurde verändert. --- ## 1. Executive Summary Die Shinkan Jinkendo App ist technisch solide aufgebaut: robuste Mandantentrennung (TenantContext), mehrstufiges Löschkonzept für Medien, serverseitig erzwungene Zugriffskontrolle. Die Kernarchitektur der Datenschicht ist gut. **Kritische Compliance-Lücken:** - Keine Rechtstexte (Impressum, Datenschutzerklärung, Nutzungsbedingungen, Medienrichtlinie) - Kein DSA-konformes Meldeverfahren für rechtswidrige Inhalte (UGC-Plattform) - Kein Recht-am-eigenen-Bild/Minderjährigen-Check beim Medienupload - Kein Self-Service-Löschrecht für Nutzer (nur Admin kann Konten löschen) - Auth-Token im localStorage (XSS-Risiko, TDDDG-Dokumentationspflicht) - HSTS-Header fehlt in der Nginx-Konfiguration - Papierkorb-Retention-Job nicht automatisch geplant - Passwort-Mindestlänge inkonsistent (Register: 8, PIN-Änderung: 4 Zeichen) Vor öffentlichem Betrieb sind die kritischen Findings (KRIT-01 bis KRIT-07) zu adressieren. --- ## 2. Scope | Bereich | Geprüft | |---------|---------| | Backend-Router (alle .py) | ✓ | | Datenbankmigrationen (001–046) | ✓ | | Frontend App.jsx, Routing, Auth | ✓ | | API-Authentifizierung und Autorisierung | ✓ | | Mandanten-/Zugriffschicht (TenantContext, club_tenancy) | ✓ | | Medien-Archiv (media_assets, lifecycle) | ✓ | | PWA-Konfiguration (manifest.webmanifest) | ✓ | | Nginx-Konfiguration (nginx.conf) | ✓ | | Docker-Compose (docker-compose.yml) | ✓ | | Vorhandene Tests (backend/tests/*.py) | ✓ | | LocalStorage / SessionStorage Nutzung | ✓ | | Rechtstexte (Impressum, DSGVO, AGB) | ✓ | | CSP / Security-Header | ✓ | | Passwort-Handling, Session-Management | ✓ | --- ## 3. Annahmen - App ist öffentlich im Internet erreichbar unter `shinkan.jinkendo.de` (HTTPS) - SSL/TLS-Terminierung erfolgt am externen Reverse-Proxy vor dem Nginx-Container - Betreiber ist im EU-Raum ansässig (DSGVO anwendbar) - Minderjährige können sich registrieren (keine Altersverifikation vorhanden) - Die Plattform erlaubt Upload und Anzeige von Bildern und Videos mit Personenabbildungen --- ## 4. Nicht geprüfte Bereiche - Produktions-Infrastruktur (Synology NAS, Raspberry Pi 5) – nur Konfigurationsdateien - Netzwerkinfrastruktur (Fritz!Box) – außerhalb des Repos - SMTP-Anbieter im Detail (Anbieter unbekannt aus Umgebungsvariablen) - Aktive Penetrationstests - Backup-Prozess und Restore-Test (kein Skript im Repository) --- ## 5. Technische Bestandsaufnahme ### 5.1 Architektur | Komponente | Technologie | Sicherheitsrelevanz | |-----------|-------------|---------------------| | Frontend | React 18 + Vite, SPA | Routing, Token-Speicherung | | Backend | FastAPI Python 3.12 | Zugriffskontrolle, Validierung | | Datenbank | PostgreSQL 16 Alpine | Datenhaltung, Mandantentrennung | | Proxy | Nginx (Docker) | CSP, Security-Header, Upload-Limit | | Storage | Lokaler Bind-Mount via Docker | Medienspeicherung | | Auth | Token-basiert (Sessions-Tabelle) | Session-Management | | PWA | Web App Manifest + Icons | Offline-Caching (kein Service Worker!) | | E-Mail | SMTP (konfigurierbar) | Registrierung, Passwort-Reset | | KI | OpenRouter (optional, nicht MVP) | KI-Features | ### 5.2 Authentifizierung - Token: `secrets.token_urlsafe(32)` (kryptografisch sicher) - Hashing: bcrypt mit auto-Upgrade von Legacy SHA256 - Session-Ablauf: 30 Tage (konfigurierbar per `session_days`) - Rate-Limiting: Login 30/min, Forgot-Password 3/min, Register 3/hour (slowapi) - No-Enumeration: `/forgot-password` gibt keine Info über E-Mail-Existenz preis ### 5.3 Rollen (global) | Rolle | Rechte | |-------|--------| | `trainer` | Standard-Nutzer; Upload, private Übungen, Planung | | `admin` | Plattform-Admin; alle Vereine, alle Profile einsehbar | | `superadmin` | Vollzugriff; Official-Promotion, physische Löschung, Admin-Konfiguration | ### 5.4 Vereinsrollen (pro Verein) | Rolle | Rechte | |-------|--------| | `club_admin` | Vereinsstruktur, Mitglieder, Vereins-Medien/Übungen | | `trainer` | Training planen, Übungen verwalten | | `content_editor` | Inhalte bearbeiten | | `division_lead` | Spartenleitung | ### 5.5 PWA / Service Worker - **Kein Service Worker** im Repository vorhanden - Keine Workbox- oder sw.js-Datei gefunden - **Bedeutung:** Das Hauptrisiko (private Medien im PWA-Cache) entfällt mangels Service Worker ### 5.6 Browser-Storage-Nutzung | Speicherart | Inhalt | TDDDG-Klassifikation | |-------------|--------|----------------------| | `localStorage['authToken']` | Auth-Session-Token | Technisch notwendig | | `localStorage['shinkan_active_club']` | Aktiver Verein (ID) | Technisch notwendig | | `localStorage['shinkan_active_profile']` | Profil-ID | Technisch notwendig | | `sessionStorage[storageStepKey]` | Trainingsschritt (Coach-Page) | Session-temporär, nicht personenbezogen | | `sessionStorage[storageDeltasKey]` | Trainingsdeltas JSON | Session-temporär | | `sessionStorage[storageDebriefKey]` | Debrief-Status | Session-temporär | | Cookies | **keine** | – | | IndexedDB | **keine** | – | --- ## 6. Datenflussanalyse ### 6.1 Registrierung / Login ``` Nutzer → POST /api/auth/register → Profil (inaktiv) + Verifikations-E-Mail Nutzer → E-Mail-Link → GET /api/auth/verify/{token} → Profil aktiv, Session-Token in Response → Frontend: localStorage.setItem('authToken', token) Nutzer → POST /api/auth/login → Token in Response → Frontend: localStorage.setItem('authToken', token) ``` Gespeicherte Daten: Name, E-Mail, bcrypt-Hash, Rolle, Tier, trial_ends_at, email_verified, verification_token (temporär, wird nach Verifikation gelöscht) ### 6.2 Medienupload ``` Nutzer → POST /api/exercises/{id}/media (multipart) [50 MB Limit] → MIME-Type-Prüfung (magic bytes) → SHA256-Hash (Deduplizierung) → Dateispeicherung: library/{scope}/{kind}/{sha256}{ext} → DB-Eintrag: media_assets + exercise_media Admin → POST /api/media-assets/bulk-upload [1 GB Limit] → gleicher Pfad; Sichtbarkeit + Verein als Formular-Parameter ``` ### 6.3 Medienpromotion ``` Vereins-Admin → PATCH /api/media-assets/{id} → assert_valid_governance_visibility() → Mitgliedschaftsprüfung → Bei visibility=club: club_id Pflicht + Mitgliedschaft → Bei visibility=official: NUR Superadmin → copyright_notice: KEIN Pflichtfeld (nur im exercises-Router für official) PROBLEM: Copyright-Pflicht ist NICHT im media_assets-Router für alle Promotions implementiert ``` ### 6.4 Medienlöschung ``` Stufe 1 (Soft-Trash, lifecycle_state='trash_soft'): → Manuell durch Eigentümer / Vereins-Admin / Superadmin → Datei bleibt auf Disk; weiterhin sichtbar (je nach Exercise-Implementierung) Stufe 2 (Hidden, lifecycle_state='trash_hidden'): → Nach 30 Tagen (Job) oder manuell → Nicht mehr in normalen Abfragen sichtbar Stufe 3 (Purge): → Nach weiteren 30 Tagen (Job) oder Superadmin manuell → Datei physisch gelöscht PROBLEM: media_retention_job.py ist NICHT automatisch geplant ``` ### 6.5 Rechteprüfung ``` Jeder Request → require_auth() → Token aus X-Auth-Token-Header → Session aus DB Vereinsdaten → get_tenant_context() → TenantContext (profile_id, role, effective_club_id) Listenabfragen → library_content_visibility_sql() → SQL WHERE-Baustein Schreibzugriffe → assert_valid_governance_visibility() → 403 bei Verstoß ``` --- ## 7. Rollen- und Rechteanalyse ### 7.1 Mandantentrennung – Stärken - `TenantContext` konsequent in allen vereinsrelevanten Routern via `Depends(get_tenant_context)` - `library_content_visibility_sql()` als zentraler Sichtbarkeits-Filter (SQL-Ebene) - `effective_club_id` aus Header nur für Mitglieder, beliebig nur für Plattform-Admins - Integrationstests vorhanden: `test_access_layer_integration.py` ### 7.2 Klarstellung: Wer kann Vereinsmedien bearbeiten? Die Audit-Anforderung „alle Vereinsnutzer können bearbeiten" trifft auf die tatsächliche Implementierung **nicht** zu. In `_item_permissions()` (media_assets.py) ist `edit_metadata` nur für `club_admin`-Rolle oder Plattform-Admin True – normale Mitglieder können Vereinsmedien nicht bearbeiten. **Dies ist ein positiver Befund.** ### 7.3 Profil-Löschung (DSGVO-Lücke) `DELETE /api/profiles/{pid}` – nur Plattform-Admin. Nutzer können ihr eigenes Konto **nicht** selbst löschen. Potenzielle DSGVO-Verletzung (Art. 17). --- ## 8. Medienrechteanalyse ### 8.1 Copyright-Feld - Vorhanden: `copyright_notice` (max. 8000 Zeichen) in `media_assets` - Pflichtfeld bei `exercise_media` mit `visibility='official'` (exercises-Router) - **NICHT** Pflichtfeld beim direkten Upload in das Medienarchiv - **NICHT** Pflichtfeld bei Promotion von `private` zu `club` - **NICHT** dokumentiert: Wer hat erklärt? Wann? Welche Lizenzversion? ### 8.2 Rechteerklärung beim Upload - Keine Einwilligungserklärung beim Upload: „Ich bestätige, alle Rechte an dieser Datei zu besitzen" - Kein Upload-Dialog mit Pflicht-Checkbox - Kein Hinweis auf verbotene Inhalte (Rechte Dritter, Persönlichkeitsrechte) ### 8.3 Recht am eigenen Bild - Keine Abfrage, ob erkennbare Personen abgebildet sind - Keine Abfrage, ob Minderjährige enthalten sind - Keine Abfrage nach Einwilligung der abgebildeten Personen - Juristisch zu prüfen: Anforderungen nach §22 KUG --- ## 9. Löschkonzeptanalyse ### 9.1 Stärken - Klares 3-Stufen-Lifecycle-Modell (active → trash_soft → trash_hidden → purged) - Superadmin-Direktlöschung als Sofortmaßnahme - SHA256-Deduplizierung verhindert doppelte physische Dateien - Datei-Relokation bei Sichtbarkeitsänderung implementiert ### 9.2 Lücken | Problem | Risiko | |---------|--------| | Papierkorb-Job nicht automatisch geplant | Dateien bleiben physisch nach Ablauf der Fristen | | Keine Löschung aus Backups dokumentiert | DSGVO Art. 17: Backup-Retention oder Löschprozess nötig | | Kein Legal-Hold-Status | Bei Rechtsverletzung dauert es 30 Tage bis zur vollständigen Unsichtbarkeit | | Kein Audit-Log für Löschgründe | Keine Nachvollziehbarkeit für DSA/DSGVO | | Kein Uploader-Benachrichtigungssystem | Bei Sperrung / Löschung kein Feedback an Uploader | --- ## 10. PWA- / Storage-Analyse ### 10.1 Positiv - Kein Service Worker → kein PWA-Cache-Risiko für Medien - Keine Cookies → kein Cookie-Banner nötig (für Cookies) - CSP-Header gesetzt: `script-src 'self'` (XSS-Mitigation) ### 10.2 LocalStorage-Bewertung Die localStorage-Nutzung ist technisch notwendig (Auth, Mandantenkontext). Nach TDDDG §25 ist technisch notwendige Speicherung ohne Einwilligung zulässig. Dokumentation in der Datenschutzerklärung ist Pflicht. ### 10.3 Token-Sicherheit - Auth-Token in `localStorage`: vulnerabel bei XSS - CSP `script-src 'self'` reduziert XSS-Risiko erheblich - Kein CSRF-Problem (Token im Header, nicht in Cookie) - `HttpOnly`-Cookie wäre sicherer, erfordert Architekturanpassung --- ## 11. Datenschutzanalyse (DSGVO) ### 11.1 Identifizierte Verarbeitungsvorgänge | Vorgang | Rechtsgrundlage (technisch) | VVT-Status | |---------|----------------------------|-----------| | Registrierung (Name, E-Mail, Passwort-Hash) | Vertrag (Art. 6 Abs. 1 lit. b) | ❌ Kein VVT | | Login / Session-Management | Berechtigtes Interesse | ❌ Kein VVT | | E-Mail-Versand | Vertragserfüllung | ❌ Kein VVT, SMTP-Anbieter unbekannt | | Medienupload (Bilder/Videos) | Einwilligung oder Vertragserfüllung | ❌ Keine Einwilligung abgeholt | | Vereinszugehörigkeit | Vertragserfüllung | ❌ Kein VVT | | Training-Logging | Berechtigtes Interesse | ❌ Kein VVT | | Backup (implizit) | Berechtigtes Interesse | ❌ Keine Retention dokumentiert | ### 11.2 Betroffenenrechte | Recht | Status | |-------|--------| | Auskunft (Art. 15) | ❌ Kein Self-Service-Export | | Berichtigung (Art. 16) | ⚠ Nur eigener Name/E-Mail über Einstellungen | | Löschung (Art. 17) | ❌ Kein Self-Service-Löschung | | Einschränkung (Art. 18) | ❌ Nicht implementiert | | Datenübertragbarkeit (Art. 20) | ❌ Kein Export-Endpoint | | Widerspruch (Art. 21) | ❌ Kein Mechanismus | ### 11.3 Auftragsverarbeiter (identifiziert) | Dienst | Anbieter | AV-Vertrag | |--------|----------|-----------| | Hosting | Selbstbetrieb (Raspberry Pi) | Entfällt | | SMTP / E-Mail | Unbekannt (Env-Variable) | ❌ Nicht dokumentiert | | MediaWiki-Import | karatetrainer.net | ❌ Nicht dokumentiert | | OpenRouter (KI) | OpenRouter.ai | ❌ Nicht dokumentiert | ### 11.4 Minderjährige - Keine Altersverifikation bei Registrierung - Keine besondere Schutzmaßnahme - Juristisch zu prüfen: §8 DSGVO --- ## 12. DSA-/UGC-Analyse ### 12.1 Einordnung Die App erlaubt Upload von User Generated Content (Bilder, Videos). Inhalte können öffentlich sichtbar sein (`official`-Stufe: plattformweit). Dies ist UGC im Sinne des DSA. **Juristisch zu prüfen:** Ab welcher Nutzerzahl und unter welchen Voraussetzungen der DSA für diese Plattform gilt. ### 12.2 Fehlende Mechanismen | DSA-Anforderung | Status | |-----------------|--------| | Meldeverfahren für rechtswidrige Inhalte | ❌ | | „Inhalt melden"-Funktion | ❌ | | Moderations-Backend mit Statuswerten | ❌ | | Benachrichtigung des Uploaders bei Sperrung | ❌ | | Begründung für Moderationsentscheidungen | ❌ | | Beschwerdemechanismus | ❌ | | Eskalation für schwere Inhalte (CSAM, Straftaten) | ❌ | | Audit-Log für Meldungen und Entscheidungen | ❌ | ### 12.3 Was vorhanden ist (Notfall-Maßnahmen) - Superadmin kann Inhalte sofort physisch löschen (`superadmin_hard_delete`) - Lifecycle-System ermöglicht schrittweise Deaktivierung - `official`-Promotion nur durch Superadmin (redaktioneller Prozess) --- ## 13. Sicherheitsanalyse ### 13.1 Positiv bewertete Maßnahmen | Maßnahme | Status | |----------|--------| | HTTPS (Produktion via Reverse-Proxy) | ✓ | | bcrypt Passwort-Hashing mit Legacy-SHA256-Upgrade | ✓ | | Rate-Limiting (slowapi) | ✓ | | CSRF-Schutz (Token im Header, nicht Cookie) | ✓ | | SQL-Injection-Schutz (parameterisierte Queries) | ✓ | | CSP-Header (nginx) | ✓ | | X-Content-Type-Options: nosniff (nginx + FastAPI-Middleware) | ✓ | | X-Frame-Options: SAMEORIGIN | ✓ | | Referrer-Policy: strict-origin-when-cross-origin | ✓ | | Permissions-Policy (camera/mic/geo) | ✓ | | OpenAPI in Produktion deaktiviert | ✓ | | DB-Port nur localhost exponiert | ✓ | | MIME-Type-Validierung beim Upload | ✓ | | SHA256-Integritätsprüfung + Deduplizierung | ✓ | | Secrets in .env (nicht im Code) | ✓ | | User-Enumeration verhindert (forgot-password, resend-verification) | ✓ | | Path-Traversal-Schutz in media_storage.py (`path_under_media_root` + `.relative_to()`) | ✓ | | Club-Name-Slugify: nur `[a-z0-9-]` im Dateipfad | ✓ | | CORS: Origins eingeschränkt (ALLOWED_ORIGINS aus Env) | ✓ | ### 13.2 Sicherheitslücken | ID | Titel | Schwere | Datei/Nachweis | |----|-------|---------|----------------| | SEC-01 | Kein HSTS-Header | Hoch | `frontend/nginx.conf` – kein `Strict-Transport-Security` | | SEC-02 | Auth-Token in localStorage | Mittel | `frontend/src/context/AuthContext.jsx:47` | | SEC-03 | `style-src 'unsafe-inline'` in CSP | Niedrig | `frontend/nginx.conf:23` | | SEC-04 | Passwort-Mindestlänge inkonsistent: Backend 3 Stellen, Frontend-Feld minLength=6, Backend-Register-Minimum=8 | Mittel | `backend/routers/auth.py:104` (`< 4`), `frontend/src/pages/LoginPage.jsx:175` (`minLength="6"`) | | SEC-05 | ALLOW_PUBLIC_MEDIA_STATIC umgeht Auth für alle Medien | Hoch | `backend/main.py:222-223` | | SEC-06 | Kein MFA für Superadmins | Mittel | Kein TOTP/OTP implementiert | | SEC-07 | Kein Audit-Log für Admin-Aktionen | Mittel | Keine `admin_audit_log`-Tabelle | | SEC-08 | Password-Reset-Token in sessions-Tabelle (Präfix `reset_`) | Niedrig | `backend/routers/auth.py:143` | | SEC-09 | Kein Backup-Konzept dokumentiert | Mittel | Kein Backup-Skript im Repo | | SEC-10 | Kein Anti-Virus-Scan für Uploads | Niedrig | Kein ClamAV o.ä. | | SEC-11 | Kein genereller HTML-Sanitizer für Rich-Text-Felder | Mittel | `backend/exercise_rich_text.py` – nur Inline-Media-Normalisierung, kein bleach/nh3 | | SEC-12 | `minLength="6"` im Login-Formular, Backend fordert 8 Zeichen | Niedrig | `frontend/src/pages/LoginPage.jsx:175` | | SEC-13 | Hartcodierte Versionsangabe `v0.1.0 • Development` auf Login-Seite (falsch + Info-Leak) | Niedrig | `frontend/src/pages/LoginPage.jsx:242` | | SEC-14 | CORS: `allow_methods=["*"]` und `allow_headers=["*"]` breiter als nötig | Niedrig | `backend/main.py:84-87` | ### 13.3 Ergänzende Befunde aus Restprüfung #### main.py — CORS-Konfiguration ```python app.add_middleware( CORSMiddleware, allow_origins=ALLOWED_ORIGINS, # ✓ aus Env, keine Wildcard-Origins allow_credentials=True, # ✓ korrekt (kein * + credentials) allow_methods=["*"], # ⚠ breiter als nötig allow_headers=["*"], # ⚠ breiter als nötig ) ``` `allow_credentials=True` in Kombination mit `allow_origins=["*"]` wäre ein kritischer Fehler (FastAPI würde ihn aber abweisen). Durch die explizite Origin-Liste ist das Risiko gering. `allow_methods=["*"]` und `allow_headers=["*"]` könnten auf die tatsächlich benötigten Methoden (GET, POST, PUT, PATCH, DELETE) und Header (X-Auth-Token, X-Active-Club-Id, Content-Type) eingeschränkt werden. #### media_storage.py — Path-Traversal-Schutz (positiv) `path_under_media_root()` kombiniert zwei unabhängige Prüfungen: 1. String-Prüfung: `".." in key.split("/")` 2. Filesystem-Prüfung: `p.relative_to(media_root.resolve())` Die Dateiendung wird auf 16 Zeichen begrenzt. Club-Namen werden auf `[a-z0-9-]` normalisiert. SHA256 als Dateiname ist manipulationssicher. **Bewertung: Gut implementiert, kein Path-Traversal-Risiko erkennbar.** #### exercise_rich_text.py — Fehlender genereller HTML-Sanitizer Das Modul normalisiert ausschließlich das Inline-Media-Markup (`{{exerciseMedia:id}}` → ``). Es enthält **keinen** generellen HTML-Sanitizer (kein bleach, lxml-cleaner, nh3 o.ä.). Felder in `RICH_HTML_EXERCISE_FIELDS` (`summary`, `goal`, `execution`, `preparation`, `trainer_notes`) können beliebiges HTML enthalten. Risikominderung: - CSP `script-src 'self'` verhindert `