From 8672f92ee5183a2857278ee93811203bcb464646 Mon Sep 17 00:00:00 2001 From: patrick Date: Wed, 4 Nov 2026 23:04:07 +0100 Subject: [PATCH] fix(security): Audit-Funde M-1,M-2,M-3,M-6,M-7,N-1..N-3 (Client-IP-Trust, AuditLog company_id, Token-Feeds, Mass-Assignment, Kiosk-Heartbeat) Co-Authored-By: Claude Sonnet 5.5 --- backend/app/core/dependencies.py | 19 +++++++---- backend/app/routers/ha.py | 34 ++++++++++++++++--- backend/app/routers/ical.py | 34 ++++++++++++++++--- backend/app/services/absence_service.py | 4 +++ backend/app/services/auth_service.py | 10 ++---- backend/app/services/kiosk_service.py | 6 +++- backend/app/services/kiosk_session_service.py | 4 +-- backend/app/services/ldap_service.py | 3 ++ .../services/public_stamp_session_service.py | 4 +-- backend/app/services/time_service.py | 5 ++- backend/app/services/user_service.py | 15 ++++++++ 11 files changed, 109 insertions(+), 29 deletions(-) diff --git a/backend/app/core/dependencies.py b/backend/app/core/dependencies.py index ff26980..13c6f8a 100644 --- a/backend/app/core/dependencies.py +++ b/backend/app/core/dependencies.py @@ -15,6 +15,9 @@ from app.models.user import User, UserRole bearer_scheme = HTTPBearer() +_TRUSTED_PROXY_IPS = {"127.0.0.1", "::1"} + + def get_client_ip(request: Request) -> str: """Liest die echte Client-IP auch hinter nginx-Proxy. @@ -22,13 +25,15 @@ def get_client_ip(request: Request) -> str: Ohne diesen Header würde request.client.host hinter nginx immer 127.0.0.1 zurückgeben, womit AuditLog-Einträge wertlos wären. """ - real_ip = request.headers.get("X-Real-IP") - if real_ip: - return real_ip.strip() - forwarded_for = request.headers.get("X-Forwarded-For") - if forwarded_for: - return forwarded_for.split(",")[0].strip() - return request.client.host if request.client else "unknown" + direct_ip = request.client.host if request.client else None + if direct_ip in _TRUSTED_PROXY_IPS: + real_ip = request.headers.get("X-Real-IP") + if real_ip: + return real_ip.strip() + forwarded_for = request.headers.get("X-Forwarded-For") + if forwarded_for: + return forwarded_for.split(",")[0].strip() + return direct_ip or "unknown" async def get_current_user( diff --git a/backend/app/routers/ha.py b/backend/app/routers/ha.py index b22e27c..639886d 100644 --- a/backend/app/routers/ha.py +++ b/backend/app/routers/ha.py @@ -10,13 +10,16 @@ import hashlib import secrets from datetime import datetime, timezone -from fastapi import APIRouter, Depends, HTTPException, Response +from fastapi import APIRouter, Depends, HTTPException, Request, Response from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.core.config import settings from app.core.database import get_db -from app.core.dependencies import get_current_user +from app.core.dependencies import get_client_ip, get_current_user +from app.core.limiter import limiter +from app.models.audit_log import AuditLog +from app.models.company import Company from app.models.time_entry import TimeEntry from app.models.user import User from app.services.time_service import time_service @@ -28,6 +31,24 @@ def _hash_token(token: str) -> str: return hashlib.sha256(token.encode("utf-8")).hexdigest() +async def _company_active(user: User, db: AsyncSession) -> bool: + if user.company_id is None: + return True + company = await db.get(Company, user.company_id) + return company is not None and company.is_active + + +def _audit(user: User, action: str, request: Request) -> AuditLog: + return AuditLog( + company_id=user.company_id, + user_id=user.id, + action=action, + entity_type="user", + entity_id=user.id, + ip=get_client_ip(request), + ) + + def _secs(t) -> int: return t.hour * 3600 + t.minute * 60 + t.second @@ -38,9 +59,10 @@ def _elapsed_secs(start, now) -> int: @router.get("/time/ha/{token}") -async def ha_status(token: str, db: AsyncSession = Depends(get_db)) -> dict: +@limiter.limit("60/minute") +async def ha_status(request: Request, token: str, db: AsyncSession = Depends(get_db)) -> dict: user = await db.scalar(select(User).where(User.ha_token_hash == _hash_token(token))) - if user is None or not user.is_active: + if user is None or not user.is_active or not await _company_active(user, db): raise HTTPException(status_code=404, detail="Nicht gefunden.") now = datetime.now(timezone.utc) @@ -94,11 +116,13 @@ async def ha_token_status(current_user: User = Depends(get_current_user)) -> dic @router.post("/users/me/ha-token") async def create_or_rotate_ha_token( + request: Request, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ) -> dict: token = secrets.token_urlsafe(32) current_user.ha_token_hash = _hash_token(token) + db.add(_audit(current_user, "ha_token_rotated", request)) await db.commit() base = settings.frontend_url.rstrip("/") return {"enabled": True, "url": f"{base}/api/v1/time/ha/{token}"} @@ -106,9 +130,11 @@ async def create_or_rotate_ha_token( @router.delete("/users/me/ha-token", status_code=204) async def delete_ha_token( + request: Request, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ) -> Response: current_user.ha_token_hash = None + db.add(_audit(current_user, "ha_token_revoked", request)) await db.commit() return Response(status_code=204) diff --git a/backend/app/routers/ical.py b/backend/app/routers/ical.py index e63bb9b..0e2f3a1 100644 --- a/backend/app/routers/ical.py +++ b/backend/app/routers/ical.py @@ -17,14 +17,17 @@ import hashlib import secrets from datetime import date, datetime, timedelta, timezone -from fastapi import APIRouter, Depends, HTTPException, Response +from fastapi import APIRouter, Depends, HTTPException, Request, Response from icalendar import Calendar, Event from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.core.config import settings from app.core.database import get_db -from app.core.dependencies import get_current_user +from app.core.dependencies import get_client_ip, get_current_user +from app.core.limiter import limiter +from app.models.audit_log import AuditLog +from app.models.company import Company from app.models.absence import Absence, AbsenceStatus from app.models.absence_type import AbsenceType from app.models.user import User @@ -43,6 +46,24 @@ def _hash_token(token: str) -> str: return hashlib.sha256(token.encode("utf-8")).hexdigest() +async def _company_active(user: User, db: AsyncSession) -> bool: + if user.company_id is None: + return True + company = await db.get(Company, user.company_id) + return company is not None and company.is_active + + +def _audit(user: User, action: str, request: Request) -> AuditLog: + return AuditLog( + company_id=user.company_id, + user_id=user.id, + action=action, + entity_type="user", + entity_id=user.id, + ip=get_client_ip(request), + ) + + def _feed_url(token: str) -> str: base = settings.frontend_url.rstrip("/") return f"{base}/api/v1/absences/ical/{token}.ics" @@ -50,9 +71,10 @@ def _feed_url(token: str) -> str: # ── Öffentlicher Feed (kein JWT) ────────────────────────────────────────────── @router.get("/absences/ical/{token}.ics") -async def ical_feed(token: str, db: AsyncSession = Depends(get_db)) -> Response: +@limiter.limit("60/minute") +async def ical_feed(request: Request, token: str, db: AsyncSession = Depends(get_db)) -> Response: user = await db.scalar(select(User).where(User.ical_token_hash == _hash_token(token))) - if user is None or not user.is_active: + if user is None or not user.is_active or not await _company_active(user, db): raise HTTPException(status_code=404, detail="Kalender-Feed nicht gefunden.") rows = ( @@ -112,21 +134,25 @@ async def ical_token_status(current_user: User = Depends(get_current_user)) -> d @router.post("/users/me/ical-token") async def create_or_rotate_ical_token( + request: Request, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ) -> dict: """Erzeugt ein neues Token (rotiert ein bestehendes) und liefert die Feed-URL einmalig zurück.""" token = secrets.token_urlsafe(32) current_user.ical_token_hash = _hash_token(token) + db.add(_audit(current_user, "ical_token_rotated", request)) await db.commit() return {"enabled": True, "url": _feed_url(token)} @router.delete("/users/me/ical-token", status_code=204) async def delete_ical_token( + request: Request, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ) -> Response: current_user.ical_token_hash = None + db.add(_audit(current_user, "ical_token_revoked", request)) await db.commit() return Response(status_code=204) diff --git a/backend/app/services/absence_service.py b/backend/app/services/absence_service.py index 3113aea..12e5a9f 100644 --- a/backend/app/services/absence_service.py +++ b/backend/app/services/absence_service.py @@ -289,6 +289,9 @@ class AbsenceService: if data.half_day_end is not None: absence.half_day_end = data.half_day_end if data.substitute_id is not None: + substitute = await db.get(User, data.substitute_id) + if substitute is None or substitute.company_id != current_user.company_id: + raise HTTPException(status_code=404, detail="Vertretung nicht gefunden.") absence.substitute_id = data.substitute_id if data.note is not None: absence.note = data.note @@ -313,6 +316,7 @@ class AbsenceService: # Audit-Log action = "absence_change_request" if (was_approved and not is_manager) else "absence_updated" db.add(AuditLog( + company_id=current_user.company_id, user_id=current_user.id, action=action, entity_type="absence", diff --git a/backend/app/services/auth_service.py b/backend/app/services/auth_service.py index 64ab4a7..d57722c 100644 --- a/backend/app/services/auth_service.py +++ b/backend/app/services/auth_service.py @@ -33,14 +33,8 @@ def _get_client_ip(request: "Request | None") -> str | None: """Gibt die echte Client-IP zurück (berücksichtigt X-Real-IP / X-Forwarded-For hinter nginx-Proxy).""" if not request: return None - real_ip = request.headers.get("X-Real-IP") - if real_ip: - return real_ip.strip() - forwarded = request.headers.get("X-Forwarded-For") - if forwarded: - # Erstes Element = Original-Client-IP - return forwarded.split(",")[0].strip() - return request.client.host if request.client else None + from app.core.dependencies import get_client_ip + return get_client_ip(request) def _slugify(name: str) -> str: diff --git a/backend/app/services/kiosk_service.py b/backend/app/services/kiosk_service.py index 33bdb2a..33080ae 100644 --- a/backend/app/services/kiosk_service.py +++ b/backend/app/services/kiosk_service.py @@ -9,6 +9,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.core.security import hash_token from app.models.company import Company from app.models.kiosk_device import KioskDevice, KioskDeviceStatus +from app.models.user import User from app.schemas.kiosk import KioskBearerDeviceCreate, HeartbeatRequest, KioskDeviceCreate, KioskDeviceUpdate @@ -162,7 +163,10 @@ class KioskService: device.offline_queue_size = data.queued_offline_entries if data.current_user_id is not None and company.kiosk_track_current_user: - device.current_user_id = data.current_user_id + tracked = await db.get(User, data.current_user_id) + device.current_user_id = ( + tracked.id if tracked is not None and tracked.company_id == device.company_id else None + ) elif not company.kiosk_track_current_user: # DSGVO-Opt-Out: aktuellen User nicht speichern device.current_user_id = None diff --git a/backend/app/services/kiosk_session_service.py b/backend/app/services/kiosk_session_service.py index f13ef48..e3d5d00 100644 --- a/backend/app/services/kiosk_session_service.py +++ b/backend/app/services/kiosk_session_service.py @@ -50,7 +50,7 @@ class KioskSessionService: except Exception as exc: raise HTTPException( status_code=503, - detail=f"Session konnte nicht erstellt werden: {exc}" + detail="Session konnte nicht erstellt werden." ) return session_token @@ -63,7 +63,7 @@ class KioskSessionService: except Exception as exc: raise HTTPException( status_code=503, - detail=f"Session-Lookup fehlgeschlagen: {exc}" + detail="Session-Lookup fehlgeschlagen." ) if data is None: return None diff --git a/backend/app/services/ldap_service.py b/backend/app/services/ldap_service.py index 947722d..8581b44 100644 --- a/backend/app/services/ldap_service.py +++ b/backend/app/services/ldap_service.py @@ -304,6 +304,9 @@ class LdapService: from ldap3 import SUBTREE from ldap3.utils.conv import escape_filter_chars + if not password: + return False + conn = self._bind_connection(config) if conn is None: return False diff --git a/backend/app/services/public_stamp_session_service.py b/backend/app/services/public_stamp_session_service.py index 157a321..3bd5a45 100644 --- a/backend/app/services/public_stamp_session_service.py +++ b/backend/app/services/public_stamp_session_service.py @@ -38,7 +38,7 @@ class PublicStampSessionService: try: await redis.set(key, json.dumps(payload), ex=PUBLIC_STAMP_SESSION_TTL) except Exception as exc: - raise HTTPException(status_code=503, detail=f"Session konnte nicht erstellt werden: {exc}") + raise HTTPException(status_code=503, detail="Session konnte nicht erstellt werden.") return session_token async def get_session(self, session_token: str) -> Optional[dict]: @@ -46,7 +46,7 @@ class PublicStampSessionService: try: data = await redis.get(SESSION_KEY_PREFIX + session_token) except Exception as exc: - raise HTTPException(status_code=503, detail=f"Session-Lookup fehlgeschlagen: {exc}") + raise HTTPException(status_code=503, detail="Session-Lookup fehlgeschlagen.") if data is None: return None return json.loads(data) diff --git a/backend/app/services/time_service.py b/backend/app/services/time_service.py index 753c521..f5cbb67 100644 --- a/backend/app/services/time_service.py +++ b/backend/app/services/time_service.py @@ -244,7 +244,7 @@ class TimeService: break_minutes=data.break_minutes, project_id=data.project_id, note=data.note, - source=data.source, + source=EntrySource.MANUAL, status=EntryStatus.PENDING, ) db.add(entry) @@ -360,6 +360,9 @@ class TimeService: raise HTTPException(status_code=403, detail="Keine Berechtigung zum Ablehnen.") entry = await self._get_entry_or_404(entry_id, db) + entry_user = await db.get(User, entry.user_id) + if entry_user is None or entry_user.company_id != current_user.company_id: + raise HTTPException(status_code=403, detail="Zugriff verweigert.") if entry.status != EntryStatus.PENDING: raise HTTPException(status_code=409, detail="Nur ausstehende Einträge können abgelehnt werden.") diff --git a/backend/app/services/user_service.py b/backend/app/services/user_service.py index f9367c2..3cb79ab 100644 --- a/backend/app/services/user_service.py +++ b/backend/app/services/user_service.py @@ -310,6 +310,21 @@ class UserService: new_value={"role": new_role.value if hasattr(new_role, "value") else str(new_role)}, )) + if changes.get("is_active") and user.anonymized_at is not None: + raise HTTPException(status_code=409, detail="Anonymisierte Konten können nicht reaktiviert werden.") + if changes.get("is_active") is False and user.id == current_user.id: + raise HTTPException(status_code=400, detail="Cannot deactivate your own account") + if changes.get("department_id") is not None: + from app.models.department import Department + dept = await db.get(Department, changes["department_id"]) + if dept is None or dept.company_id != user.company_id: + raise HTTPException(status_code=404, detail="Abteilung nicht gefunden.") + if changes.get("work_schedule_id") is not None: + from app.models.work_schedule import WorkSchedule + ws = await db.get(WorkSchedule, changes["work_schedule_id"]) + if ws is None or ws.company_id != user.company_id: + raise HTTPException(status_code=404, detail="Arbeitsplan nicht gefunden.") + for field, value in changes.items(): setattr(user, field, value)