security: 9 Findings aus Security-Audit behoben (CRITICAL + HIGH + MEDIUM)

CRITICAL:
- C-1: LDAP tls_verify Default False → True (MITM-Schutz)
- C-2: TOTP-Secret Fernet-verschlüsselt in DB (statt Plaintext)
  - core/crypto.py: encrypt_value() / decrypt_value() helper
  - Migration 0026: totp_secret VARCHAR(64→500), ldap tls_verify default=true
  - _totp_plain() helper mit Legacy-Fallback für bestehende Werte

HIGH:
- H-1: Kiosk Nonce-Cache asyncio.Lock (Race Condition behoben)
- H-2: File-Upload-Limit 10 MB (import_kimai.py + users.py CSV-Import)
- H-3: CORS allow_methods/allow_headers explizit eingeschränkt (war *)
- H-4: TrustedHostMiddleware aktiviert wenn ALLOWED_HOSTS gesetzt

MEDIUM:
- M-1: IP-Logging nutzt X-Forwarded-For hinter nginx-Proxy
- M-4: Audit-Log für password_changed, totp_enabled, totp_disabled
- M-5: CalDAV verify_ssl in Production erzwungen (_effective_verify_ssl)

152/152 Tests grün

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-24 19:45:09 +02:00
parent a639de13f8
commit 62c4e742ab
12 changed files with 319 additions and 31 deletions
+27 -10
View File
@@ -10,6 +10,7 @@ Jeder Kiosk-Request muss folgende HTTP-Header mitschicken:
Bei Fehler: 401 Unauthorized (oder 403 für IP-Whitelist-Verletzungen).
"""
import asyncio
import base64
import hashlib
import ipaddress
@@ -32,14 +33,23 @@ from app.models.kiosk_device import KioskDevice, KioskDeviceStatus
logger = logging.getLogger(__name__)
# ── Nonce-Cache (Redis wenn verfügbar, sonst In-Memory-Fallback) ─────────────
# ── Nonce-Cache (Redis primär, In-Memory-Fallback mit asyncio.Lock) ──────────
_nonce_cache: dict[str, float] = {} # nonce → expires_at (epoch)
_nonce_lock: asyncio.Lock | None = None # lazy init (Loop-abhängig)
_NONCE_TTL = 60 # Sekunden
def _get_nonce_lock() -> asyncio.Lock:
"""Lazy-initialized asyncio.Lock (thread-safe, event-loop-abhängig)."""
global _nonce_lock
if _nonce_lock is None:
_nonce_lock = asyncio.Lock()
return _nonce_lock
def _cleanup_nonce_cache() -> None:
"""Abgelaufene Einträge aus dem In-Memory-Cache entfernen."""
"""Abgelaufene Einträge aus dem In-Memory-Cache entfernen (muss unter Lock aufgerufen werden)."""
now = time.time()
expired = [k for k, v in _nonce_cache.items() if v <= now]
for k in expired:
@@ -51,6 +61,11 @@ async def _check_and_set_nonce(nonce: str) -> bool:
Prüft ob die Nonce schon gesehen wurde und speichert sie.
Gibt True zurück wenn die Nonce NEU ist (Request erlaubt).
Gibt False zurück wenn die Nonce bereits bekannt ist (Replay!).
Verwendet Redis als primären Nonce-Store. Falls Redis nicht erreichbar,
wird ein asyncio.Lock-geschützter In-Memory-Fallback verwendet.
Kritisch: Redis-Ausfall bei laufenden Kiosk-Requests ermöglicht theoretisch
Replay im Fallback-Fenster → Redis sollte in Production HA sein.
"""
try:
import redis.asyncio as aioredis
@@ -61,14 +76,16 @@ async def _check_and_set_nonce(nonce: str) -> bool:
await r.aclose()
return result is not None # None = bereits vorhanden
except Exception as e:
logger.warning("Redis nicht erreichbar, nutze In-Memory-Nonce-Cache: %s", e)
# Fallback: In-Memory
_cleanup_nonce_cache()
now = time.time()
if nonce in _nonce_cache:
return False
_nonce_cache[nonce] = now + _NONCE_TTL
return True
logger.warning("Redis nicht erreichbar, nutze In-Memory-Nonce-Cache (Lock-geschützt): %s", e)
# Fallback: In-Memory mit asyncio.Lock gegen Race Conditions
lock = _get_nonce_lock()
async with lock:
_cleanup_nonce_cache()
now = time.time()
if nonce in _nonce_cache:
return False
_nonce_cache[nonce] = now + _NONCE_TTL
return True
# ── Öffentlichen Schlüssel laden ─────────────────────────────────────────────