Multi-Agent-Review: Race Conditions, DB-Indizes, TLS/Security-Härtung
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:
@@ -2799,3 +2799,155 @@ Keine Commits in dieser Session.
|
||||
- flutter_app/pubspec.yaml | 3 +
|
||||
|
||||
---
|
||||
## 2026-09-04 18:38 – 18:38 (0m)
|
||||
**Beschreibung:** Claude Code Session
|
||||
**Projekt:** flutter_app
|
||||
|
||||
### Commits
|
||||
- 0909dac Flutter-Tests: ApiClient, Offline-Queue, Login-/Objektliste-Widgets
|
||||
|
||||
### Geänderte Dateien
|
||||
- DEVLOG.md | 16 ++++
|
||||
- flutter_app/DEVLOG.md | 16 ++++
|
||||
- flutter_app/README.md | 14 +++-
|
||||
- flutter_app/lib/api/api_client.dart | 15 ++--
|
||||
- flutter_app/pubspec.yaml | 2 +
|
||||
- flutter_app/test/api_client_test.dart | 90 ++++++++++++++++++++
|
||||
- flutter_app/test/offline_queue_test.dart | 96 ++++++++++++++++++++++
|
||||
- flutter_app/test/widget/login_screen_test.dart | 57 +++++++++++++
|
||||
- .../test/widget/objekt_list_screen_test.dart | 51 ++++++++++++
|
||||
|
||||
---
|
||||
## 2026-09-04 18:38 – 18:38 (0m)
|
||||
**Beschreibung:** Claude Code Session
|
||||
**Projekt:** flutter_app
|
||||
|
||||
### Commits
|
||||
- 0909dac Flutter-Tests: ApiClient, Offline-Queue, Login-/Objektliste-Widgets
|
||||
|
||||
### Geänderte Dateien
|
||||
- DEVLOG.md | 16 ++++
|
||||
- flutter_app/DEVLOG.md | 16 ++++
|
||||
- flutter_app/README.md | 14 +++-
|
||||
- flutter_app/lib/api/api_client.dart | 15 ++--
|
||||
- flutter_app/pubspec.yaml | 2 +
|
||||
- flutter_app/test/api_client_test.dart | 90 ++++++++++++++++++++
|
||||
- flutter_app/test/offline_queue_test.dart | 96 ++++++++++++++++++++++
|
||||
- flutter_app/test/widget/login_screen_test.dart | 57 +++++++++++++
|
||||
- .../test/widget/objekt_list_screen_test.dart | 51 ++++++++++++
|
||||
|
||||
---
|
||||
## 2026-09-04 18:39 – 18:39 (0m)
|
||||
**Beschreibung:** Claude Code Session
|
||||
**Projekt:** asb-material
|
||||
|
||||
### Commits
|
||||
Keine Commits in dieser Session.
|
||||
|
||||
### Geänderte Dateien
|
||||
- DEVLOG.md | 16 ++++
|
||||
- flutter_app/DEVLOG.md | 16 ++++
|
||||
- flutter_app/README.md | 14 +++-
|
||||
- flutter_app/lib/api/api_client.dart | 15 ++--
|
||||
- flutter_app/pubspec.yaml | 2 +
|
||||
- flutter_app/test/api_client_test.dart | 90 ++++++++++++++++++++
|
||||
- flutter_app/test/offline_queue_test.dart | 96 ++++++++++++++++++++++
|
||||
- flutter_app/test/widget/login_screen_test.dart | 57 +++++++++++++
|
||||
- .../test/widget/objekt_list_screen_test.dart | 51 ++++++++++++
|
||||
|
||||
---
|
||||
## 2026-09-04 18:39 – 18:39 (0m)
|
||||
**Beschreibung:** Claude Code Session
|
||||
**Projekt:** asb-material
|
||||
|
||||
### Commits
|
||||
Keine Commits in dieser Session.
|
||||
|
||||
### Geänderte Dateien
|
||||
- DEVLOG.md | 16 ++++
|
||||
- flutter_app/DEVLOG.md | 16 ++++
|
||||
- flutter_app/README.md | 14 +++-
|
||||
- flutter_app/lib/api/api_client.dart | 15 ++--
|
||||
- flutter_app/pubspec.yaml | 2 +
|
||||
- flutter_app/test/api_client_test.dart | 90 ++++++++++++++++++++
|
||||
- flutter_app/test/offline_queue_test.dart | 96 ++++++++++++++++++++++
|
||||
- flutter_app/test/widget/login_screen_test.dart | 57 +++++++++++++
|
||||
- .../test/widget/objekt_list_screen_test.dart | 51 ++++++++++++
|
||||
|
||||
---
|
||||
## 2026-09-04 18:39 – 18:39 (0m)
|
||||
**Beschreibung:** Claude Code Session
|
||||
**Projekt:** asb-material
|
||||
|
||||
### Commits
|
||||
Keine Commits in dieser Session.
|
||||
|
||||
### Geänderte Dateien
|
||||
- DEVLOG.md | 16 ++++
|
||||
- flutter_app/DEVLOG.md | 16 ++++
|
||||
- flutter_app/README.md | 14 +++-
|
||||
- flutter_app/lib/api/api_client.dart | 15 ++--
|
||||
- flutter_app/pubspec.yaml | 2 +
|
||||
- flutter_app/test/api_client_test.dart | 90 ++++++++++++++++++++
|
||||
- flutter_app/test/offline_queue_test.dart | 96 ++++++++++++++++++++++
|
||||
- flutter_app/test/widget/login_screen_test.dart | 57 +++++++++++++
|
||||
- .../test/widget/objekt_list_screen_test.dart | 51 ++++++++++++
|
||||
|
||||
---
|
||||
## 2026-09-04 18:40 – 18:40 (0m)
|
||||
**Beschreibung:** Claude Code Session
|
||||
**Projekt:** asb-material
|
||||
|
||||
### Commits
|
||||
Keine Commits in dieser Session.
|
||||
|
||||
### Geänderte Dateien
|
||||
- DEVLOG.md | 16 ++++
|
||||
- flutter_app/DEVLOG.md | 16 ++++
|
||||
- flutter_app/README.md | 14 +++-
|
||||
- flutter_app/lib/api/api_client.dart | 15 ++--
|
||||
- flutter_app/pubspec.yaml | 2 +
|
||||
- flutter_app/test/api_client_test.dart | 90 ++++++++++++++++++++
|
||||
- flutter_app/test/offline_queue_test.dart | 96 ++++++++++++++++++++++
|
||||
- flutter_app/test/widget/login_screen_test.dart | 57 +++++++++++++
|
||||
- .../test/widget/objekt_list_screen_test.dart | 51 ++++++++++++
|
||||
|
||||
---
|
||||
## 2026-09-04 18:40 – 18:40 (0m)
|
||||
**Beschreibung:** Claude Code Session
|
||||
**Projekt:** asb-material
|
||||
|
||||
### Commits
|
||||
Keine Commits in dieser Session.
|
||||
|
||||
### Geänderte Dateien
|
||||
- DEVLOG.md | 16 ++++
|
||||
- flutter_app/DEVLOG.md | 16 ++++
|
||||
- flutter_app/README.md | 14 +++-
|
||||
- flutter_app/lib/api/api_client.dart | 15 ++--
|
||||
- flutter_app/pubspec.yaml | 2 +
|
||||
- flutter_app/test/api_client_test.dart | 90 ++++++++++++++++++++
|
||||
- flutter_app/test/offline_queue_test.dart | 96 ++++++++++++++++++++++
|
||||
- flutter_app/test/widget/login_screen_test.dart | 57 +++++++++++++
|
||||
- .../test/widget/objekt_list_screen_test.dart | 51 ++++++++++++
|
||||
|
||||
---
|
||||
## 2026-09-04 18:40 – 18:40 (0m)
|
||||
**Beschreibung:** Claude Code Session
|
||||
**Projekt:** asb-material
|
||||
|
||||
### Commits
|
||||
Keine Commits in dieser Session.
|
||||
|
||||
### Geänderte Dateien
|
||||
- DEVLOG.md | 16 ++++
|
||||
- flutter_app/DEVLOG.md | 16 ++++
|
||||
- flutter_app/README.md | 14 +++-
|
||||
- flutter_app/lib/api/api_client.dart | 15 ++--
|
||||
- flutter_app/pubspec.yaml | 2 +
|
||||
- flutter_app/test/api_client_test.dart | 90 ++++++++++++++++++++
|
||||
- flutter_app/test/offline_queue_test.dart | 96 ++++++++++++++++++++++
|
||||
- flutter_app/test/widget/login_screen_test.dart | 57 +++++++++++++
|
||||
- .../test/widget/objekt_list_screen_test.dart | 51 ++++++++++++
|
||||
|
||||
---
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
"""Review-Funde (postgres-/sql-expert): fehlende FK-Indizes + partielle Unique-
|
||||
Constraints gegen Race Conditions (doppelte aktive Kontrolle/Mindermenge)
|
||||
|
||||
Revision ID: 0007_indizes_und_unique_constraints
|
||||
Revises: 0006_add_eskalation_felder
|
||||
Create Date: 2026-09-04
|
||||
|
||||
Zwei unabhängige Fixes aus dem Agenten-Review dieser Session:
|
||||
1. Partielle Unique-Indizes als DB-Sicherheitsnetz gegen die TOCTOU-Race
|
||||
zwischen Check und Insert in starte_kontrolle()/genehmigen() - Anwendungscode
|
||||
fängt die resultierende IntegrityError bereits ab (lifecycle.py, mindermenge.py).
|
||||
2. Fehlende Indizes auf häufig gefilterten Fremdschlüsseln (Eskalations-Cron,
|
||||
Kontroll-Sperre-Check, Dashboard-Ablaufdatum-Abfrage u.a.).
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0007_indizes_und_unique_constraints"
|
||||
down_revision: Union[str, None] = "0006_add_eskalation_felder"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute(
|
||||
"""
|
||||
CREATE UNIQUE INDEX idx_kontrolle_eine_aktive_je_objekt
|
||||
ON kontrolle (objekt_id) WHERE status = 'in_bearbeitung';
|
||||
CREATE UNIQUE INDEX idx_mindermenge_eine_aktive_je_fehlbestand
|
||||
ON mindermengen_genehmigung (fehlbestand_id) WHERE status = 'aktiv';
|
||||
|
||||
CREATE INDEX idx_fehlbestand_material ON fehlbestand (material_id);
|
||||
CREATE INDEX idx_fehlbestand_status_entstanden ON fehlbestand (status, entstanden_am);
|
||||
CREATE INDEX idx_kontrolle_objekt_status ON kontrolle (objekt_id, status);
|
||||
CREATE INDEX idx_kontrollposition_kontrolle ON kontrollposition (kontrolle_id);
|
||||
CREATE INDEX idx_zustaendigkeit_objekt ON zustaendigkeit (objekt_id);
|
||||
CREATE INDEX idx_zustaendigkeit_standort ON zustaendigkeit (standort_id);
|
||||
CREATE INDEX idx_benutzer_rolle_rolle ON benutzer_rolle (rolle);
|
||||
CREATE INDEX idx_objektposition_material ON objektposition (material_id);
|
||||
CREATE INDEX idx_nachfuellung_fehlbestand ON nachfuellung (fehlbestand_id);
|
||||
CREATE INDEX idx_objektposition_ablauf ON objektposition (ablaufdatum) WHERE ablaufdatum IS NOT NULL;
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute(
|
||||
"""
|
||||
DROP INDEX idx_objektposition_ablauf;
|
||||
DROP INDEX idx_nachfuellung_fehlbestand;
|
||||
DROP INDEX idx_objektposition_material;
|
||||
DROP INDEX idx_benutzer_rolle_rolle;
|
||||
DROP INDEX idx_zustaendigkeit_standort;
|
||||
DROP INDEX idx_zustaendigkeit_objekt;
|
||||
DROP INDEX idx_kontrollposition_kontrolle;
|
||||
DROP INDEX idx_kontrolle_objekt_status;
|
||||
DROP INDEX idx_fehlbestand_status_entstanden;
|
||||
DROP INDEX idx_fehlbestand_material;
|
||||
DROP INDEX idx_mindermenge_eine_aktive_je_fehlbestand;
|
||||
DROP INDEX idx_kontrolle_eine_aktive_je_objekt;
|
||||
"""
|
||||
)
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -20,6 +20,7 @@ dependencies = [
|
||||
"aiosmtplib>=3.0,<3.1",
|
||||
"python-barcode[images]>=0.15,<0.16",
|
||||
"reportlab>=4.2,<4.3",
|
||||
"slowapi>=0.1.9,<0.2",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
|
||||
@@ -9,8 +9,14 @@ import app.models # noqa: F401 (registriert ALLE ORM-Modelle an Base.metadata,
|
||||
# nicht importierte Tabelle zeigt. Fund im dritten echten CI-Lauf.)
|
||||
from app.core.app_settings import settings
|
||||
from app.core.security import hash_password
|
||||
from app.core.rate_limit import limiter
|
||||
from app.db.session import get_db
|
||||
from app.main import app
|
||||
|
||||
# Login-Rate-Limit (5/Minute, Karte owasp-Review) würde bei vielen Tests, die
|
||||
# login() mehrfach aufrufen, den Testlauf selbst blockieren - hier bewusst
|
||||
# global für die Testsuite deaktiviert, Produktivverhalten bleibt unberührt.
|
||||
limiter.enabled = False
|
||||
from app.models.auth import Benutzer, BenutzerRolle, RolleTyp
|
||||
from app.models.objekt import Objekt
|
||||
from app.models.stammdaten import Bereich, Material, MaterialTyp, Objekttyp, Standort
|
||||
|
||||
@@ -1,5 +1,42 @@
|
||||
import pytest
|
||||
|
||||
from app.core.rate_limit import limiter
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_login_unbekannter_benutzer_ist_401(client):
|
||||
"""jwt-expert-Review-Fund: konstante Antwortzeit auch ohne existierenden
|
||||
Login (verify_password_konstante_zeit) - hier nur der Statuscode, die
|
||||
Timing-Eigenschaft selbst ist nicht sinnvoll per Unit-Test prüfbar."""
|
||||
response = await client.post(
|
||||
"/api/v1/auth/login",
|
||||
data={"username": "gibt-es-nicht", "password": "irgendwas"},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_login_rate_limit(client, mitarbeiter_user):
|
||||
"""owasp-Review-Fund: App-seitiges Rate-Limit als Verteidigung in der Tiefe,
|
||||
unabhängig vom nginx-Limit. In conftest.py global deaktiviert (sonst würden
|
||||
andere Tests, die login() mehrfach aufrufen, selbst geblockt) - hier gezielt
|
||||
für diesen einen Test wieder aktiviert."""
|
||||
limiter.enabled = True
|
||||
try:
|
||||
for _ in range(5):
|
||||
response = await client.post(
|
||||
"/api/v1/auth/login",
|
||||
data={"username": "mitarbeiter1", "password": "falsch"},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
blockiert = await client.post(
|
||||
"/api/v1/auth/login",
|
||||
data={"username": "mitarbeiter1", "password": "falsch"},
|
||||
)
|
||||
assert blockiert.status_code == 429
|
||||
finally:
|
||||
limiter.enabled = False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_login_success(client, mitarbeiter_user):
|
||||
|
||||
@@ -63,7 +63,19 @@ PGCONF
|
||||
grep -q "conf.d/\*.conf" "${PG_CONF_DIR}/postgresql.conf" || \
|
||||
echo "include_dir = 'conf.d'" >> "${PG_CONF_DIR}/postgresql.conf"
|
||||
|
||||
echo "== nginx Reverse-Proxy Config (inkl. Security-Header) =="
|
||||
echo "== TLS: selbstsigniertes Zertifikat (LAN-Betrieb ohne Domain, Karte owasp-Review) =="
|
||||
mkdir -p /etc/nginx/ssl
|
||||
if [ ! -f /etc/nginx/ssl/mabea.crt ]; then
|
||||
openssl req -x509 -nodes -days 3650 -newkey rsa:2048 \
|
||||
-keyout /etc/nginx/ssl/mabea.key -out /etc/nginx/ssl/mabea.crt \
|
||||
-subj "/CN=${APP_DOMAIN}"
|
||||
chmod 600 /etc/nginx/ssl/mabea.key
|
||||
echo "Selbstsigniertes Zertifikat erzeugt (10 Jahre gültig) - Browser warnen beim ersten Zugriff, bis manuell akzeptiert/importiert."
|
||||
else
|
||||
echo "Zertifikat existiert bereits, überspringe."
|
||||
fi
|
||||
|
||||
echo "== nginx Reverse-Proxy Config (inkl. Security-Header, TLS) =="
|
||||
sed -e "s/__APP_DOMAIN__/${APP_DOMAIN}/g" \
|
||||
"$(dirname "$0")/nginx_mabea.conf.template" > /etc/nginx/sites-available/mabea.conf
|
||||
ln -sf /etc/nginx/sites-available/mabea.conf /etc/nginx/sites-enabled/mabea.conf
|
||||
@@ -94,5 +106,7 @@ echo "Zugangsdaten liegen (chmod 600) in ${CRED_FILE} – NICHT auf stdout ausge
|
||||
echo "DATABASE_URL von dort in backend/.env auf dem Server übernehmen (siehe backend/example.env)."
|
||||
echo "Backend selbst ist noch nicht deployt – siehe deploy/mabea-backend.service.template für den späteren systemd-Dienst."
|
||||
echo
|
||||
echo "Hinweis: nginx läuft aktuell nur über Port 80 (kein TLS). Für echten Betrieb vor Domain-Vergabe"
|
||||
echo "TLS nachziehen (Let's Encrypt bei Domain, sonst selbstsigniertes Zertifikat fürs LAN)."
|
||||
echo "nginx läuft über HTTPS mit selbstsigniertem Zertifikat (Port 80 leitet auf 443 um)."
|
||||
echo "Browser warnen beim ersten Zugriff - Zertifikat manuell akzeptieren/importieren."
|
||||
echo "Sobald eine echte Domain feststeht: ssl_certificate/-key in /etc/nginx/sites-available/mabea.conf"
|
||||
echo "auf ein Let's-Encrypt-Zertifikat umstellen (certbot)."
|
||||
|
||||
@@ -1,18 +1,39 @@
|
||||
# limit_req_zone gehört in den http-Kontext (hier: via sites-enabled-Include-Kette
|
||||
# bereits innerhalb von http{}, aber außerhalb des server{}-Blocks nötig).
|
||||
# Grober Schutz gegen Brute-Force auf Login, ergänzt (nicht ersetzt) die
|
||||
# Rate-Begrenzung/Lockout auf Anwendungsebene, die noch aussteht (Review-Fund).
|
||||
# App-seitige Rate-Begrenzung (slowapi, siehe backend/app/core/rate_limit.py).
|
||||
limit_req_zone $binary_remote_addr zone=mabea_login:10m rate=5r/m;
|
||||
|
||||
# HTTP -> HTTPS Redirect. Selbstsigniertes Zertifikat (Karte owasp-Review: LAN-
|
||||
# Betrieb ohne öffentliche Domain, kein Let's Encrypt möglich) - Browser zeigen
|
||||
# beim ersten Zugriff eine Warnung, bis das Zertifikat manuell akzeptiert wird.
|
||||
server {
|
||||
listen 80;
|
||||
server_name __APP_DOMAIN__;
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
|
||||
# Basis-Hardening-Header (Review-Fund owasp-top10-expert). Sobald TLS aktiv ist,
|
||||
# zusätzlich Strict-Transport-Security ergänzen.
|
||||
server {
|
||||
listen 443 ssl;
|
||||
server_name __APP_DOMAIN__;
|
||||
|
||||
ssl_certificate /etc/nginx/ssl/mabea.crt;
|
||||
ssl_certificate_key /etc/nginx/ssl/mabea.key;
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_ciphers HIGH:!aNULL:!MD5;
|
||||
ssl_prefer_server_ciphers on;
|
||||
|
||||
# Basis-Hardening-Header (Review-Fund owasp-top10-expert).
|
||||
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||||
add_header X-Content-Type-Options nosniff always;
|
||||
add_header X-Frame-Options DENY always;
|
||||
add_header Referrer-Policy strict-origin-when-cross-origin always;
|
||||
# CSP erlaubt nur same-origin (PWA lädt keine externen Skripte/Fonts) plus
|
||||
# Kamera-Zugriff für den Barcode-Scanner (Karte 10). connect-src 'self'
|
||||
# deckt auch API-Aufrufe ab, da Frontend/API dieselbe Origin teilen (siehe
|
||||
# location-Blöcke unten). Owasp-Review-Fund: CSP fehlte komplett.
|
||||
add_header Content-Security-Policy "default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self'; connect-src 'self'; frame-ancestors 'none'" always;
|
||||
add_header Permissions-Policy "camera=(self)" always;
|
||||
|
||||
location /api/v1/auth/login {
|
||||
limit_req zone=mabea_login burst=5 nodelay;
|
||||
|
||||
Reference in New Issue
Block a user