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
+152
View File
@@ -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;
"""
)
+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:
@@ -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),
)
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
+10
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)
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,
+1
View File
@@ -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]
+6
View File
@@ -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
+37
View File
@@ -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):
+17 -3
View File
@@ -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)."
+24 -3
View File
@@ -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;