Multi-Agent-Review: Race Conditions, DB-Indizes, TLS/Security-Härtung
CI / backend-tests (push) Failing after 19s
CI / frontend-build (push) Successful in 25s

Konsolidierte Funde aus postgres-/sql-/jwt-/owasp-top10-expert-Review:

- Race Conditions gefixt: doppelte aktive Kontrolle (SAVEPOINT + partieller
  Unique-Index), doppelte Mindermengen-Genehmigung (FOR UPDATE + Unique-Index),
  Lost-Update bei Nachfüllung (FOR UPDATE auf Fehlbestand/Objektposition).
- Migration 0007: partielle Unique-Indizes als DB-Sicherheitsnetz + fehlende
  FK-Indizes (fehlbestand.material_id, kontrolle(objekt_id,status),
  zustaendigkeit, benutzer_rolle.rolle, objektposition.ablaufdatum u.a.).
- Connection-Pool explizit begrenzt (pool_size=5, max_overflow=5) - ohne das
  könnte jeder uvicorn-Worker den Postgres max_connections-Wert sprengen.
- Timing-Angriff bei Login-Enumeration gefixt (konstante Antwortzeit über
  Dummy-Hash), JWT-Decode verlangt jetzt exp/sub-Claims.
- App-seitiges Rate-Limiting (slowapi, 5/min) auf /auth/login als Verteidigung
  in der Tiefe zusätzlich zum nginx-Limit.
- nginx: TLS mit selbstsigniertem Zertifikat (LAN-Betrieb, keine Domain),
  HSTS, Content-Security-Policy, Permissions-Policy ergänzt.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CVgbozhYmuEhiEJHffRXCV
This commit is contained in:
2026-09-04 18:47:06 +02:00
co-authored by Claude Sonnet 5
parent 0909dacf2b
commit ceddeb1bf4
15 changed files with 391 additions and 19 deletions
+11 -7
View File
@@ -1,11 +1,12 @@
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi import APIRouter, Depends, HTTPException, Request, status
from fastapi.security import OAuth2PasswordRequestForm
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_current_user
from app.core.security import create_access_token, verify_password
from app.core.rate_limit import limiter
from app.core.security import create_access_token, verify_password_konstante_zeit
from app.db.session import get_db
from app.models.auth import Benutzer
@@ -25,17 +26,20 @@ class MeResponse(BaseModel):
@router.post("/auth/login", response_model=TokenResponse)
@limiter.limit("5/minute")
async def login(
request: Request,
form_data: OAuth2PasswordRequestForm = Depends(),
db: AsyncSession = Depends(get_db),
) -> TokenResponse:
result = await db.execute(select(Benutzer).where(Benutzer.login == form_data.username))
benutzer = result.scalar_one_or_none()
if (
benutzer is None
or not benutzer.aktiv
or not verify_password(form_data.password, benutzer.passwort_hash)
):
# Passwort-Vergleich läuft IMMER (auch bei unbekanntem Login), damit die
# Antwortzeit nicht verrät, ob ein Login existiert (jwt-expert-Review-Fund).
passwort_ok = verify_password_konstante_zeit(
form_data.password, benutzer.passwort_hash if benutzer else None
)
if benutzer is None or not benutzer.aktiv or not passwort_ok:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Login oder Passwort falsch",
+8
View File
@@ -0,0 +1,8 @@
from slowapi import Limiter
from slowapi.util import get_remote_address
# App-seitiges Rate-Limiting als Verteidigung in der Tiefe (owasp-Review-Fund):
# nginx begrenzt /auth/login bereits auf 5r/m (deploy/nginx_mabea.conf.template),
# aber das schützt nicht, wenn der Backend-Port direkt erreichbar ist (z.B.
# Fehlkonfiguration, Firewall-Lücke) oder nginx umgangen wird.
limiter = Limiter(key_func=get_remote_address)
+17 -1
View File
@@ -7,6 +7,11 @@ from app.core.app_settings import settings
_pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
# Konstanter Dummy-Hash für Timing-Angriff-Schutz (jwt-expert-Review-Fund):
# ohne existierenden Benutzer kurzschließt `benutzer is None or verify_password(...)`
# den bcrypt-Vergleich, was per Antwortzeit verrät, ob ein Login existiert.
_DUMMY_HASH = _pwd_context.hash("kein-echtes-passwort-nur-fuer-konstante-antwortzeit")
def hash_password(password: str) -> str:
return _pwd_context.hash(password)
@@ -16,6 +21,12 @@ def verify_password(plain_password: str, password_hash: str) -> bool:
return _pwd_context.verify(plain_password, password_hash)
def verify_password_konstante_zeit(plain_password: str, password_hash: str | None) -> bool:
"""Wie verify_password, aber prüft immer gegen einen Hash (Dummy, falls der
Benutzer nicht existiert) - verhindert Login-Enumeration per Antwortzeit."""
return _pwd_context.verify(plain_password, password_hash or _DUMMY_HASH)
def create_access_token(*, subject: str) -> str:
# Bewusst KEINE Rollen im Token: get_current_user liest Rollen bei jedem Request
# frisch aus der DB (Rollenänderung wirkt sofort, kein Token-Refresh nötig).
@@ -30,6 +41,11 @@ class InvalidTokenError(Exception):
def decode_access_token(token: str) -> dict:
try:
return jwt.decode(token, settings.jwt_secret_key, algorithms=[settings.jwt_algorithm])
return jwt.decode(
token,
settings.jwt_secret_key,
algorithms=[settings.jwt_algorithm],
options={"require": ["exp", "sub"]},
)
except jwt.PyJWTError as exc:
raise InvalidTokenError(str(exc)) from exc
+12 -1
View File
@@ -4,7 +4,18 @@ from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_asyn
from app.core.app_settings import settings
engine = create_async_engine(settings.database_url, pool_pre_ping=True)
# Explizite Pool-Grenzen (postgres-expert-Review-Fund): ohne das erzeugt jeder
# uvicorn-Worker einen eigenen Default-Pool (5 + 10 Overflow), was bei mehreren
# Workern auf dem 4GB-VPS PostgreSQL max_connections (per Tuning-Config auf 50
# begrenzt, siehe deploy/install_server.sh) sprengen kann. pool_recycle gegen
# von der DB nach Idle-Timeout gekappte Verbindungen.
engine = create_async_engine(
settings.database_url,
pool_pre_ping=True,
pool_size=5,
max_overflow=5,
pool_recycle=1800,
)
SessionLocal = async_sessionmaker(engine, expire_on_commit=False)
+5
View File
@@ -1,12 +1,15 @@
from contextlib import asynccontextmanager
from fastapi import FastAPI
from slowapi import _rate_limit_exceeded_handler
from slowapi.errors import RateLimitExceeded
import app.models # noqa: F401 (alle ORM-Modelle vollständig an Base.metadata
# registrieren, unabhängig davon, welche Endpunkte tatsächlich verdrahtet sind -
# sonst schlägt FK-Auflösung zwischen Modellen fehl, siehe tests/conftest.py)
from app.api.v1.api import api_router
from app.core.app_settings import settings
from app.core.rate_limit import limiter
from app.db.session import engine
_PLACEHOLDER_JWT_SECRET = "change-me-to-a-long-random-value"
@@ -24,4 +27,6 @@ async def lifespan(app: FastAPI):
app = FastAPI(title="MABEA", version="0.1.0", lifespan=lifespan)
app.state.limiter = limiter
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
app.include_router(api_router, prefix="/api/v1")
+10 -1
View File
@@ -38,6 +38,13 @@ async def nachfuellen(
Stand stehen, obwohl die Kontrolle bereits einen realen Zählwert kennt -
"Ist-Menge wird auf Soll korrigiert" (Prompt 02.3) meint genau dieses Setzen.
"""
# Row-Lock gegen Lost-Update bei gleichzeitiger Nachfüllung derselben Position
# (z. B. zwei Stationen erfassen parallel, sql-expert-Review-Fund) - blockiert
# bis Transaktionsende (get_db committet/rollbacked nach dem Request), danach
# sieht der zweite Aufruf bereits die aktualisierte istmenge/status.
result = await db.execute(select(Fehlbestand).where(Fehlbestand.id == fehlbestand.id).with_for_update())
fehlbestand = result.scalar_one()
if fehlbestand.status == FehlbestandStatus.erledigt:
raise FehlbestandBereitsErledigtError()
@@ -46,10 +53,12 @@ async def nachfuellen(
neue_fehlmenge = max(Decimal(0), fehlbestand.sollmenge - neue_istmenge)
result = await db.execute(
select(Objektposition).where(
select(Objektposition)
.where(
Objektposition.objekt_id == fehlbestand.objekt_id,
Objektposition.material_id == fehlbestand.material_id,
)
.with_for_update()
)
objektposition = result.scalar_one_or_none()
if objektposition is not None:
+17 -2
View File
@@ -1,6 +1,7 @@
from datetime import datetime, timezone
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.fehlbestand import Fehlbestand, FehlbestandStatus
@@ -60,8 +61,22 @@ async def starte_kontrolle(
status=KontrollStatus.in_bearbeitung,
gestartet_am=datetime.now(timezone.utc),
)
db.add(neue_kontrolle)
await db.flush()
try:
# SAVEPOINT statt vollem Rollback: ein Fehlschlag hier darf die bereits
# oben (Übernahme-Zweig) protokollierte Historie/Abbruch der alten
# Kontrolle nicht mit verwerfen.
async with db.begin_nested():
db.add(neue_kontrolle)
await db.flush()
except IntegrityError as exc:
# Sicherheitsnetz gegen den partiellen Unique-Index (Migration 0007):
# zwei parallele Requests haben beide die aktive_kontrolle()-Prüfung
# oben passiert (TOCTOU, sql-expert-Review-Fund) - hier verliert der
# zweite Request kontrolliert statt mit rohem DB-Fehler.
laufende_jetzt = await aktive_kontrolle(db, objekt_id)
if laufende_jetzt is not None:
raise ObjektGesperrtError(laufende_jetzt) from exc
raise
await _lasse_mindermengen_ablaufen(
db, objekt_id=objekt_id, neue_kontrolle=neue_kontrolle, zustaendiger_server_id=zustaendiger_server_id
+11 -1
View File
@@ -1,6 +1,7 @@
from datetime import datetime, timezone
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.fehlbestand import Fehlbestand, FehlbestandStatus
@@ -34,6 +35,10 @@ async def genehmigen(
# Kontrolle, Sprint 3), aber das Schema erlaubt kontrolle_id=NULL.
raise ValueError("Fehlbestand ohne auslösende Kontrolle kann nicht genehmigt werden")
# Row-Lock auf den Fehlbestand serialisiert parallele Genehmigungsversuche
# (sql-expert-Review-Fund: TOCTOU zwischen Check und Insert ohne Lock).
await db.execute(select(Fehlbestand).where(Fehlbestand.id == fehlbestand.id).with_for_update())
result = await db.execute(
select(MindermengenGenehmigung).where(
MindermengenGenehmigung.fehlbestand_id == fehlbestand.id,
@@ -52,7 +57,12 @@ async def genehmigen(
status=MindermengeStatus.aktiv,
)
db.add(genehmigung)
await db.flush()
try:
await db.flush()
except IntegrityError as exc:
# Sicherheitsnetz gegen den partiellen Unique-Index (Migration 0007),
# falls der Lock oben je umgangen würde (z. B. anderer Isolation-Level).
raise BereitsGenehmigtError() from exc
await historie_service.log(
db,