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)
Security Audit / Python Dependency Audit (push) Canceled after 0s
Security Audit / Node.js Dependency Audit (push) Canceled after 0s
Security Audit / Frontend Build (tsc + vite) (push) Canceled after 0s

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
patrickandClaude Sonnet 5.5 committed 2026-11-04 23:04:07 +01:00
1 parent a3470a135c
commit 8672f92ee5
11 files changed
+109 -29

No files matched your search

+12 -7
View File
@@ -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(
+30 -4
View File
@@ -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)
+30 -4
View File
@@ -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)
+4
View File
@@ -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",
+2 -8
View File
@@ -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:
+5 -1
View File
@@ -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
@@ -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
+3
View File
@@ -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
@@ -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)
+4 -1
View File
@@ -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.")
+15
View File
@@ -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)