Lesen darf jeder, schreiben nur der Betreiber - mit der bewussten Ausnahme des Reservierens. Dazu Dockerfile und Compose-Datei, damit sich das lokal ausprobieren lässt. Beim Bauen sind zwei Fehler aufgefallen, die ohne Test nicht aufgefallen wären: 1. slowapi zählt pro URL-Pfad. Weil jedes Kleidungsstück eine eigene URL hat, bekam jedes seinen eigenen Zähler - ein Skript hätte also den gesamten Bestand reservieren können, ohne je an ein Limit zu stossen. Genau der Missbrauch, gegen den das Limit gedacht ist. Behoben mit shared_limit und festem scope. 2. Der erste Anlauf des Tests machte fünf Anfragen gegen ein Limit von fünf und konnte damit gar nichts zeigen. Geprüft wird jetzt der tatsächlich ausgelieferte Standardwert, mit mehr Anfragen als erlaubt. Weiter umgesetzt: - Passwort als bcrypt-Hash aus der Umgebung, einmal beim Start gebildet und gemerkt. Bei jeder Anfrage neu gehasht liesse sich die Anwendung sonst ohne Anmeldung lahmlegen - bcrypt ist absichtlich langsam. - Ohne hinterlegtes Passwort bleibt der Erfassungsbereich gesperrt (503) statt offen zu stehen. Kein mitgeliefertes Standardpasswort. - Sitzung als signiertes Cookie, HttpOnly, SameSite=Lax (blockt seitenfremde POSTs), Secure abschaltbar nur fürs lokale Testen, Abmeldung nach zwei Stunden Ruhe. - CSP mit script-src 'self', nosniff, frame-ancestors none, dazu noindex und robots.txt: die Galerie ist frei zugänglich, soll aber nicht dauerhaft im Suchindex stehen. - Der Betreiber darf Reservierungen ohne Token aufheben, damit sich eine Missbrauchswelle aufräumen lässt. - uvicorn mit --proxy-headers: hinter einem Reverse-Proxy zählte sonst alles auf dessen IP, und ein einzelner Besucher sperrte alle aus. - Container läuft nicht als root; Code gehört root, nur Daten und Bilder dem Dienstbenutzer. Umgebungsvariablen heissen jetzt ausdrücklich englisch (ADMIN_PASSWORD, SECRET_KEY, ...), passend zur Anleitung und zur Kantone-App. 59 Tests, alle grün. Zusätzlich gegen den laufenden Container geprüft. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GR4bNaj9GtRu57J4Niii8o
127 lines
4.1 KiB
Python
127 lines
4.1 KiB
Python
"""Einstiegspunkt der Anwendung.
|
|
|
|
FastAPI liefert später sowohl die REST-API als auch die gerenderten Seiten
|
|
aus - ein Dienst, eine Datenbank, kein getrenntes Frontend (Variante A aus
|
|
Plan.md).
|
|
"""
|
|
|
|
import logging
|
|
import secrets
|
|
from contextlib import asynccontextmanager
|
|
|
|
from fastapi import FastAPI, Request
|
|
from fastapi.responses import JSONResponse
|
|
from slowapi.errors import RateLimitExceeded
|
|
from starlette.middleware.sessions import SessionMiddleware
|
|
|
|
from app.config import einstellungen
|
|
from app.database import SessionLocal, engine
|
|
from app.routers import auth, categories, images, items
|
|
from app.security import limiter
|
|
from app.seed import stammdaten_anlegen
|
|
|
|
log = logging.getLogger("kleiderboerse")
|
|
|
|
|
|
@asynccontextmanager
|
|
async def lebenszyklus(_app: FastAPI):
|
|
# Die Tabellen legt Alembic an (alembic upgrade head). Hier werden nur
|
|
# die Stammdaten ergänzt, damit eine frische Datenbank sofort brauchbar
|
|
# ist - Kategorien und Grössen sind Voraussetzung fürs Erfassen.
|
|
cfg = einstellungen()
|
|
cfg.upload_pfad.mkdir(parents=True, exist_ok=True)
|
|
with SessionLocal() as db:
|
|
stammdaten_anlegen(db)
|
|
|
|
if not cfg.admin_aktiv:
|
|
log.warning(
|
|
"Kein ADMIN_PASSWORD gesetzt - der Erfassungsbereich ist gesperrt. "
|
|
"Die Galerie funktioniert."
|
|
)
|
|
yield
|
|
engine.dispose()
|
|
|
|
|
|
app = FastAPI(
|
|
title="Kinderkleider-Börse",
|
|
description="Zu klein gewordene Kinderkleidung katalogisieren und weitergeben.",
|
|
version="0.1.0",
|
|
lifespan=lebenszyklus,
|
|
)
|
|
|
|
_cfg = einstellungen()
|
|
|
|
# Ohne festen Schlüssel wird bei jedem Start ein neuer erzeugt - dann ist
|
|
# man nach einem Neustart abgemeldet. Für den Betrieb SECRET_KEY setzen.
|
|
_schluessel = _cfg.secret_key or secrets.token_urlsafe(48)
|
|
if not _cfg.secret_key:
|
|
log.warning("Kein SECRET_KEY gesetzt - Anmeldungen überleben keinen Neustart.")
|
|
|
|
app.add_middleware(
|
|
SessionMiddleware,
|
|
secret_key=_schluessel,
|
|
session_cookie="kleiderboerse",
|
|
same_site="lax", # blockt Cookies bei seitenfremden POSTs (CSRF-Schutz)
|
|
https_only=_cfg.cookie_nur_https,
|
|
max_age=_cfg.sitzung_max_ruhe_sekunden,
|
|
)
|
|
|
|
app.state.limiter = limiter
|
|
|
|
|
|
@app.exception_handler(RateLimitExceeded)
|
|
async def rate_limit_ueberschritten(_request: Request, _exc: RateLimitExceeded):
|
|
return JSONResponse(
|
|
status_code=429,
|
|
content={"detail": "Zu viele Versuche. Bitte später nochmal probieren."},
|
|
)
|
|
|
|
|
|
@app.middleware("http")
|
|
async def sicherheits_header(request: Request, call_next):
|
|
"""Schutz-Header für jede Antwort.
|
|
|
|
Wichtigster Punkt ist die CSP: sollte trotz Maskierung einmal
|
|
Fremd-Markup in eine Seite geraten, verhindert script-src 'self', dass
|
|
daraus ausführbarer Code wird.
|
|
|
|
'unsafe-inline' bei style-src ist für Tailwind-Utility-Klassen nicht
|
|
nötig, wohl aber für einzelne style-Attribute im Markup; script-src
|
|
bleibt bewusst ohne.
|
|
"""
|
|
antwort = await call_next(request)
|
|
antwort.headers.setdefault(
|
|
"Content-Security-Policy",
|
|
"default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; "
|
|
"img-src 'self' data:; font-src 'self'; connect-src 'self'; "
|
|
"object-src 'none'; base-uri 'none'; form-action 'self'; "
|
|
"frame-ancestors 'none'",
|
|
)
|
|
antwort.headers.setdefault("X-Content-Type-Options", "nosniff")
|
|
antwort.headers.setdefault("Referrer-Policy", "same-origin")
|
|
antwort.headers.setdefault("X-Frame-Options", "DENY")
|
|
# Die Galerie ist frei zugänglich, soll aber nicht im Suchindex landen:
|
|
# sonst sind Fotos und Texte noch auffindbar, wenn die Börse längst
|
|
# abgeräumt ist.
|
|
antwort.headers.setdefault("X-Robots-Tag", "noindex, nofollow")
|
|
return antwort
|
|
|
|
|
|
app.include_router(auth.router)
|
|
app.include_router(categories.router)
|
|
app.include_router(items.router)
|
|
app.include_router(images.router)
|
|
|
|
|
|
@app.get("/robots.txt", include_in_schema=False)
|
|
def robots():
|
|
from fastapi.responses import PlainTextResponse
|
|
|
|
return PlainTextResponse("User-agent: *\nDisallow: /\n")
|
|
|
|
|
|
@app.get("/health", tags=["Betrieb"])
|
|
def health():
|
|
"""Wird vom Docker-Healthcheck und von der CI benutzt."""
|
|
return {"status": "ok"}
|