Dieses Dokument erklärt warum PulseBase welche Sicherheitsmaßnahmen trifft, nicht nur was implementiert ist. Es ist die zentrale Referenz für alle Sicherheitsentscheidungen — von der Entwurfsphase bis zur laufenden Wartung.
Verwandte Dokumente:
production-hardening.md— Deployment-Checkliste und konkrete Befehlereview-open-items.md— offene Punkte aus den Reviews (App/UX/Style)architecture.md— Systemaufbau und Datenflüsse
PulseBase speichert Gesundheitsdaten. Diese fallen unter Art. 9 DSGVO ("besondere Kategorien personenbezogener Daten") und sind damit der höchsten gesetzlichen Schutzklasse zugeordnet.
| Asset | Kritikalität | Beispiel |
|---|---|---|
| Gesundheitsdaten (HRV, Schlaf, Glukose, Aktivitäten) | Sehr hoch | Epilepsie-Events, HRV-Trends |
| Garmin/LibreLink-Zugangsdaten (Token) | Sehr hoch | Ermöglicht Zugriff auf externen Health-Account |
| Benutzerkonto (E-Mail, Passwort-Hash) | Hoch | Identität, Login-Möglichkeit |
| Session-Token (Cookie) | Hoch | Aktive Sitzung aller Nutzer |
| App-Secrets (SESSION_SECRET, FERNET_KEY) | Sehr hoch | Kompromittierung betrifft alle Nutzer |
PulseBase ist eine öffentlich zugängliche Self-Hosted-App mit echten Nutzern. Gesundheitsdaten nach Art. 9 DSGVO erfordern das volle Threat Model — kein reduzierter Homelab-Scope.
| Threat Actor | Motivation | Wahrscheinlichkeit |
|---|---|---|
| Automatisierte Scanner (Shodan, Masscan) | Credential Stuffing, bekannte CVEs ausnutzen | Hoch |
| Opportunistische Angreifer | Niedrig hängende Früchte (schwache Passwörter, Standard-Credentials) | Mittel |
| Gezielte Angreifer | Gesundheitsdaten exfiltrieren, Konto übernehmen | Mittel |
| Supply Chain (kompromittierte Abhängigkeit) | Code-Ausführung im Container | Mittel |
| Physischer Zugriff auf den Server | Daten-Dump, Token-Extraktion | Sehr niedrig |
[Internet]
│
▼ 443/tcp (einziger eingehender Vektor)
[Caddy] ←── homelab-gateway (Heim) oder gebündelt via make up-public (SaaS)
│
▼ HTTP intern
[FastAPI] ←── [Garmin Connect API] (ausgehend, OAuth-ähnlich mit Token)
│ [LibreLink API] (ausgehend, Token-Auth)
│
▼ asyncpg
[TimescaleDB] (nicht exponiert, nur internes Docker-Netz)
Eingehende Angriffsvektoren:
- HTTP-Endpunkte (Auth-Bypass, IDOR, Injection, CSRF, XSS)
- Login-Formulare (Brute Force, Credential Stuffing)
- File-Upload-ähnliche Eingaben (z.B. Seizure Notes mit Nutzerdaten)
Ausgehende Risiken:
- SSRF durch manipulierte Garmin/Libre-Credentials (kein User-Input in URL-Aufbau — mitigiert)
- Kompromittierter Upstream (Garmin Connect oder LibreLink) liefert manipulierte Daten
- DDoS: Mitigation durch Cloudflare/Caddy; kein eigener Schutz implementiert
- Seitenkanal-Angriffe auf der Hardware (außerhalb des Software-Scopes)
- Angriffe nach vollständiger Host-Kompromittierung (root auf Mac mini)
| Datenkategorie | DSGVO-Klasse | Gespeichert in | Schutz |
|---|---|---|---|
| Gesundheitsdaten (Aktivitäten, HRV, Schlaf, Glukose, Anfälle) | Art. 9 (hoch) | TimescaleDB | DB auf internem Netz, kein direkter Zugriff |
| Garmin/LibreLink Auth-Token | Art. 9 indirekt (Zugriff auf Gesundheitsdaten) | user_tokens-Tabelle |
Fernet-verschlüsselt at rest |
| E-Mail, Passwort-Hash | Art. 6 | users-Tabelle |
bcrypt, nie Plaintext |
| Session-Cookie | — | Client-Browser | httpOnly, secure, sameSite=Strict, max_age=3600 (1h) |
| Consent-Audit-Log | Art. 5(2) Rechenschaftspflicht | user_consents-Tabelle |
IP als SHA-256-Hash (keine Reverse-Lookup-Möglichkeit) |
| Strukturierte Logs | — | stdout / Container-Log | Keine PII (E-Mail, IP nie geloggt) |
| ML-Modelle | — | ml-models-Volume |
Aggregiert, kein Rückschluss auf Individuen |
- Garmin-Passwörter werden nie gespeichert — nur der nach Login erhaltene Session-Token
- IP-Adressen werden im Consent-Log nur als SHA-256-Hash gespeichert (V21-Migration)
export_user_dataschließtpassword_hashexplizit aus- Logs enthalten keine E-Mail-Adressen, keine Passwörter, keine IP-Adressen
Warum bcrypt direkt (nicht passlib)?
passlib ist mit bcrypt>=4.0 inkompatibel — es würde ohne Fehler einen schwächeren Hash-Algorithmus fallen. bcrypt direkt gibt beim Start einen Fehler wenn die Library-Version nicht unterstützt wird.
# Sicherheitsrelevante Parameter:
bcrypt.hashpw(password.encode(), bcrypt.gensalt()) # gensalt() ohne Argument → Default 12 Rounds- Der Default von
bcrypt.gensalt()istrounds=12, also 2¹² = 4096 Hash-Iterationen — ~300ms pro Versuch, macht Brute Force unpraktikabel - Timing-sicherer Vergleich:
bcrypt.checkpw()ist constant-time (keine timing-basierten Enumeration-Angriffe)
Warum DUMMY_HASH? Ohne Dummy-Hash: Login mit nicht-existierender E-Mail → ~0ms Response (kein bcrypt-Aufruf). Login mit falscher E-Mail bei existierendem User → ~300ms (bcrypt läuft). Angreifer kann E-Mails validieren durch Timing-Messung.
DUMMY_HASH = bcrypt.hashpw(b"dummy", bcrypt.gensalt()).decode()
# Einmal beim App-Start berechnet, dann immer genutzt wenn User nicht existiertPulseBase nutzt signierte Cookie-Sessions (Starlette SessionMiddleware) statt JWT.
Warum kein JWT? JWT erfordert Token-Invalidierung (z.B. bei Konto-Kompromittierung) entweder via Datenbank-Lookup (dann verliert man Statelessness) oder via kurze TTL + Refresh-Token-Rotation (erhöhte Komplexität). Für ein Single-Server-Deployment bringt JWT keinen Vorteil.
Session-Cookie-Eigenschaften (gesetzt in api/src/main.py, SessionMiddleware):
httpOnly=True— kein JavaScript-Zugriff (Starlette-Default; reduziert Cookie-Diebstahl via XSS)secure=True(wennHTTPS_ONLY=true, viahttps_only=settings.https_only) — Cookie nur über HTTPS übertragensameSite="strict"— Cookie wird bei Cross-Site-Requests gar nicht mitgesendet (stärker alslax)max_age=3600— Session läuft nach 1 Stunde ab (signiertes Cookie mit eingebettetem Ablauf, kein reines Browser-Session-Cookie)
Session-Fixation verhindern:
request.session.clear() # Alte Session-ID wegwerfen
request.session["user_id"] = ... # Neue Session beginnenOhne clear() könnte ein Angreifer eine Session-ID in einen Link einbauen, das Opfer damit einloggen lassen und dann die vorbekannte Session-ID nutzen.
Warum konto-basiert statt nur IP-basiert? IP-basiertes Rate Limiting (slowapi) schützt gegen volumetrische Brute Force. Konto-basierter Lockout schützt gegen verteilte Angriffe (viele IPs, ein Ziel-Account).
5 Fehlversuche → locked_until = NOW() + 15 Minuten
Fehlversuch während Lockout → kein neuer bcrypt-Aufruf (Timing-safe, keine Lockout-Extension)
Erfolgreicher Login → failed_login_attempts = 0
DoS-Gegenmaßnahme: Der Lockout könnte von einem Angreifer genutzt werden um legitime User auszusperren. Mitigation: E-Mail-Benachrichtigung informiert den echten Nutzer, locked_until läuft automatisch ab (kein Admin-Eingriff nötig).
Design-Prinzip: Non-leaking
POST /auth/reset-request antwortet immer mit HTTP 200 und gleicher Message, unabhängig ob die E-Mail-Adresse existiert. Würde der Server differenzieren, könnten Angreifer den Endpunkt zur E-Mail-Enumeration nutzen.
Token-Design (DB-backed, api/src/auth_tokens.py):
# Reset-Token ist NICHT stateless — Zufallswert wird gehasht in der DB abgelegt
raw = secrets.token_urlsafe(32)
token_hash = hashlib.sha256(raw.encode()).hexdigest()
expires_at = datetime.now(timezone.utc) + timedelta(seconds=900) # _RESET_MAX_AGE = 15 min
await save_reset_token(user_id, token_hash, expires_at)
# Validierung per DB-Lookup auf den SHA-256-Hash, nicht per HMAC-Signatur- Nur der SHA-256-Hash liegt in der DB — ein DB-Leak gibt keine nutzbaren Tokens preis
- 15min TTL (
_RESET_MAX_AGE = 900) — kurzes Fenster reduziert Risiko bei abgefangener E-Mail - Token-Invalidierung nach Verwendung: Nach erfolgreichem Reset wird der DB-Eintrag entwertet — Replay-Angriffe mit demselben Token schlagen fehl.
- Alle drei Token-Typen sind DB-backed single-use (Reset 15 min, E-Mail-Verify 24h, Account-Delete 1h): ein Zufallswert wird gemailt, nur sein SHA-256-Hash + Ablauf wird gespeichert, serverseitig geprüft und bei Verwendung gelöscht (V26 hat Verify/Delete von stateless
itsdangerousauf DB-backed umgestellt — keine replaybaren Tokens mehr).
Garmin- und LibreLink-Passwörter werden niemals gespeichert. Der Flow (identisch für beide Services):
- User gibt Credentials im
/garmin/linkoder/libre/link-Formular ein - Client-Library authenticiert sich und erhält einen Session-Token
- Token wird ausschließlich in einem
tempfile.TemporaryDirectory()geschrieben (kein permanenter Pfad auf Disk) - Token-Daten werden Fernet-verschlüsselt und in der DB gespeichert (
user_tokens-Tabelle, V20) - Tempdir (und damit der Klartext-Token) wird beim Verlassen des Context-Managers automatisch gelöscht
- Credentials sind nach dem Request aus dem Speicher weg
Fernet-Verschlüsselung:
from cryptography.fernet import Fernet
f = Fernet(settings.fernet_key)
encrypted = f.encrypt(token_data) # AES-128-CBC + HMAC-SHA256
decrypted = f.decrypt(encrypted)Fernet bietet authenticated encryption — manipulierte Ciphertext-Blöcke werden erkannt und verworfen. Der FERNET_KEY wird beim App-Start validiert; die App crasht mit ValueError wenn der Key ungültig oder leer ist.
Request
│
▼ Schicht 1: SessionMiddleware
│ └── Prüft ob session["user_id"] existiert
│ → 401 / Redirect /login wenn nicht
│
▼ Schicht 2: require_user() Dependency (deps.py)
│ └── Prüft session["user_id"] und lädt den User aus der DB
│ → NeedsLogin (Redirect /login) wenn Session fehlt oder User nicht existiert
│ (Die E-Mail-Verifikation wird beim LOGIN erzwungen, nicht pro Request —
│ siehe auth_helpers._handle_unverified_email)
│
▼ Schicht 3: Data Access Layer (db/*.py)
└── Jede Query bindet user_id: WHERE user_id = $1
→ BOLA unmöglich — andere User-Daten nicht abrufbar
Warum die 3. Schicht die wichtigste ist:
Schicht 1 und 2 können durch Fehler im Routing oder durch vergessene require_user()-Dependency umgangen werden. Die 3. Schicht ist schwerer zu vergessen, weil jede Query explizit user_id als Parameter haben muss.
Gefahr ohne BOLA-Schutz: GET /api/activities/12345 würde Aktivität 12345 zurückgeben, egal welchem User sie gehört.
Schutz in api/src/db/activities.py:
SELECT * FROM activities WHERE id = $1 AND user_id = $2Beide Parameter müssen passen. Wenn $2 (eingeloggte User-ID) nicht zum Datensatz passt → None zurück → 404.
TLS: Caddy terminiert TLS mit ACME/Let's Encrypt (Pflicht für öffentliches Deployment). HSTS ist aktiviert (max-age=31536000; includeSubDomains) — Browser erzwingen HTTPS nach dem ersten Aufruf.
Deployment-Optionen:
- Heim — homelab-gateway (Caddy): ACME via HTTP-01/DNS-01 im
homelab-gateway; nur über Tailscale erreichbar. - Public SaaS —
make up-public: gebündeltes Caddy holt automatisch ein Let's-Encrypt-Cert fürPUBLIC_DOMAIN(deploy/Caddyfile). Self-signed TLS ist für öffentliches Deployment nicht akzeptabel.
Alle HTTP-Responses enthalten diese Headers (gesetzt in api/src/main.py):
| Header | Wert | Schutz gegen |
|---|---|---|
Content-Security-Policy |
Nonce-basiert + 'strict-dynamic' |
XSS |
Strict-Transport-Security |
max-age=31536000; includeSubDomains |
Downgrade-Angriffe, SSL-Stripping |
X-Content-Type-Options |
nosniff |
MIME-Sniffing (IE/Edge-Exploit) |
X-Frame-Options |
DENY |
Clickjacking |
Referrer-Policy |
strict-origin-when-cross-origin |
Referrer-Leakage |
Permissions-Policy |
camera=(), microphone=(), geolocation=(), payment=() |
Browser-Feature-Missbrauch |
Wave 7 (L-05): worker-src 'none' und manifest-src 'self' sind seit Wave 7 in der CSP gesetzt (api/src/main.py).
Alle Datenbankzugriffe laufen über asyncpg Prepared Statements:
await conn.fetch("SELECT * FROM activities WHERE user_id = $1", user_id)$1, $2, usw. sind Platzhalter für Parameter — asyncpg trennt SQL-Code von Daten auf Protokollebene. User-Input landet nie im SQL-String. Kein ORM, kein Query Builder — direktes Prepared-Statement-API verhindert versehentliche String-Konkatenation.
Risiko: Seizure Notes, Event-Type-Strings, Metriken-Labels kommen aus der DB (also ursprünglich vom User) und werden in der UI dargestellt.
Mitigation: Kein innerHTML mit User-Daten. Drei Ansätze je nach Kontext:
// 1. Reiner Text → textContent (epilepsy.js, dashboard-utils.js)
element.textContent = userInput;
// 2. String-Werte in HTML-Attributen/Inhalt → esc() (activity.js: statTile, sport_label)
element.innerHTML = `<span>${esc(label)}: ${esc(value)}</span>`;
// 3. Renderer-erzeugte HTML-Struktur → DOMPurify (metrics.js)
element.innerHTML = DOMPurify.sanitize(rendererOutput);
// Verboten:
element.innerHTML = userInput; // ← XSSPulseBase startet keine Subprozesse mit User-kontrollierten Parametern. Der einzige externe Call ist die Garmin/LibreLink-Library, die intern HTTP-Requests baut.
CSRF (Cross-Site Request Forgery) nutzt aus, dass Browser Cookies automatisch mitsenden. Eine bösartige Seite kann einen POST-Request an https://your-domain.com/account/delete schicken — der Browser hängt das Session-Cookie an, die App sieht einen "authentifizierten" Request.
Gefährdete Endpunkte (alle POST-Routen mit State-Change):
/login,/register— Auth-Formulare (L-30, W9)/garmin/link,/libre/link— Verknüpfung externer Accounts/account/delete— Konto-Löschung/auth/reset— Passwort-Reset
PulseBase implementiert das Double-Submit-Cookie-Pattern zusätzlich zum SameSite=Strict-Cookie (Defense in Depth — der CSRF-Token-Check hängt nicht allein vom Browser-SameSite-Verhalten ab):
# Generierung (GET-Endpoint):
csrf_token = secrets.token_urlsafe(32) # Kryptographisch stark
request.session["csrf_token"] = csrf_token
# Token im HTML-Formular als <input type="hidden">
# Validierung (POST-Endpoint):
session_token = request.session.get("csrf_token")
form_token = form_data.get("csrf_token")
if not session_token or not hmac.compare_digest(session_token, form_token):
raise HTTPException(status_code=403)hmac.compare_digest() statt == verhindert Timing-Angriffe auf den Token-Vergleich.
Warum zusätzlich zum sameSite=strict-Cookie:
Der Session-Cookie ist auf sameSite=strict gesetzt (api/src/main.py) — Browser senden ihn bei Cross-Site-Requests gar nicht mit. Der serverseitige Double-Submit-Token bleibt als zweite, browser-unabhängige Schicht bestehen, falls ein Browser SameSite nicht korrekt durchsetzt.
Alle API-Eingaben werden an der System-Grenze gegen Pydantic-Schemas validiert:
class SeizureBody(BaseModel):
notes: str = Field(default="", max_length=1000) # Ohne max_length: DoS durch riesige Payloads
severity: int = Field(ge=1, le=10)
trigger: str = Field(max_length=100)Pydantic wirft ValidationError (→ HTTP 422) bevor der Handler-Code ausgeführt wird.
class RegisterBody(BaseModel):
email: EmailStr # Pydantic EmailStr: RFC-5322-konformOhne EmailStr könnten Nutzer beliebige Strings als E-Mail registrieren, was den Verifikations-Flow bricht und E-Mail-Injection ermöglicht (SMTP-Header-Injection über Newlines in der Adresse).
Zusätzlich zur Schema-Validierung sind folgende Endpunkte rate-limitiert (slowapi):
| Endpunkt | Limit | Schutz gegen |
|---|---|---|
POST /login |
10/min | Brute Force |
POST /register |
5/min | Account-Spam |
POST /auth/reset-request |
3/h | E-Mail-Flooding |
GET/POST /garmin/link |
5/h | Credential-Stuffing gegen Garmin API |
GET/POST /libre/link |
5/h | Credential-Stuffing gegen LibreLink API |
Zwei globale Exception-Handler in api/src/main.py normalisieren
alle JSON-Fehler auf die einheitliche Form {error:{code,message,details?}} (Details
siehe docs/api.md → Error Format). Sicherheitsrelevant:
- Kein Echo gesendeter Werte (NEU-1). Der
RequestValidationError-Handler mappt pro Fehler nurloc → fieldundmsgund verwirft Pydanticsinput/ctx. Der FastAPI-Default hätte den fehlerhaften Wert zurückgespiegelt — auf den Auth-POSTs (/login,/register,/auth/reset/*) ist das das Klartext-Passwort, und derselbe Wert wäre auch im Sentry-Event gelandet. Invariante: kein vom Client gesendeter Wert verlässt den Server je über eine Fehler-Antwort. Regressions-Schutz:test_validation_error_does_not_leak_submitted_value(Canary-Assert). debug=False(FastAPI-Default, keindebug=Trueinmain.py) → keine Tracebacks im Response-Body. Die beiden Handler deckenRequestValidationErrorundHTTPExceptionab; eine sonst unbehandelte Exception liefert Starlettes generische 500-Antwort ohne Stacktrace/interne Details.- DSN nie geloggt (NEU-2). Die DB-Verbindungs-URL (
db/pool.py) enthält das DB-Passwort im Klartext.get_pool()fängt Verbindungsfehler ab und loggt ausschließlichreason=type(e).__name__(nie den DSN), dannraise. Generell gilt: Exceptions werden mittype(e).__name__stattstr(e)geloggt (z. B.garmin.link.fail).
| Secret | Zweck | Scope | Rotation |
|---|---|---|---|
SESSION_SECRET |
Cookie-Signierung (HMAC), min. 32 Zeichen | API | Rotieren erzwingt alle User auszuloggen |
FERNET_KEY |
Token-Verschlüsselung at rest | API + Sync | Rotation erfordert Re-Encrypt aller Tokens |
DB_APP_PASSWORD |
DB-Verbindung (breite Rolle: Auth, Account-Löschung) | nur API | Standard DB-Rotation |
DB_SYNC_PASSWORD |
DB-Verbindung (Least-Privilege-Rolle, V24) | nur Sync | Standard DB-Rotation |
DB_ML_PASSWORD |
DB-Verbindung (read-only Health + write ml_predictions, V24) | nur ML | Standard DB-Rotation |
DB_PASSWORD |
DB-Admin (Migrations + Backup-Dump) | Flyway + Backup | Selten |
RESEND_API_KEY |
E-Mail-Versand | API | Bei Verdacht |
| age-Keypair | Backup-Verschlüsselung (Public am Server, Private offsite) | Backup | Unkritisch (s. 9.6) |
Jeder Service bekommt nur die Secrets die er braucht (Principle of Least Privilege):
env/.env → nur db + flyway (DB_USER/PASSWORD Admin-Creds, HOST_IP)
env/.env.app → api + sync + ml (FERNET_KEY + Per-Service-DB-Rollen V24:
DB_APP_* nur api, DB_SYNC_* nur sync, DB_ML_* nur ml — Least Privilege)
env/.env.api → nur api (SESSION_SECRET, RESEND_API_KEY, APP_BASE_URL, ...)
env/.env.sync → nur sync-service (SYNC_INTERVAL_HOURS, SYNC_LOOKBACK_DAYS, ...)
env/.env.ml → nur ml-service (ML_INFER_HOUR — ml-service greift nie auf Tokens zu, kein FERNET_KEY nötig)
env/.env.backup → nur backup-Container (AGE_RECIPIENT, BACKUP_*; DB-Creds aus env/.env)
Admin-Credentials (DB_USER/DB_PASSWORD) sind nie im Prozess-Environment von api/sync/ml
sichtbar — kein Leak via /proc/<pid>/environ (H-11, W9). Ausnahme: der backup-Container
bekommt sie bewusst (pg_dump braucht Voll-Read). Least-Privilege-Härtung wäre eine dedizierte
pg_read_all_data-Backup-Rolle (Migration V27) statt der Admin-Rolle — dokumentiert vertagt.
Verifikation:
docker exec pulsebase-api env | grep DB_USER # → leer (nur DB_APP_USER vorhanden)
docker exec pulsebase-sync env | grep SESSION_SECRET # → leer (korrekt)
docker exec pulsebase-ml env | grep SESSION_SECRET # → leer (korrekt)make gen-secrets # SESSION_SECRET, FERNET_KEY, DB_APP/SYNC/ML_PASSWORD + age-keygen-HinweisErzeugt mit openssl rand bzw. Fernet.generate_key() — kryptographisch starke Zufallszahlen
aus dem OS-CSPRNG. Das age-Backup-Keypair wird nicht hier erzeugt (der private Key soll nicht
am Server entstehen): make gen-secrets druckt nur den age-keygen-Hinweis für eine Offsite-Maschine.
SESSION_SECRET rotieren invalidiert alle aktiven Sessions sofort:
# Neues Secret generieren und in env/.env.api eintragen
make gen-secrets
make dashboard # Container neu starten mit neuem SecretWann rotieren: Bei Verdacht auf Kompromittierung, oder präventiv nach Incident. Alle eingeloggten User werden automatisch ausgeloggt — das ist der gewünschte Effekt.
Komplexer als Session-Secret, da bestehende verschlüsselte Token re-encrypt werden müssen:
# Ablauf:
# 1. Neuen Key generieren
new_key = Fernet.generate_key()
# 2. MultiFernet: dekodiert mit altem Key, kann mit neuem verschlüsseln
from cryptography.fernet import MultiFernet
f = MultiFernet([Fernet(new_key), Fernet(old_key)])
# 3. Alle Tokens in user_tokens re-encrypten
tokens = await conn.fetch("SELECT id, token_data FROM user_tokens")
for row in tokens:
re_encrypted = f.rotate(row["token_data"])
await conn.execute("UPDATE user_tokens SET token_data = $1 WHERE id = $2",
re_encrypted, row["id"])
# 4. Alten Key aus env entfernen, nur neuen Key stehen lassenWichtig — zwei unabhängige Schlüssel nicht verwechseln: FERNET_KEY (verschlüsselt
user_tokens) und das age-Backup-Keypair haben verschiedene Rotations-Konsequenzen (s. 9.6).
Das age-Keypair verschlüsselt die DB-Backups (Service backup). Asymmetrisch: der
Public-Key (AGE_RECIPIENT in env/.env.backup) liegt am Server und verschlüsselt nur;
der private Key bleibt offsite (Passwortmanager) und wird ausschließlich fürs Restore
gebraucht — ein kompromittierter Server kann seine eigenen Backups also nicht entschlüsseln.
- Rotation unkritisch: neuen
AGE_RECIPIENTeintragen → gilt ab dem nächsten Backup. Alte Dumps brauchen weiterhin den alten privaten Key zum Restore → beide privaten Keys aufbewahren, bis die Retention (BACKUP_RETENTION_DAYS) der alten Dumps abgelaufen ist. - Kontrast zu
FERNET_KEY: dessen Rotation macht alleuser_tokensunlesbar → betroffene Nutzer müssen Garmin/LibreLink neu verknüpfen (re-link). age-Rotation hat keine solche Nutzer-Auswirkung. Nur bei Verdacht auf Kompromittierung rotieren.
Multi-Stage Builds:
FROM python:3.14-slim AS builder
# ... Build-Abhängigkeiten installieren ...
FROM python:3.14-slim AS runner
# Nur Runtime-Dateien kopieren, keine Build-Tools im finalen Image
COPY --from=builder /app /appWarum: Kleineres Image = kleinere Angriffsfläche. Build-Tools (gcc, pip, etc.) sind nicht im laufenden Container.
Non-root User:
RUN adduser --system --no-create-home appuser
USER appuserWarum: Wenn ein Angreifer Code-Execution erlangt (z.B. durch eine FastAPI-Schwachstelle), läuft er als appuser ohne Schreibrechte auf das Dateisystem. Container-Escape via root wird deutlich schwerer.
Digest-Pins für Base Images:
image: python:3.14-slim@sha256:abc123...Warum: Ein latest-Tag kann sich über Nacht ändern. Ein kompromittierter Registry-Uploader könnte ein Backdoor-Image mit demselben Tag hochladen. Digest-Pins verhindern das.
┌─────────────────── internal (Docker-intern) ─────────────────┐
│ pulsebase-api ←──→ pulsebase-db │
│ pulsebase-sync ←──→ pulsebase-db │
│ pulsebase-ml ←──→ pulsebase-db │
└───────────────────────────────────────────────────────────────┘
│
│ (nur pulsebase-api ist Mitglied beider Netze)
▼
┌─────── proxy (externe Docker-Netz) ──────┐
│ gateway-caddy ←──→ pulsebase-api │
└──────────────────────────────────────────┘
Die Datenbank ist nie direkt exponiert. Sync- und ML-Service kommunizieren nur intern. Kein Service bindet Ports auf 0.0.0.0.
In der CI-Pipeline läuft Trivy gegen jedes gebaute Image:
trivy image --severity CRITICAL,HIGH --exit-code 1 --ignore-unfixed pulsebase-api:latest--ignore-unfixed: Findings ohne verfügbaren Fix werden ignoriert — der Developer kann diese nicht beheben, sie erhöhen nur den Lärm. --exit-code 1 bricht den CI-Build ab wenn CRITICAL oder HIGH Findings mit verfügbarem Fix existieren.
- SSH: Nur Key-Auth (
PasswordAuthentication no) - UFW: Nur 22, 80, 443 offen
- Automatische Security-Updates (
unattended-upgradesauf Linux) env/-Dateien:chmod 600(nur Owner lesbar)
Renovate erstellt automatisch PRs für veraltete Abhängigkeiten:
| Abhängigkeitstyp | Strategie | Begründung |
|---|---|---|
| devDependencies (patch) | Automerge | Patch-Updates sind fast immer sicher |
| Python-Pakete (minor/patch) | PR + manueller Review | Könnte Breaking Changes enthalten |
| Docker-Image-Digests | Automerge | Nur neuer Digest für gleiche Version |
| Docker-Image-Tags (major) | Manueller Review | Z.B. Python 3.14 → 3.15 |
| GitHub Actions | PR + Review | Actions können Code ausführen |
bandit (per Pre-commit + CI):
bandit -r api/src/ sync-service/src/ -l -iScannt auf bekannte Python-Sicherheitsmuster: eval(), shell=True, hardcoded Passwörter, unsichere MD5-Nutzung, etc.
semgrep (nur CI):
semgrep --config=auto .Cross-file Taint-Analyse — erkennt wenn User-Input einen gefährlichen Codepfad erreicht, auch über mehrere Dateien hinweg. Aufwändiger als bandit, läuft deshalb nur in CI (nicht pre-commit).
pip-audit (CI):
uv export --frozen --no-hashes --directory api/ -o /tmp/req-api.txt
pip-audit -r /tmp/req-api.txtuv export --frozen liest das eingefrorene uv.lock — deterministisch, kein Re-Resolve. Prüft gegen Python Packaging Advisory Database (GHSA + PyPI). Nicht Safety (veraltet, kommerziell).
gitleaks ← Secrets-Scan zuerst (Commit mit Secret sofort verhindern)
pre-commit-hooks ← trailing-whitespace, check-yaml/json/toml, no-commit-to-branch
bandit ← SAST (vor ruff — findet Security-Issues vor Code-Style-Korrekturen)
ruff ← Lint + Fix
ruff-format ← Format
detect-secrets ← Baseline-basierter Secret-Scan (ergänzt gitleaks)
mypy ← Type Check (findet implizite None-Dereferenzierungen)
gitleaks läuft zuerst: Selbst wenn spätere Hooks fehlschlagen und der Commit abbricht, ist sichergestellt dass kein Secret committed wurde. bandit läuft vor ruff damit Security-Issues nicht durch Auto-Fixes überdeckt werden.
gitleaks läuft zuerst: Selbst wenn spätere Hooks fehlschlagen und der Commit abbricht, ist sichergestellt dass kein Secret committed wurde.
Alle Actions sind mit @sha256:... gepinnt, nicht mit @v3 oder ähnlichem:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2Warum: Ein Angreifer könnte einen neuen Commit auf den v3-Tag pushen. SHA256-Digest ist unveränderlich.
Threat Modeling vor neuen Features: Für signifikante neue Features (neuer Datentyp, neuer externer Service, neue Auth-Methode) ein kurzes STRIDE-Modell erstellen:
| STRIDE | Frage | Mitigation |
|---|---|---|
| Spoofing | Wer kann sich als jemand anderes ausgeben? | Starke Auth, Token-Binding |
| Tampering | Wer kann Daten manipulieren? | HMAC, Prepared Statements |
| Repudiation | Kann ein User Aktionen abstreiten? | Audit-Log |
| Information Disclosure | Welche Daten könnten leaken? | Least Privilege, Encryption |
| Denial of Service | Was kann den Service lahmlegen? | Rate Limiting, Input-Größen |
| Elevation of Privilege | Wie kann jemand mehr Rechte bekommen? | Defense in Depth |
ASVS 5.0 als Prüfrahmen: Neue Features gegen die relevanten ASVS-Chapters prüfen (V2 Auth, V3 Session, V4 Access Control, V5 Validation, V13 API).
Checkliste vor jedem PR:
- Kein
innerHTMLmit User-Daten (DOM XSS) - Alle DB-Queries als Prepared Statements
- Neue POST-Endpunkte mit State-Change haben CSRF-Schutz
- Neue Eingabefelder haben Pydantic-Validierung mit
max_length - Keine Secrets in Logs oder Error-Responses
- Neue Endpunkte mit Auth haben
require_user()als Dependency
Pre-commit: gitleaks → bandit → mypy
CI Lint: ruff (Python) + Biome (JS)
CI Security: gitleaks + pip-audit + bandit + semgrep
CI Type: mypy (alle 3 Services)
CI Test: pytest + Playwright E2E
CI Image: Trivy (CRITICAL+HIGH, ignore-unfixed)
Ein ci-ok-All-Green-Gate-Job wurde in W3 ergänzt — security/lint/typecheck/test müssen grün sein bevor e2e läuft (H-06, ✅).
Vor jedem Deployment auf Produktion:
make migrate # Migrations anwenden (Flyway macht das automatisch beim Start)
make dashboard # API neu bauen + starten
make analytics # ML-Service neu bauen + startenKeine Zero-Downtime-Deployment derzeit: make dashboard startet den Container neu — kurze Downtime (~5-10s). Dokumentierter Tech-Debt (CICD-M4).
Rollback:
# Docker-Tag des letzten funktionierenden Builds
docker compose up -d pulsebase-api:previous-tagRegelmäßige Aufgaben:
| Frequenz | Aufgabe |
|---|---|
| Täglich | Sentry-Dashboard: neue Exceptions? |
| Wöchentlich | UptimeRobot-Report: Ausfälle? |
| Monatlich | Renovate-PRs mergen (Major-Updates nach Review) |
| Monatlich | Backup-Restore-Test (Dump in Test-Container einspielen) |
| Quartalsweise | pip-audit manuell laufen lassen, Dependencies prüfen |
| Jährlich | ASVS-Review: Hat sich die Bedrohungslage verändert? |
| Test-Art | Tool | Was wird geprüft |
|---|---|---|
| Unit Tests (Auth) | pytest | Login/Lockout/Rate-Limit/E-Mail-Verifikation/Password-Reset — alle mit ~100% Coverage |
| E2E Smoke Tests | Playwright | Login-Flow, Dashboard, Settings, Metrics, Help, Account-Export/Delete |
| E2E Auth Flows | Playwright | Register, E-Mail-Verify (Token), Passwort-Reset (Token aus DB) |
| E2E Static Pages | Playwright | Privacy/Terms/Imprint/Accessibility + Epilepsie-Seite (mit/ohne Modus) |
| SAST | bandit + semgrep | Bekannte unsichere Python-Patterns |
| SCA | pip-audit | CVEs in Dependencies |
| Image-Scan | Trivy | CVEs in OS-Packages und Python-Paketen im Container |
| Lücke | Risiko | Mitigation |
|---|---|---|
| Manuelle Penetration | IDOR, Logic-Bugs | App-Eval mit ASVS-Fokus (dieser Report) |
| DAST (Dynamic Scanning) | Laufzeit-Injection | Kein OWASP ZAP in CI — manueller App-Eval (ASVS) als Ersatz |
| Fuzzing | Unerwartete Inputs | Pydantic-Validierung als Ersatz |
| E2E CSRF-Test | CSRF-Bypass | Manueller Test ausreichend |
| Auth-Flow E2E (Register, Verify, Reset) | Regressions in Auth | test_auth_flows.py ✅ (W9) |
Für signifikante neue Features oder nach größeren Refactorings:
# 1. OWASP ZAP Baseline Scan (lokal, einmalig)
docker run -t owasp/zap2docker-stable zap-baseline.py \
-t https://your-domain.com -r zap-report.html
# 2. Auth-Flow testen
# - Login mit falschen Credentials → 401, kein Timing-Leak?
# - 5x falsch → Lockout? E-Mail-Benachrichtigung?
# - Reset-Token nach Verwendung ungültig?
# 3. CSRF testen
# - POST /garmin/link ohne csrf_token → 403?
# - POST /account/delete von anderer Domain → geblockt?
# 4. IDOR testen
# - Als User A einloggen, Activity-ID eines User B abfragen → 404?Sentry meldet Exceptions in Echtzeit. Folgende Events sollten sofortige Untersuchung auslösen:
- Ungewöhnlich viele
auth.login.fail-Einträge (Brute-Force-Versuch) - Unerwartete
500-Fehler auf Auth-Endpunkten - Sentry:
KeyErroroderPermissionErrorin db-Layer (möglicher IDOR-Versuch) - UptimeRobot: Downtime-Alert
Bei kompromittierter Session (Cookie-Theft/XSS):
# SESSION_SECRET rotieren → alle Sessions sofort ungültig
make gen-secrets # Neues Secret in env/.env.api
make dashboard # Container neu startenBei Verdacht auf kompromittierte Garmin/LibreLink-Credentials:
# Betroffene User-IDs identifizieren
make db
# Im psql:
SELECT user_id, service, updated_at FROM user_tokens WHERE updated_at > NOW() - INTERVAL '24h';
# Betroffene Tokens löschen → User werden aufgefordert neu zu verknüpfen
DELETE FROM user_tokens WHERE user_id = <id>;Bei Verdacht auf Daten-Breach:
- App offline nehmen:
make down-public(odercaddy-Service stoppen) - Logs sichern:
docker logs pulsebase-api > incident_$(date +%Y%m%d).log - Audit-Log durchsuchen: Welche User-IDs, welche Endpunkte, welche IPs?
- DSGVO-Meldepflicht prüfen: Bei Art. 9-Daten (Gesundheitsdaten) → Meldung an Datenschutzbehörde innerhalb 72h
- Root Cause Analysis dokumentieren
- Falls offen: Finding in
review-open-items.mdergänzen - Wenn anwendbar: neuen Test schreiben der den Angriffspfad abdeckt
- Security-Kontrollen anpassen
| Recht | Endpunkt | Status | Anmerkung |
|---|---|---|---|
| Auskunft (Art. 15) | GET /account/export |
✅ | JSON-Download aller Daten außer password_hash |
| Löschung (Art. 17) | POST /account/delete |
✅ | E-Mail + Passwort als Bestätigung, atomar in TX |
| Datenportabilität (Art. 20) | GET /account/export |
✅ | Maschinenlesbares JSON-Format |
| Einwilligung (Art. 7, 9) | /register |
✅ | 3 Checkboxen (Gesundheitsdaten, AGB, Alter ≥16) + Audit-Log |
| Widerruf | Konto-Löschung = impliziter Widerruf | ✅ | Alle Nutzdaten werden gelöscht; Consent-Logs (user_consents, user_consent_events) bleiben pseudonymisiert erhalten (V30, SET NULL) |
Wichtig: Die Consent-Audit-Logs (user_consents, user_consent_events) werden bei Konto-Löschung nicht gelöscht, sondern pseudonymisiert: V30 ersetzte ON DELETE CASCADE durch ON DELETE SET NULL — beim Löschen der User-Zeile wird user_id auf NULL gesetzt, der Datensatz bleibt als anonymer Nachweis erhalten (Art. 5(2) Rechenschaftspflicht). Alle anderen Nutzdaten werden in derselben Transaktion gelöscht (delete_user, api/src/db/users.py).
Prüfrahmen: OWASP Application Security Verification Standard 5.0, Level 2.
| ASVS Chapter | Status | Offene Punkte |
|---|---|---|
| V2 Authentication | ✅ | — |
| V3 Session Management | ✅ | — |
| V4 Access Control | ✅ | — |
| V5 Validation & Encoding | ✅ | — |
| V7 Error Handling & Logging | 🟡 | Audit-Log noch nicht vollständig (3.5) |
| V8 Data Protection | ✅ | — |
| V9 Communication | ✅ | — |
| V13 API & Web Service | ✅ | — |
| V14 Configuration | ✅ | — |