diff --git a/backend/main.py b/backend/main.py index 28714a7..6dc94e4 100644 --- a/backend/main.py +++ b/backend/main.py @@ -82,8 +82,8 @@ app.add_middleware( CORSMiddleware, allow_origins=ALLOWED_ORIGINS, allow_credentials=True, - allow_methods=["*"], - allow_headers=["*"], + allow_methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"], + allow_headers=["Content-Type", "X-Auth-Token", "X-Active-Club-Id"], ) diff --git a/backend/routers/auth.py b/backend/routers/auth.py index ee08f33..e407a79 100644 --- a/backend/routers/auth.py +++ b/backend/routers/auth.py @@ -98,8 +98,8 @@ def change_pin(req: dict, session: dict=Depends(require_auth)): """Change PIN/password for current user.""" pid = session['profile_id'] new_pin = req.get('pin', '') - if len(new_pin) < 4: - raise HTTPException(400, "PIN/Passwort muss mind. 4 Zeichen haben") + if len(new_pin) < 8: + raise HTTPException(400, "Passwort muss mind. 8 Zeichen haben") new_hash = hash_pin(new_pin) with get_db() as conn: diff --git a/backend/routers/media_assets.py b/backend/routers/media_assets.py index 787546b..e5f6de3 100644 --- a/backend/routers/media_assets.py +++ b/backend/routers/media_assets.py @@ -1158,6 +1158,22 @@ def bulk_media_patch( ) continue + if next_vis in ("club", "official"): + effective_copyright = ( + patch_fields.get("copyright_notice") or asset.get("copyright_notice") or "" + ) + if not str(effective_copyright).strip(): + failed.append( + { + "id": asset_id, + "detail": ( + "Fur Vereins- oder offizielle Medien ist eine " + "Urheberrechtsangabe (copyright_notice) Pflicht." + ), + } + ) + continue + new_sk: Optional[str] = None if "visibility" in patch_fields or "club_id" in patch_fields: next_club_param: Optional[int] = None @@ -1256,6 +1272,20 @@ def patch_media_asset( ), ) + if next_vis in ("club", "official"): + effective_copyright = ( + data.get("copyright_notice") or asset.get("copyright_notice") or "" + ) + if not str(effective_copyright).strip(): + raise HTTPException( + status_code=400, + detail=( + "Fur Vereins- oder offizielle Medien ist eine Urheberrechtsangabe " + "(copyright_notice) Pflicht. Bitte vor oder zusammen mit der " + "Freigabe angeben." + ), + ) + new_sk: Optional[str] = None if "visibility" in data or "club_id" in data: next_club_param: Optional[int] = None diff --git a/backend/tests/test_media_assets_copyright_promotion.py b/backend/tests/test_media_assets_copyright_promotion.py new file mode 100644 index 0000000..542c863 --- /dev/null +++ b/backend/tests/test_media_assets_copyright_promotion.py @@ -0,0 +1,252 @@ +""" +P-04: Copyright-Pflicht bei Promotion auf club/official. +PATCH /api/media-assets/{id} und POST /api/media-assets/bulk-patch +lehnen eine Sichtbarkeits-Promotion auf club oder official ab, +wenn keine copyright_notice vorhanden ist. +""" +from __future__ import annotations + +import os +from contextlib import ExitStack +from unittest.mock import MagicMock, patch + +import pytest +from fastapi.testclient import TestClient + +os.environ.setdefault("SKIP_DB_MIGRATE", "1") + +from main import app +from tenant_context import TenantContext, get_tenant_context + +_SUPERADMIN_TENANT = TenantContext( + profile_id=1, + global_role="superadmin", + effective_club_id=None, + club_ids=frozenset(), + memberships=[], +) + +_PRIVATE_ASSET: dict = { + "id": 42, + "visibility": "private", + "club_id": 7, + "uploaded_by_profile_id": 1, + "lifecycle_state": "active", + "copyright_notice": None, + "original_filename": "foto.jpg", + "sha256": "a" * 64, + "storage_key": f"library/verein-7/image/{'a' * 64}.jpg", + "storage_backend": "local", + "mime_type": "image/jpeg", + "byte_size": 1024, + "created_at": None, + "tags": [], +} + +_ASSET_WITH_COPYRIGHT: dict = {**_PRIVATE_ASSET, "copyright_notice": "Verein 2026"} + + +@pytest.fixture +def client() -> TestClient: + return TestClient(app) + + +@pytest.fixture(autouse=True) +def _clear_overrides(): + yield + app.dependency_overrides.pop(get_tenant_context, None) + + +def _make_db_mocks(asset: dict) -> tuple[MagicMock, MagicMock]: + mock_cur = MagicMock() + mock_cur.fetchone.return_value = asset + mock_conn = MagicMock() + mock_cm = MagicMock() + mock_cm.__enter__.return_value = mock_conn + mock_cm.__exit__.return_value = False + return mock_cm, mock_cur + + +_PERMISSION_PATCHES = [ + ("routers.media_assets.assert_can_edit_media_asset_metadata", {}), + ("routers.media_assets.assert_valid_governance_visibility", {}), + ("routers.media_assets._media_assets_tags_column_present", {"return_value": False}), + ("routers.media_assets.get_effective_media_root", {"return_value": "/tmp/media"}), + ("routers.media_assets._relocate_asset_file_if_governance_changed", {"return_value": None}), +] + + +def _enter_permission_patches(stack: ExitStack) -> None: + for target, kwargs in _PERMISSION_PATCHES: + stack.enter_context(patch(target, **kwargs)) + + +# ── Single PATCH ───────────────────────────────────────────────────────────── + + +def test_patch_promote_to_club_without_copyright_returns_400(client: TestClient) -> None: + """Promotion private -> club ohne copyright_notice muss 400 liefern.""" + app.dependency_overrides[get_tenant_context] = lambda: _SUPERADMIN_TENANT + mock_cm, mock_cur = _make_db_mocks(_PRIVATE_ASSET) + + with ExitStack() as stack: + stack.enter_context(patch("routers.media_assets.get_db", return_value=mock_cm)) + stack.enter_context(patch("routers.media_assets.get_cursor", return_value=mock_cur)) + _enter_permission_patches(stack) + r = client.patch( + "/api/media-assets/42", + json={"visibility": "club", "club_id": 7}, + headers={"X-Auth-Token": "t"}, + ) + + assert r.status_code == 400 + detail = r.json()["detail"].lower() + assert "copyright" in detail or "urheberrecht" in detail + + +def test_patch_promote_to_official_without_copyright_returns_400(client: TestClient) -> None: + """Promotion private -> official ohne copyright_notice muss 400 liefern.""" + app.dependency_overrides[get_tenant_context] = lambda: _SUPERADMIN_TENANT + mock_cm, mock_cur = _make_db_mocks(_PRIVATE_ASSET) + + with ExitStack() as stack: + stack.enter_context(patch("routers.media_assets.get_db", return_value=mock_cm)) + stack.enter_context(patch("routers.media_assets.get_cursor", return_value=mock_cur)) + _enter_permission_patches(stack) + r = client.patch( + "/api/media-assets/42", + json={"visibility": "official"}, + headers={"X-Auth-Token": "t"}, + ) + + assert r.status_code == 400 + detail = r.json()["detail"].lower() + assert "copyright" in detail or "urheberrecht" in detail + + +def test_patch_promote_to_club_with_copyright_in_body_allowed(client: TestClient) -> None: + """Promotion private -> club MIT copyright_notice im Body darf nicht 400 liefern.""" + app.dependency_overrides[get_tenant_context] = lambda: _SUPERADMIN_TENANT + mock_cm, mock_cur = _make_db_mocks(_PRIVATE_ASSET) + mock_cur.fetchone.side_effect = [ + _PRIVATE_ASSET, + {**_PRIVATE_ASSET, "visibility": "club", "copyright_notice": "Verein 2026"}, + ] + + with ExitStack() as stack: + stack.enter_context(patch("routers.media_assets.get_db", return_value=mock_cm)) + stack.enter_context(patch("routers.media_assets.get_cursor", return_value=mock_cur)) + _enter_permission_patches(stack) + r = client.patch( + "/api/media-assets/42", + json={"visibility": "club", "club_id": 7, "copyright_notice": "Verein 2026"}, + headers={"X-Auth-Token": "t"}, + ) + + assert r.status_code != 400 or "copyright" not in r.json().get("detail", "").lower() + + +def test_patch_promote_to_club_existing_copyright_allowed(client: TestClient) -> None: + """Asset hat bereits copyright_notice -> Promotion ohne Body-Copyright erlaubt.""" + app.dependency_overrides[get_tenant_context] = lambda: _SUPERADMIN_TENANT + mock_cm, mock_cur = _make_db_mocks(_ASSET_WITH_COPYRIGHT) + mock_cur.fetchone.side_effect = [ + _ASSET_WITH_COPYRIGHT, + {**_ASSET_WITH_COPYRIGHT, "visibility": "club"}, + ] + + with ExitStack() as stack: + stack.enter_context(patch("routers.media_assets.get_db", return_value=mock_cm)) + stack.enter_context(patch("routers.media_assets.get_cursor", return_value=mock_cur)) + _enter_permission_patches(stack) + r = client.patch( + "/api/media-assets/42", + json={"visibility": "club", "club_id": 7}, + headers={"X-Auth-Token": "t"}, + ) + + assert r.status_code != 400 or "copyright" not in r.json().get("detail", "").lower() + + +def test_patch_filename_only_no_copyright_check(client: TestClient) -> None: + """Kein Visibility-Wechsel -> keine Copyright-Prufung.""" + app.dependency_overrides[get_tenant_context] = lambda: _SUPERADMIN_TENANT + mock_cm, mock_cur = _make_db_mocks(_PRIVATE_ASSET) + mock_cur.fetchone.side_effect = [ + _PRIVATE_ASSET, + {**_PRIVATE_ASSET, "original_filename": "neu.jpg"}, + ] + + with ExitStack() as stack: + stack.enter_context(patch("routers.media_assets.get_db", return_value=mock_cm)) + stack.enter_context(patch("routers.media_assets.get_cursor", return_value=mock_cur)) + _enter_permission_patches(stack) + r = client.patch( + "/api/media-assets/42", + json={"original_filename": "neu.jpg"}, + headers={"X-Auth-Token": "t"}, + ) + + assert r.status_code == 200 + + +# ── Bulk PATCH ──────────────────────────────────────────────────────────────── + + +def test_bulk_patch_promote_to_club_without_copyright_in_failed(client: TestClient) -> None: + """Bulk-Promotion ohne copyright_notice -> Asset in failed-Liste.""" + app.dependency_overrides[get_tenant_context] = lambda: _SUPERADMIN_TENANT + mock_cur = MagicMock() + mock_cur.fetchone.return_value = _PRIVATE_ASSET + mock_conn = MagicMock() + mock_cm = MagicMock() + mock_cm.__enter__.return_value = mock_conn + mock_cm.__exit__.return_value = False + + with ExitStack() as stack: + stack.enter_context(patch("routers.media_assets.get_db", return_value=mock_cm)) + stack.enter_context(patch("routers.media_assets.get_cursor", return_value=mock_cur)) + _enter_permission_patches(stack) + r = client.post( + "/api/media-assets/bulk-patch", + json={"media_asset_ids": [42], "visibility": "club", "club_id": 7}, + headers={"X-Auth-Token": "t"}, + ) + + assert r.status_code == 200 + body = r.json() + assert body["updated_count"] == 0 + assert body["failed_count"] == 1 + detail = body["failed"][0]["detail"].lower() + assert "copyright" in detail or "urheberrecht" in detail + + +def test_bulk_patch_promote_to_club_with_copyright_in_updated(client: TestClient) -> None: + """Bulk-Promotion MIT copyright_notice -> Asset in updated-Liste.""" + app.dependency_overrides[get_tenant_context] = lambda: _SUPERADMIN_TENANT + mock_cur = MagicMock() + mock_cur.fetchone.return_value = _PRIVATE_ASSET + mock_conn = MagicMock() + mock_cm = MagicMock() + mock_cm.__enter__.return_value = mock_conn + mock_cm.__exit__.return_value = False + + with ExitStack() as stack: + stack.enter_context(patch("routers.media_assets.get_db", return_value=mock_cm)) + stack.enter_context(patch("routers.media_assets.get_cursor", return_value=mock_cur)) + _enter_permission_patches(stack) + r = client.post( + "/api/media-assets/bulk-patch", + json={ + "media_asset_ids": [42], + "visibility": "club", + "club_id": 7, + "copyright_notice": "Verein 2026", + }, + headers={"X-Auth-Token": "t"}, + ) + + assert r.status_code == 200 + body = r.json() + assert 42 in body["updated"] + assert body["updated_count"] == 1 diff --git a/backend/tests/test_security_release.py b/backend/tests/test_security_release.py index 1d50482..81a652a 100644 --- a/backend/tests/test_security_release.py +++ b/backend/tests/test_security_release.py @@ -124,3 +124,27 @@ def test_api_attachments_x_content_type_options_nosniff(client: TestClient) -> N r2 = client.get("/api/version") assert r2.status_code == 200 assert r2.headers.get("x-content-type-options") == "nosniff" + + +def test_public_media_static_not_mounted_by_default() -> None: + """/media/-StaticFiles-Mount darf ohne ALLOW_PUBLIC_MEDIA_STATIC nicht aktiv sein.""" + snippet = """ +from main import app +mounted = [getattr(r, 'path', '') for r in app.routes] +assert not any(p == '/media' for p in mounted), ( + "ALLOW_PUBLIC_MEDIA_STATIC aktiv – /media oeffentlich erreichbar. Vor Deploy entfernen." +) +""" + proc = _run_fresh_import_int(snippet, {"ENVIRONMENT": "production"}) + assert proc.returncode == 0, proc.stderr + proc.stdout + + +def test_allow_public_media_static_activates_media_mount() -> None: + """Dokumentiert: ALLOW_PUBLIC_MEDIA_STATIC=1 aktiviert /media ohne Authentifizierung.""" + snippet = """ +from main import app +mounted = [getattr(r, 'path', '') for r in app.routes] +assert any(p == '/media' for p in mounted), "/media-Mount wurde nicht aktiviert" +""" + proc = _run_fresh_import_int(snippet, {"ALLOW_PUBLIC_MEDIA_STATIC": "1"}) + assert proc.returncode == 0, proc.stderr + proc.stdout diff --git a/backend/version.py b/backend/version.py index 58e8700..0ded75f 100644 --- a/backend/version.py +++ b/backend/version.py @@ -1,11 +1,11 @@ # Shinkan Jinkendo Version Information -APP_VERSION = "0.8.65" -BUILD_DATE = "2026-05-08" +APP_VERSION = "0.8.66" +BUILD_DATE = "2026-05-09" DB_SCHEMA_VERSION = "20260508049" MODULE_VERSIONS = { - "auth": "1.2.1", # Login-Rate-Limit 30/minute pro IP (vorher 5/minute) + "auth": "1.2.2", # Passwort-Mindestlange PUT /auth/pin: 4 auf 8 Zeichen angehoben "profiles": "1.7.0", # exercise_list_prefs JSONB (Standard Übungsfilter); Patch via ProfileUpdate + Json() "tenant_context": "1.0.5", # Plattform-Admin: effective_club ohne Header aus Profil active_club_id wenn Verein existiert "clubs": "0.4.1", # Alle geschützten Endpoints Depends(get_tenant_context); profile_id/role aus TenantContext @@ -13,7 +13,7 @@ MODULE_VERSIONS = { "club_join_requests": "1.0.1", # Depends(get_tenant_context) "admin_users": "1.0.0", # GET /api/admin/users "platform_media_storage": "1.0.0", # GET/PUT /api/admin/platform-media-storage (Superadmin-Pfad unter MEDIA_ROOT) - "media_assets": "1.12.1", # official: nur Superadmin Lifecycle/PATCH; UI Lesemodus; Superadmin Upload-Verein = aktiv + "media_assets": "1.12.2", # P-04: Copyright-Pflicht bei Promotion auf club/official in PATCH und bulk-patch "groups": "0.1.0", "skills": "0.1.0", "methods": "0.1.0", @@ -29,6 +29,18 @@ MODULE_VERSIONS = { } CHANGELOG = [ + { + "version": "0.8.66", + "date": "2026-05-09", + "changes": [ + "Sicherheit P-03: Papierkorb-Retention-Job (media_retention_job.py) als Docker-Service retention-cron aktiviert (lauft taglich um 03:00 Uhr)", + "Sicherheit P-04: Copyright-Pflicht bei Sichtbarkeits-Promotion auf club/official in PATCH und bulk-patch (media_assets)", + "Sicherheit P-05: Passwort-Mindestlange PUT /auth/pin von 4 auf 8 Zeichen angehoben", + "Sicherheit P-07: Release-Test fur ALLOW_PUBLIC_MEDIA_STATIC in test_security_release.py ergaenzt", + "Sicherheit P-23: LoginPage minLength 6 auf 8 angehoben; hartcodierter Versionsstring entfernt", + "Sicherheit P-24: CORS allow_methods und allow_headers auf benotigte Werte eingeschrankt", + ], + }, { "version": "0.8.65", "date": "2026-05-08", diff --git a/docker-compose.yml b/docker-compose.yml index 0c1f165..4f0b189 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -81,6 +81,43 @@ services: networks: - shinkan-network + retention-cron: + build: + context: ./backend + dockerfile: Dockerfile + container_name: shinkan-retention-cron + command: > + python -c " + import time, subprocess, sys, os, datetime + def next_3am(): + now = datetime.datetime.now() + target = now.replace(hour=3, minute=0, second=0, microsecond=0) + if target <= now: + target += datetime.timedelta(days=1) + return (target - now).total_seconds() + subprocess.run([sys.executable, 'scripts/media_retention_job.py'], check=False) + while True: + time.sleep(next_3am()) + subprocess.run([sys.executable, 'scripts/media_retention_job.py'], check=False) + " + working_dir: /app + environment: + DB_HOST: postgres + DB_PORT: 5432 + DB_NAME: shinkan + DB_USER: shinkan_user + DB_PASSWORD: ${DB_PASSWORD} + MEDIA_ROOT: "${MEDIA_ROOT:-/app/media}" + MEDIA_TRASH_SOFT_TO_HIDDEN_DAYS: "${MEDIA_TRASH_SOFT_TO_HIDDEN_DAYS:-30}" + MEDIA_TRASH_HIDDEN_TO_PURGE_DAYS: "${MEDIA_TRASH_HIDDEN_TO_PURGE_DAYS:-90}" + volumes: + - ${SHINKAN_MEDIA_HOST:-/shinkan-media}:${MEDIA_ROOT:-/app/media} + depends_on: + - postgres + restart: unless-stopped + networks: + - shinkan-network + volumes: shinkan-db-data: diff --git a/docs/compliance-audit.md b/docs/compliance-audit.md new file mode 100644 index 0000000..bba9b9a --- /dev/null +++ b/docs/compliance-audit.md @@ -0,0 +1,931 @@ +# 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 `