Security-Hardening vor öffentlicher Freigabe: Rate-Limiting, Passwort-Policy, Docs-Endpunkte, Security-Header

Sicherheits-Review 25.08.2026 vor geplanter öffentlicher Erreichbarkeit
über Pangolin (bisher nur Pangolin-interne Auth vorgeschaltet):

- auth.py: In-Memory-Rate-Limiting auf /login (je Email UND je Quell-IP,
  8 Fehlversuche / 10 Minuten), UserManager.validate_password() setzt
  jetzt eine Mindestlänge von 12 Zeichen durch (fastapi_users' Default
  war ein No-Op, ließ auch leere Passwörter zu).
- routers/admin_kunden.py, routers/organisation.py: fangen
  InvalidPasswordException/UserAlreadyExists jetzt sauber ab statt 500.
- app.py: /docs, /redoc, /openapi.json deaktiviert (legten zuvor die
  komplette Routenstruktur inkl. /admin/* offen), Security-Header-
  Middleware (X-Content-Type-Options, X-Frame-Options, Referrer-Policy,
  CSP).
- scripts/reset_password.py: neues Skript, Gegenstück zu create_user.py,
  für die im selben Review gefundenen aktiven Testaccounts mit dem
  Passwort 'test123' (einer davon Superuser) gebraucht.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Thomas Stallinger 2026-08-25 12:14:34 +02:00
parent 9265d47916
commit cb820b9790
5 changed files with 197 additions and 18 deletions

31
app.py
View File

@ -1,4 +1,4 @@
from fastapi import FastAPI from fastapi import FastAPI, Request
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
import auth import auth
@ -15,9 +15,36 @@ from routers import (
) )
app = FastAPI(title="Tuxflotte Kundenplattform") # Sicherheits-Review 25.08.2026 (vor der geplanten oeffentlichen Freigabe
# ueber Pangolin): /docs, /redoc, /openapi.json waren zuvor unauthentifiziert
# erreichbar und legten die komplette interne Routenstruktur (inkl. aller
# /admin/*-Pfade) offen - fuer eine Plattform in aktiver Entwicklung
# unnoetige Aufklaerungshilfe fuer Angreifer.
app = FastAPI(title="Tuxflotte Kundenplattform", docs_url=None, redoc_url=None, openapi_url=None)
app.mount("/static", StaticFiles(directory="static"), name="static") app.mount("/static", StaticFiles(directory="static"), name="static")
@app.middleware("http")
async def security_headers(request: Request, call_next):
"""
Basis-Security-Header (Sicherheits-Review 25.08.2026). Pangolin
terminiert TLS und kann HSTS ergaenzen, das hier ist die App-seitige
Schicht unabhaengig davon. CSP erlaubt 'unsafe-inline' nur fuer Styles
(einzelne style="display:inline"-Attribute in ein paar Admin-Templates),
nicht fuer Skripte - die Templates enthalten ohnehin kein Inline-JS.
"""
response = await call_next(request)
response.headers.setdefault("X-Content-Type-Options", "nosniff")
response.headers.setdefault("X-Frame-Options", "DENY")
response.headers.setdefault("Referrer-Policy", "same-origin")
response.headers.setdefault(
"Content-Security-Policy",
"default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:",
)
return response
app.include_router(auth.router) app.include_router(auth.router)
app.include_router(geraete.router) app.include_router(geraete.router)
app.include_router(organisation.router) app.include_router(organisation.router)

85
auth.py
View File

@ -1,12 +1,14 @@
import os import os
import time
import uuid import uuid
from collections import defaultdict, deque
from dataclasses import dataclass from dataclasses import dataclass
from types import SimpleNamespace from types import SimpleNamespace
from typing import Optional from typing import Optional
from fastapi import APIRouter, Depends, Form, HTTPException, Request from fastapi import APIRouter, Depends, Form, HTTPException, Request
from fastapi.responses import RedirectResponse from fastapi.responses import RedirectResponse
from fastapi_users import BaseUserManager, FastAPIUsers, UUIDIDMixin, schemas from fastapi_users import BaseUserManager, FastAPIUsers, UUIDIDMixin, exceptions, schemas
from fastapi_users.authentication import AuthenticationBackend, CookieTransport, JWTStrategy from fastapi_users.authentication import AuthenticationBackend, CookieTransport, JWTStrategy
from fastapi_users.db import BaseUserDatabase from fastapi_users.db import BaseUserDatabase
@ -19,6 +21,58 @@ SESSION_SECRET = os.environ.get("KUNDENPLATTFORM_SESSION_SECRET", "")
COOKIE_NAME = "kundenplattform_session" COOKIE_NAME = "kundenplattform_session"
COOKIE_MAX_AGE = 14 * 24 * 60 * 60 # 14 Tage, wie zuvor Starlettes SessionMiddleware-Default COOKIE_MAX_AGE = 14 * 24 * 60 * 60 # 14 Tage, wie zuvor Starlettes SessionMiddleware-Default
MIN_PASSWORD_LENGTH = 12
# Login-Rate-Limiting (Sicherheits-Review 25.08.2026): einfacher In-Memory-
# Zaehler statt einer zusaetzlichen Abhaengigkeit (slowapi o.ae.) - der
# Dienst laeuft laut kundenplattform.service ohne --workers, also als
# einzelner uvicorn-Prozess, ein In-Process-Dict ist damit ausreichend (kein
# Multi-Worker-Sync-Problem). Ueberlebt keinen Neustart - das ist akzeptabel,
# CrowdSec am Pangolin-Proxy ist die persistente/IP-uebergreifende Schicht,
# das hier ist die zusaetzliche Anwendungsschicht gegen gezieltes Erraten
# eines bekannten Accounts, auch von wechselnden Quell-IPs aus.
LOGIN_RATE_LIMIT = 8
LOGIN_RATE_WINDOW_SECONDS = 10 * 60
_login_attempts_je_email: dict[str, deque] = defaultdict(deque)
_login_attempts_je_ip: dict[str, deque] = defaultdict(deque)
def _client_ip(request: Request) -> str:
# Pangolin (Reverse Proxy) setzt X-Forwarded-For; ohne Proxy (z.B. beim
# lokalen QEMU-Test) faellt das auf die direkte Verbindung zurueck.
weitergeleitet = request.headers.get("x-forwarded-for")
if weitergeleitet:
return weitergeleitet.split(",")[0].strip()
return request.client.host if request.client else "unbekannt"
def _rate_limited(zaehler: dict[str, deque], schluessel: str) -> bool:
jetzt = time.monotonic()
versuche = zaehler[schluessel]
while versuche and jetzt - versuche[0] > LOGIN_RATE_WINDOW_SECONDS:
versuche.popleft()
return len(versuche) >= LOGIN_RATE_LIMIT
def _login_rate_limited(email: str, ip: str) -> bool:
return _rate_limited(_login_attempts_je_email, email) or _rate_limited(_login_attempts_je_ip, ip)
def _record_failed_login(email: str, ip: str) -> None:
jetzt = time.monotonic()
_login_attempts_je_email[email].append(jetzt)
_login_attempts_je_ip[ip].append(jetzt)
def _clear_failed_logins(email: str, ip: str) -> None:
_login_attempts_je_email.pop(email, None)
_login_attempts_je_ip.pop(ip, None)
BENUTZER_SPALTEN = ( BENUTZER_SPALTEN = (
"id, organization_id, email, hashed_password, is_active, is_superuser, " "id, organization_id, email, hashed_password, is_active, is_superuser, "
"ist_organisationsadmin, is_verified" "ist_organisationsadmin, is_verified"
@ -136,6 +190,21 @@ class UserManager(UUIDIDMixin, BaseUserManager[User, uuid.UUID]):
reset_password_token_secret = SESSION_SECRET reset_password_token_secret = SESSION_SECRET
verification_token_secret = SESSION_SECRET verification_token_secret = SESSION_SECRET
async def validate_password(self, password: str, user) -> None:
# Sicherheits-Review 25.08.2026: fastapi_users' Default ist ein
# No-Op, das lies bislang jedes Passwort (auch leer) zu - live als
# `root@default-lab.test` / `test123` (Superuser!) gefunden.
if len(password) < MIN_PASSWORD_LENGTH:
raise exceptions.InvalidPasswordException(
reason=f"Das Passwort muss mindestens {MIN_PASSWORD_LENGTH} Zeichen lang sein."
)
email = getattr(user, "email", None)
if email and password.lower() == email.lower():
raise exceptions.InvalidPasswordException(
reason="Das Passwort darf nicht mit der E-Mail-Adresse identisch sein."
)
async def get_user_manager(user_db: UserDatabase = Depends(get_user_db)): async def get_user_manager(user_db: UserDatabase = Depends(get_user_db)):
yield UserManager(user_db) yield UserManager(user_db)
@ -250,13 +319,27 @@ async def login_submit(
password: str = Form(...), password: str = Form(...),
user_manager: UserManager = Depends(get_user_manager), user_manager: UserManager = Depends(get_user_manager),
): ):
email_normalisiert = email.strip().lower()
ip = _client_ip(request)
if _login_rate_limited(email_normalisiert, ip):
return templates.TemplateResponse(
request,
"login.html",
{"error": "Zu viele fehlgeschlagene Loginversuche. Bitte in einigen Minuten erneut versuchen."},
status_code=429,
)
user = await user_manager.authenticate(SimpleNamespace(username=email, password=password)) user = await user_manager.authenticate(SimpleNamespace(username=email, password=password))
if user is None or not user.is_active: if user is None or not user.is_active:
_record_failed_login(email_normalisiert, ip)
return templates.TemplateResponse( return templates.TemplateResponse(
request, "login.html", {"error": "E-Mail oder Passwort ist falsch."}, status_code=401 request, "login.html", {"error": "E-Mail oder Passwort ist falsch."}, status_code=401
) )
_clear_failed_logins(email_normalisiert, ip)
strategy = get_jwt_strategy() strategy = get_jwt_strategy()
token = await strategy.write_token(user) token = await strategy.write_token(user)

View File

@ -1,5 +1,6 @@
from fastapi import APIRouter, Depends, Form, HTTPException, Request from fastapi import APIRouter, Depends, Form, HTTPException, Request
from fastapi.responses import RedirectResponse from fastapi.responses import RedirectResponse
from fastapi_users import exceptions
from anode_client import anode_request from anode_client import anode_request
from auth import UserCreate, UserManager, fetch_benutzer_fuer_organisation, get_user_manager, require_admin from auth import UserCreate, UserManager, fetch_benutzer_fuer_organisation, get_user_manager, require_admin
@ -232,6 +233,7 @@ async def konto_anlegen(
user: dict = Depends(require_admin), user: dict = Depends(require_admin),
user_manager: UserManager = Depends(get_user_manager), user_manager: UserManager = Depends(get_user_manager),
): ):
try:
await user_manager.create( await user_manager.create(
UserCreate( UserCreate(
email=email, email=email,
@ -241,6 +243,10 @@ async def konto_anlegen(
ist_organisationsadmin=ist_organisationsadmin == "on", ist_organisationsadmin=ist_organisationsadmin == "on",
) )
) )
except exceptions.InvalidPasswordException as fehler:
raise HTTPException(status_code=400, detail=fehler.reason)
except exceptions.UserAlreadyExists:
raise HTTPException(status_code=400, detail="Ein Konto mit dieser E-Mail-Adresse existiert bereits.")
return RedirectResponse(f"/admin/kunden/{organization_id}", status_code=303) return RedirectResponse(f"/admin/kunden/{organization_id}", status_code=303)

View File

@ -1,5 +1,6 @@
from fastapi import APIRouter, Depends, Form, HTTPException, Request from fastapi import APIRouter, Depends, Form, HTTPException, Request
from fastapi.responses import RedirectResponse from fastapi.responses import RedirectResponse
from fastapi_users import exceptions
from anode_client import anode_request from anode_client import anode_request
from auth import ( from auth import (
@ -72,6 +73,7 @@ async def konto_anlegen(
# is_superuser wird beim Self-Service-Aufruf nie gesetzt (Default False) # is_superuser wird beim Self-Service-Aufruf nie gesetzt (Default False)
# - das bleibt exklusiv dem internen Admin-Bereich vorbehalten (siehe # - das bleibt exklusiv dem internen Admin-Bereich vorbehalten (siehe
# ADR zu is_superuser vs. ist_organisationsadmin). # ADR zu is_superuser vs. ist_organisationsadmin).
try:
await user_manager.create( await user_manager.create(
UserCreate( UserCreate(
email=email, email=email,
@ -80,6 +82,10 @@ async def konto_anlegen(
ist_organisationsadmin=ist_organisationsadmin == "on", ist_organisationsadmin=ist_organisationsadmin == "on",
) )
) )
except exceptions.InvalidPasswordException as fehler:
raise HTTPException(status_code=400, detail=fehler.reason)
except exceptions.UserAlreadyExists:
raise HTTPException(status_code=400, detail="Ein Konto mit dieser E-Mail-Adresse existiert bereits.")
return RedirectResponse("/organisation", status_code=303) return RedirectResponse("/organisation", status_code=303)

57
scripts/reset_password.py Normal file
View File

@ -0,0 +1,57 @@
"""
Passwort eines bestehenden Kontos zuruecksetzen (Gegenstueck zu
create_user.py). Beispiel:
KUNDENPLATTFORM_DATABASE_URL=... .venv/bin/python scripts/reset_password.py \\
--email admin@schule-beispiel.de
"""
import argparse
import asyncio
import getpass
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from fastapi_users import exceptions # noqa: E402
from auth import UserDatabase, UserManager # noqa: E402
from db import get_async_database_connection # noqa: E402
async def passwort_zuruecksetzen(email: str, password: str):
async with await get_async_database_connection() as connection:
user_manager = UserManager(UserDatabase(connection))
user = await user_manager.get_by_email(email)
await user_manager.validate_password(password, user)
await user_manager.user_db.update(user, {"hashed_password": user_manager.password_helper.hash(password)})
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--email", required=True)
args = parser.parse_args()
if not os.environ.get("KUNDENPLATTFORM_DATABASE_URL"):
sys.exit("KUNDENPLATTFORM_DATABASE_URL ist nicht gesetzt.")
password = getpass.getpass("Neues Passwort: ")
password_confirm = getpass.getpass("Neues Passwort (Wiederholung): ")
if password != password_confirm:
sys.exit("Passwörter stimmen nicht überein.")
try:
asyncio.run(passwort_zuruecksetzen(args.email, password))
except exceptions.UserNotExists:
sys.exit(f"Kein Konto mit der E-Mail {args.email} gefunden.")
except exceptions.InvalidPasswordException as fehler:
sys.exit(f"Passwort abgelehnt: {fehler.reason}")
print(f"Passwort für {args.email} zurückgesetzt.")
if __name__ == "__main__":
main()