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
+44 -7
View File
@@ -2,10 +2,12 @@ from fastapi import APIRouter, Depends, HTTPException, Request
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.crypto import decrypt_value, encrypt_value
from app.core.database import get_db
from app.core.dependencies import CurrentUser
from app.core.limiter import limiter
from app.core.security import hash_password, verify_password
from app.models.audit_log import AuditLog
from app.schemas.auth import (
LoginRequest,
MessageResponse,
@@ -103,12 +105,30 @@ async def change_password(
detail="Neues Passwort muss mindestens 1 Großbuchstaben und 1 Zahl enthalten"
)
current_user.password_hash = hash_password(data.new_password)
db.add(AuditLog(
company_id=current_user.company_id,
user_id=current_user.id,
action="password_changed",
entity_type="user",
entity_id=current_user.id,
))
await db.commit()
return MessageResponse(message="Passwort erfolgreich geändert")
# ── TOTP / 2FA ────────────────────────────────────────────────────────────────
def _totp_plain(user) -> str | None:
"""Gibt das entschlüsselte TOTP-Secret zurück, oder None."""
if not user.totp_secret:
return None
try:
return decrypt_value(user.totp_secret)
except ValueError:
# Fallback: Secret war noch im Klartext (Legacy-Daten vor 0026-Migration)
return user.totp_secret
@router.post("/totp/setup", response_model=TotpSetupResponse)
async def totp_setup(current_user: CurrentUser):
"""Generiert ein neues TOTP-Secret und gibt die otpauth-URI zurück. Noch nicht aktiviert."""
@@ -117,8 +137,8 @@ async def totp_setup(current_user: CurrentUser):
issuer = "TimeMaster"
label = current_user.email
uri = pyotp.totp.TOTP(secret).provisioning_uri(name=label, issuer_name=issuer)
# Secret temporär im User speichern (noch nicht totp_enabled)
current_user.totp_secret = secret
# Secret Fernet-verschlüsselt speichern (noch nicht totp_enabled)
current_user.totp_secret = encrypt_value(secret)
# Hinweis: DB-Commit passiert NICHT hier erst nach verify in /totp/confirm
# Damit das Secret nicht verloren geht, sofort speichern
return TotpSetupResponse(secret=secret, otpauth_uri=uri)
@@ -133,7 +153,7 @@ async def totp_setup_save(
import pyotp
if not current_user.totp_secret:
secret = pyotp.random_base32()
current_user.totp_secret = secret
current_user.totp_secret = encrypt_value(secret)
await db.commit()
return MessageResponse(message="Secret gespeichert")
@@ -146,12 +166,20 @@ async def totp_confirm(
):
"""Bestätigt den ersten TOTP-Code und aktiviert 2FA."""
import pyotp
if not current_user.totp_secret:
plain_secret = _totp_plain(current_user)
if not plain_secret:
raise HTTPException(400, "Kein TOTP-Secret vorhanden. Zuerst /totp/setup aufrufen.")
totp = pyotp.TOTP(current_user.totp_secret)
totp = pyotp.TOTP(plain_secret)
if not totp.verify(data.code, valid_window=1):
raise HTTPException(400, "Ungültiger Code")
current_user.totp_enabled = True
db.add(AuditLog(
company_id=current_user.company_id,
user_id=current_user.id,
action="totp_enabled",
entity_type="user",
entity_id=current_user.id,
))
await db.commit()
return MessageResponse(message="Zwei-Faktor-Authentifizierung aktiviert")
@@ -168,11 +196,19 @@ async def totp_disable(
raise HTTPException(400, "Passwort falsch")
if not current_user.totp_enabled or not current_user.totp_secret:
raise HTTPException(400, "2FA ist nicht aktiv")
totp = pyotp.TOTP(current_user.totp_secret)
plain_secret = _totp_plain(current_user)
totp = pyotp.TOTP(plain_secret or "")
if not totp.verify(data.code, valid_window=1):
raise HTTPException(400, "Ungültiger TOTP-Code")
current_user.totp_enabled = False
current_user.totp_secret = None
db.add(AuditLog(
company_id=current_user.company_id,
user_id=current_user.id,
action="totp_disabled",
entity_type="user",
entity_id=current_user.id,
))
await db.commit()
return MessageResponse(message="Zwei-Faktor-Authentifizierung deaktiviert")
@@ -202,7 +238,8 @@ async def totp_login(
if not user.totp_enabled or not user.totp_secret:
raise HTTPException(400, "2FA nicht aktiv")
totp = pyotp.TOTP(user.totp_secret)
plain_secret = _totp_plain(user)
totp = pyotp.TOTP(plain_secret or "")
if not totp.verify(data.code, valid_window=1):
raise HTTPException(400, "Ungültiger Code")
+15 -2
View File
@@ -19,6 +19,19 @@ router = APIRouter(prefix="/import", tags=["import"])
_allowed_roles = [UserRole.HR, UserRole.COMPANY_ADMIN, UserRole.SUPER_ADMIN]
_MAX_UPLOAD_BYTES = 10 * 1024 * 1024 # 10 MB
async def _read_upload(file: UploadFile) -> bytes:
"""Liest eine UploadFile mit Größenbegrenzung (max 10 MB)."""
content = await file.read(_MAX_UPLOAD_BYTES + 1)
if len(content) > _MAX_UPLOAD_BYTES:
raise HTTPException(
status_code=413,
detail=f"Datei zu groß. Maximale Upload-Größe: {_MAX_UPLOAD_BYTES // (1024 * 1024)} MB.",
)
return content
class ImportPreviewResponse(BaseModel):
preview: list[ImportPreviewEntry]
@@ -48,7 +61,7 @@ async def kimai_preview(
except ValueError:
raise HTTPException(status_code=400, detail="Ungültige user_id")
content = await file.read()
content = await _read_upload(file)
result: ImportResult = await preview_kimai_import(content, target_id, db)
time_count = sum(1 for p in result.preview if p.kind == "time" and not p.skipped)
@@ -76,7 +89,7 @@ async def kimai_run(
except ValueError:
raise HTTPException(status_code=400, detail="Ungültige user_id")
content = await file.read()
content = await _read_upload(file)
result: ImportResult = await run_kimai_import(content, target_id, current_user.id, db)
return ImportRunResponse(
+17 -3
View File
@@ -1,7 +1,7 @@
from typing import Annotated
from uuid import UUID
from fastapi import APIRouter, Depends, File, Query, UploadFile
from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile
from fastapi.responses import PlainTextResponse
from sqlalchemy.ext.asyncio import AsyncSession
@@ -90,13 +90,27 @@ async def import_template(
)
_MAX_UPLOAD_BYTES = 10 * 1024 * 1024 # 10 MB
async def _read_upload(file: UploadFile) -> bytes:
"""Liest eine UploadFile mit Größenbegrenzung (max 10 MB)."""
content = await file.read(_MAX_UPLOAD_BYTES + 1)
if len(content) > _MAX_UPLOAD_BYTES:
raise HTTPException(
status_code=413,
detail=f"Datei zu groß. Maximale Upload-Größe: {_MAX_UPLOAD_BYTES // (1024 * 1024)} MB.",
)
return content
@router.post("/import/preview", response_model=UserImportResult)
async def user_import_preview(
file: Annotated[UploadFile, File()],
current_user: User = require_role(*_admin_roles),
db: AsyncSession = Depends(get_db),
):
content = await file.read()
content = await _read_upload(file)
result = await user_import_service.preview_csv(content, current_user.company_id, current_user, db)
return _to_import_result_schema(result)
@@ -107,7 +121,7 @@ async def user_import_apply(
current_user: User = require_role(*_admin_roles),
db: AsyncSession = Depends(get_db),
):
content = await file.read()
content = await _read_upload(file)
result = await user_import_service.apply_csv(content, current_user.company_id, current_user, db)
return _to_import_result_schema(result)