Files
kleiderboerse/app/config.py
T
StefanandClaude Opus 5 99ba74330c Phase 3: Anmeldung, Rate-Limits und Sicherheits-Header
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
2026-08-29 23:00:23 +02:00

75 lines
3.2 KiB
Python

"""Einstellungen, ausschliesslich aus Umgebungsvariablen.
Nichts davon steht im Code oder im Image: Passwort und Pfade kommen beim
Start von aussen. Ohne gesetztes Passwort bleibt der Erfassungsbereich
gesperrt (siehe Phase 3) - ein mitgeliefertes Standardpasswort wäre
öffentlich bekannt und damit keines.
"""
from functools import lru_cache
from pathlib import Path
from pydantic import Field
from pydantic_settings import BaseSettings, SettingsConfigDict
class Einstellungen(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
# Die Namen der Umgebungsvariablen sind ausdrücklich angegeben und
# englisch - sie stehen in docker-compose.yml und in der Anleitung, und
# dort wären deutsche Namen neben ADMIN_PASSWORD & Co. verwirrend.
# Ablageorte. Im Container zeigen beide auf eingehängte Volumes, damit
# Datenbank und Bilder einen Neustart überleben.
datenbank_pfad: Path = Field(default=Path("data/kleiderboerse.sqlite"),
validation_alias="DATABASE_PATH")
upload_pfad: Path = Field(default=Path("uploads"), validation_alias="UPLOAD_PATH")
# Passwort für den Erfassungsbereich. Leer = gesperrt.
admin_passwort: str = Field(default="", validation_alias="ADMIN_PASSWORD")
admin_passwort_hash: str = Field(default="", validation_alias="ADMIN_PASSWORD_HASH")
# Schlüssel, mit dem das Sitzungs-Cookie signiert wird. Ohne festen Wert
# wird beim Start einer erzeugt - dann ist man nach jedem Neustart
# abgemeldet. Im Betrieb also setzen.
secret_key: str = Field(default="", validation_alias="SECRET_KEY")
# Secure-Kennzeichen am Sitzungs-Cookie. Standard an; nur zum Testen
# über http://localhost abschalten, sonst schickt der Browser das
# Cookie gar nicht erst mit.
cookie_nur_https: bool = Field(default=True, validation_alias="COOKIE_HTTPS_ONLY")
# Grösse eines einzelnen Bild-Uploads. Greift zusätzlich zur Prüfung in
# images.py; ein Handyfoto liegt heute bei 3-12 MB.
max_upload_bytes: int = Field(default=10 * 1024 * 1024,
validation_alias="MAX_UPLOAD_BYTES")
# Rate-Limits (pro IP). Reservieren ist ohne Anmeldung möglich, also
# steht hier nichts anderes zwischen einem Skript und dem Bestand.
limit_reservieren: str = Field(default="5/hour", validation_alias="RATE_LIMIT_RESERVE")
limit_anmelden: str = Field(default="10/hour", validation_alias="RATE_LIMIT_LOGIN")
# Nach dieser Zeit ohne Aktivität wird abgemeldet.
sitzung_max_ruhe_sekunden: int = Field(default=7200,
validation_alias="SESSION_IDLE_SECONDS")
@property
def datenbank_url(self) -> str:
return f"sqlite:///{self.datenbank_pfad}"
@property
def admin_aktiv(self) -> bool:
"""Ohne hinterlegtes Passwort bleibt der Erfassungsbereich gesperrt.
Bewusst kein mitgeliefertes Standardpasswort: das stünde in der
Anleitung und wäre damit allgemein bekannt.
"""
return bool(self.admin_passwort_hash or self.admin_passwort)
@lru_cache
def einstellungen() -> Einstellungen:
"""Einmal lesen und merken - die Werte ändern sich zur Laufzeit nicht."""
return Einstellungen()