security: H-1 settings-Whitelist + H-5 UUID-Guard + H-6 DNS-Pinning + H-7 Heartbeat-Timing

H-1: company.settings als typisiertes Sub-Schema
- schemas/company.py: CompanySettingsUpdate mit extra=forbid
- Nur bekannte Keys (carryover_expires_month/day) erlaubt
- Unbekannte Keys → HTTP 422

H-5: SQL-Injection defensiv absichern
- dependencies.py: UUID-Round-Trip str(_uuid.UUID(...)) + Sicherheitskommentar

H-6: CalDAV DNS-Rebinding-Schutz
- caldav_service.py: PinnedIPTransport — IP einmal auflösen, beim Request fixieren
- _validate_caldav_url gibt aufgelöste IP zurück
- Alle HTTP-Methoden nutzen PinnedIPTransport

H-7: Heartbeat-Timestamp nach Route-Logik
- kiosk_security.py: last_heartbeat_at-Update aus Dependency entfernt
- kiosk_service.py: Update erst in process_heartbeat() nach erfolgreicher Auth

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-26 11:35:18 +02:00
parent 654258f13e
commit 4dc69137dd
7 changed files with 152 additions and 24 deletions
+8 -4
View File
@@ -1,3 +1,4 @@
import uuid as _uuid
from typing import Annotated
from uuid import UUID
@@ -60,10 +61,13 @@ async def get_current_user(
# bypass so subsequent queries in the same transaction are automatically
# filtered to the user's company.
if user.role != UserRole.SUPER_ADMIN and user.company_id is not None:
# SET LOCAL does not accept bind parameters in PostgreSQL; the value
# must be inlined. We sanitise by converting through uuid.UUID first
# so an attacker-supplied token payload cannot inject arbitrary SQL.
safe_company_id = str(user.company_id) # already a UUID object from db.get()
# Sicherheits-Invariante: safe_company_id muss eine valide UUID sein.
# Der _uuid.UUID()-Round-Trip verhindert SQL-Injection auch bei zukünftigen
# Refactorings (z.B. falls user.company_id einmal ein String aus einem
# anderen Pfad käme). SET LOCAL akzeptiert keine Bind-Parameter in
# PostgreSQL, daher ist String-Interpolation hier unvermeidlich —
# der UUID-Round-Trip ist die kryptographische Absicherung dagegen.
safe_company_id = str(_uuid.UUID(str(user.company_id)))
await db.execute(text(f"SET LOCAL app.company_id = '{safe_company_id}'"))
await db.execute(text("SET LOCAL app.bypass_rls = 'off'"))
+7 -4
View File
@@ -177,9 +177,10 @@ async def verify_kiosk_request(
3. Gerät laden + Status prüfen
4. IP-Whitelist (falls konfiguriert)
5. Ed25519-Signatur verifizieren
6. last_heartbeat_at aktualisieren
Gibt das verifizierte KioskDevice zurück.
Hinweis: last_heartbeat_at wird NICHT hier gesetzt (H-7) nur der Heartbeat-
Endpoint setzt es, nach erfolgreicher Route-Logik.
"""
# 1. Timestamp-Check
try:
@@ -259,8 +260,10 @@ async def verify_kiosk_request(
except InvalidSignature:
raise HTTPException(status_code=401, detail="Ungültige Signatur.")
# 6. Heartbeat-Zeitstempel aktualisieren
device.last_heartbeat_at = datetime.now(timezone.utc)
await db.flush()
# Hinweis: last_heartbeat_at wird NICHT hier gesetzt, sondern erst im
# Heartbeat-Route-Handler (process_heartbeat). So wird der Timestamp nur
# committed wenn die gesamte Route-Logik erfolgreich war, nicht bereits
# bei jedem signierten Request. Andere Endpunkte (auth/pin etc.) aktualisieren
# last_heartbeat_at bewusst nicht nur echter Heartbeat zählt als Liveness-Signal.
return device
+7 -1
View File
@@ -35,7 +35,13 @@ async def update_my_company(
db: AsyncSession = Depends(get_db),
):
company = await db.get(Company, current_user.company_id)
for field, value in data.model_dump(exclude_none=True).items():
update_data = data.model_dump(exclude_none=True)
# settings ist ein CompanySettingsUpdate-Objekt → als dict ins JSONB mergen
if "settings" in update_data and isinstance(update_data["settings"], dict):
existing = dict(company.settings or {})
existing.update(update_data.pop("settings"))
update_data["settings"] = existing
for field, value in update_data.items():
setattr(company, field, value)
return CompanyOut.model_validate(company)
+15 -2
View File
@@ -1,12 +1,25 @@
import uuid
from typing import Literal
from pydantic import BaseModel, Field
from pydantic import BaseModel, ConfigDict, Field
PersonnelNumberModeT = Literal["manual", "auto"]
class CompanySettingsUpdate(BaseModel):
"""Validiertes Sub-Schema für das company.settings JSONB-Feld.
Nur bekannte Top-Level-Keys sind erlaubt (extra="forbid").
Derzeit genutzte Keys: carryover_expires_month, carryover_expires_day
(gelesen in absences.py für Resturlaub-Verfall-Berechnung).
"""
model_config = ConfigDict(extra="forbid")
carryover_expires_month: int | None = Field(None, ge=1, le=12)
carryover_expires_day: int | None = Field(None, ge=1, le=31)
class CompanyOut(BaseModel):
model_config = {"from_attributes": True}
@@ -37,7 +50,7 @@ class CompanyOut(BaseModel):
class CompanyUpdate(BaseModel):
name: str | None = Field(None, min_length=2, max_length=255)
state: str | None = Field(None, max_length=10)
settings: dict | None = None
settings: CompanySettingsUpdate | None = None
personnel_number_required: bool | None = None
personnel_number_mode: PersonnelNumberModeT | None = None
personnel_number_next: int | None = Field(None, ge=1)
+67 -11
View File
@@ -121,16 +121,21 @@ _BLOCKED_NETWORKS = [
]
def _validate_caldav_url(url: str) -> None:
async def _validate_caldav_url(url: str) -> str:
"""
Prüft eine CalDAV-URL auf SSRF-Risiken.
Wirft ValueError mit sprechender Meldung bei Problemen.
Prüft eine CalDAV-URL auf SSRF-Risiken und gibt die aufgelöste IP zurück.
Die zurückgegebene IP wird beim eigentlichen HTTP-Request via PinnedIPTransport
fest eingesetzt, um DNS-Rebinding zwischen Validierung und Request zu verhindern
(TTL=0-Angriff: zweite DNS-Auflösung durch httpx könnte andere IP liefern).
Prüfungen:
1. Schema muss http:// oder https:// sein (kein file://, ftp://, etc.)
2. Hostname muss vorhanden sein
3. Keine expliziten privaten IP-Adressen im URL
4. DNS-Auflösung + Prüfung ob die aufgelöste IP intern ist (DNS-Rebinding-Schutz)
Gibt die erste aufgelöste (und erlaubte) IP als String zurück.
"""
try:
parsed = urlparse(url)
@@ -150,23 +155,39 @@ def _validate_caldav_url(url: str) -> None:
try:
ip = ipaddress.ip_address(hostname)
_check_ip_blocked(ip, hostname)
# hostname ist bereits eine IP-Literal → direkt zurückgeben
return str(ip)
except ValueError:
pass # Kein gültiges IP-Literal → ist ein Hostname, DNS-Auflösung folgt
# DNS auflösen und alle aufgelösten Adressen prüfen (DNS-Rebinding-Schutz)
# DNS auflösen und alle aufgelösten Adressen prüfen (DNS-Rebinding-Schutz).
# socket.getaddrinfo ist blockierend → in Executor auslagern damit der Event-Loop
# nicht blockiert wird.
loop = asyncio.get_event_loop()
port = parsed.port or (443 if parsed.scheme == "https" else 80)
try:
infos = socket.getaddrinfo(hostname, parsed.port or (443 if parsed.scheme == "https" else 80))
infos = await loop.run_in_executor(
None, socket.getaddrinfo, hostname, port
)
except socket.gaierror as exc:
raise ValueError(f"Hostname '{hostname}' konnte nicht aufgelöst werden: {exc}") from exc
first_allowed_ip: str | None = None
for _family, _type, _proto, _canonname, sockaddr in infos:
ip_str = sockaddr[0]
try:
ip = ipaddress.ip_address(ip_str)
_check_ip_blocked(ip, hostname)
if first_allowed_ip is None:
first_allowed_ip = ip_str
except ValueError as exc:
raise ValueError(str(exc)) from exc
if first_allowed_ip is None:
raise ValueError(f"Hostname '{hostname}' konnte nicht auf eine erlaubte IP aufgelöst werden.")
return first_allowed_ip
def _get_allowed_networks() -> list[ipaddress.IPv4Network | ipaddress.IPv6Network]:
"""Gibt die konfigurierten Whitelist-Netzwerke zurück (CALDAV_ALLOWED_CIDRS)."""
@@ -203,6 +224,38 @@ def _check_ip_blocked(ip: ipaddress.IPv4Address | ipaddress.IPv6Address, hostnam
)
# ── DNS-Rebinding-sicherer Transport ──────────────────────────────────────────
class PinnedIPTransport(httpx.AsyncHTTPTransport):
"""
Sicherheits-Transport: Nutzt eine vorab aufgelöste IP statt erneuter DNS-Auflösung.
Verhindert DNS-Rebinding zwischen URL-Validierung und eigentlichem HTTP-Request
(Angriff mit TTL=0: zweite DNS-Auflösung durch httpx könnte andere IP liefern).
Der originale Host-Header bleibt erhalten, damit TLS-SNI und
vHost-Routing des Servers korrekt funktionieren.
"""
def __init__(self, pinned_ip: str, **kwargs):
super().__init__(**kwargs)
self._pinned_ip = pinned_ip
async def handle_async_request(self, request: httpx.Request) -> httpx.Response:
# URL mit aufgelöster IP statt Hostname umschreiben
new_url = request.url.copy_with(host=self._pinned_ip)
# Neues Request-Objekt mit ersetzter URL; alle Header (inkl. Host) bleiben
# erhalten httpx setzt Host automatisch auf den original-Hostnamen wenn
# er nicht explizit überschrieben wird, was SNI und vHost korrekt hält.
request = request.__class__(
method=request.method,
url=new_url,
headers=request.headers,
stream=request.stream,
extensions=request.extensions,
)
return await super().handle_async_request(request)
# ── HTTP helpers ───────────────────────────────────────────────────────────────
def _event_url(calendar_url: str, uid: str) -> str:
@@ -228,10 +281,11 @@ async def _http_put(
ical: bytes, verify_ssl: bool,
) -> str:
"""PUT event. Returns ETag (empty string if server doesn't send one)."""
_validate_caldav_url(calendar_url)
pinned_ip = await _validate_caldav_url(calendar_url)
verify_ssl = _effective_verify_ssl(verify_ssl)
url = _event_url(calendar_url, uid)
async with httpx.AsyncClient(verify=verify_ssl, timeout=15) as client:
transport = PinnedIPTransport(pinned_ip=pinned_ip, verify=verify_ssl)
async with httpx.AsyncClient(transport=transport, verify=verify_ssl, timeout=15) as client:
resp = await client.put(
url, content=ical,
headers={"Content-Type": "text/calendar; charset=utf-8"},
@@ -244,10 +298,11 @@ async def _http_put(
async def _http_delete(
calendar_url: str, username: str, password: str, uid: str, verify_ssl: bool,
) -> None:
_validate_caldav_url(calendar_url)
pinned_ip = await _validate_caldav_url(calendar_url)
verify_ssl = _effective_verify_ssl(verify_ssl)
url = _event_url(calendar_url, uid)
async with httpx.AsyncClient(verify=verify_ssl, timeout=15) as client:
transport = PinnedIPTransport(pinned_ip=pinned_ip, verify=verify_ssl)
async with httpx.AsyncClient(transport=transport, verify=verify_ssl, timeout=15) as client:
resp = await client.delete(url, auth=(username, password))
if resp.status_code not in (200, 204, 404):
resp.raise_for_status()
@@ -257,10 +312,11 @@ async def _http_propfind(
calendar_url: str, username: str, password: str, verify_ssl: bool,
) -> int:
"""Einfacher Verbindungstest via PROPFIND Depth:0. Gibt HTTP-Status zurück."""
_validate_caldav_url(calendar_url)
pinned_ip = await _validate_caldav_url(calendar_url)
verify_ssl = _effective_verify_ssl(verify_ssl)
body = b'<?xml version="1.0"?><d:propfind xmlns:d="DAV:"><d:prop><d:resourcetype/></d:prop></d:propfind>'
async with httpx.AsyncClient(verify=verify_ssl, timeout=10) as client:
transport = PinnedIPTransport(pinned_ip=pinned_ip, verify=verify_ssl)
async with httpx.AsyncClient(transport=transport, verify=verify_ssl, timeout=10) as client:
resp = await client.request(
"PROPFIND", calendar_url.rstrip("/") + "/",
content=body,
+6 -2
View File
@@ -119,9 +119,13 @@ class KioskService:
) -> None:
"""
Heartbeat-Daten vom Kiosk-Gerät verarbeiten.
last_heartbeat_at wird bereits in verify_kiosk_request gesetzt
hier werden nur die zusätzlichen Felder aktualisiert.
last_heartbeat_at wird hier gesetzt (H-7 Fix: nicht in verify_kiosk_request,
da der Timestamp erst nach erfolgreicher Route-Logik committed werden soll).
"""
# Heartbeat-Timestamp erst jetzt setzen nach erfolgreicher Auth + Route-Logik
device.last_heartbeat_at = datetime.now(timezone.utc)
if data.client_version is not None:
device.client_version = data.client_version