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:
@@ -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 ─────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user