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
This commit is contained in:
2026-08-29 23:00:23 +02:00
co-authored by Claude Opus 5
parent 1d122b33f9
commit 99ba74330c
13 changed files with 713 additions and 41 deletions
+170
View File
@@ -0,0 +1,170 @@
"""Tests zum Zugriffsschutz.
Grundsatz: lesen darf jeder, schreiben nur der Betreiber - mit der einen
Ausnahme des Reservierens.
"""
import io
import pytest
from PIL import Image
from app import security
from app.config import einstellungen
from tests.conftest import TEST_PASSWORT
def bild_bytes() -> bytes:
puffer = io.BytesIO()
Image.new("RGB", (400, 300), (10, 10, 200)).save(puffer, "JPEG")
return puffer.getvalue()
# ------------------------------------------------------------- Anmeldung ---
def test_anmelden_und_abmelden(gast):
assert gast.get("/api/v1/auth/me").json()["angemeldet"] is False
antwort = gast.post("/api/v1/auth/login", json={"passwort": TEST_PASSWORT})
assert antwort.status_code == 200
assert gast.get("/api/v1/auth/me").json()["angemeldet"] is True
gast.post("/api/v1/auth/logout")
assert gast.get("/api/v1/auth/me").json()["angemeldet"] is False
def test_falsches_passwort(gast):
antwort = gast.post("/api/v1/auth/login", json={"passwort": "daneben"})
assert antwort.status_code == 401
assert gast.get("/api/v1/auth/me").json()["angemeldet"] is False
def test_ohne_passwort_ist_der_bereich_gesperrt(gast, monkeypatch, kategorie_id):
"""Ohne hinterlegtes Passwort bleibt gesperrt statt offen zu stehen."""
monkeypatch.delenv("ADMIN_PASSWORD", raising=False)
einstellungen.cache_clear()
security.hash_zwischenspeicher_leeren()
assert gast.post("/api/v1/auth/login", json={"passwort": "x"}).status_code == 503
antwort = gast.post("/api/v1/items", json={"size": "80", "category_id": kategorie_id})
assert antwort.status_code == 503
# Die Galerie funktioniert trotzdem
assert gast.get("/api/v1/items").status_code == 200
# ------------------------------------------------------- Schreibzugriffe ---
@pytest.mark.parametrize(
"methode,pfad,rumpf",
[
("post", "/api/v1/items", {"json": {"size": "80"}}),
("patch", "/api/v1/items/1", {"json": {"size": "92"}}),
("delete", "/api/v1/items/1", {}),
("post", "/api/v1/items/1/mark-given", {}),
("post", "/api/v1/images/1/primary", {}),
("delete", "/api/v1/images/1", {}),
],
)
def test_schreiben_ohne_anmeldung_wird_abgewiesen(gast, methode, pfad, rumpf):
antwort = getattr(gast, methode)(pfad, **rumpf)
assert antwort.status_code == 401, f"{methode.upper()} {pfad} war offen!"
def test_hochladen_ohne_anmeldung_wird_abgewiesen(gast):
antwort = gast.post(
"/api/v1/items/batch-upload",
files={"dateien": ("a.jpg", bild_bytes(), "image/jpeg")},
)
assert antwort.status_code == 401
def test_lesen_ist_offen(gast, client, kategorie_id):
item = client.post(
"/api/v1/items", json={"size": "80", "category_id": kategorie_id}
).json()
client.post("/api/v1/auth/logout")
assert gast.get("/api/v1/items").status_code == 200
assert gast.get(f"/api/v1/items/{item['id']}").status_code == 200
assert gast.get("/api/v1/categories").status_code == 200
assert gast.get("/api/v1/sizes").status_code == 200
def test_reservieren_bleibt_ohne_anmeldung_moeglich(gast, client, kategorie_id):
"""Die bewusste Ausnahme - Nachbarn sollen kein Konto brauchen."""
item = client.post(
"/api/v1/items", json={"size": "80", "category_id": kategorie_id}
).json()
client.post("/api/v1/auth/logout")
antwort = gast.post(
f"/api/v1/items/{item['id']}/reserve", json={"reserved_by": "Familie Meier"}
)
assert antwort.status_code == 200
def test_betreiber_darf_ohne_token_freigeben(client, kategorie_id):
"""Damit eine Missbrauchswelle aufgeräumt werden kann."""
item = client.post(
"/api/v1/items", json={"size": "80", "category_id": kategorie_id}
).json()
client.post(f"/api/v1/items/{item['id']}/reserve", json={"reserved_by": "Wer auch immer"})
antwort = client.post(f"/api/v1/items/{item['id']}/release")
assert antwort.status_code == 200
assert antwort.json()["status"] == "available"
# ------------------------------------------------------------ Rate-Limit ---
def test_reservieren_ist_begrenzt(gast, client, kategorie_id):
"""Bei offener Galerie die einzige Bremse vor dem Bestand.
Geprüft wird der tatsächlich ausgelieferte Standardwert (5/Stunde),
nicht ein für den Test heruntergesetzter: sonst sagte der Test nichts
darüber aus, was im Betrieb gilt. Ein erster Anlauf mit genau fünf
Anfragen gegen ein Limit von fünf konnte gar nichts zeigen.
"""
assert einstellungen().limit_reservieren == "5/hour"
ids = [
client.post("/api/v1/items", json={"size": "80", "category_id": kategorie_id}).json()["id"]
for _ in range(8)
]
client.post("/api/v1/auth/logout")
ergebnisse = [
gast.post(f"/api/v1/items/{i}/reserve", json={"reserved_by": "Skript"}).status_code
for i in ids
]
assert ergebnisse[:5] == [200] * 5, f"unerwartet früh gebremst: {ergebnisse}"
assert ergebnisse[5:] == [429] * 3, f"Rate-Limit griff nicht: {ergebnisse}"
def test_anmelden_ist_begrenzt(gast):
"""Ein Konto, aus dem Internet erreichbar - ohne Bremse liesse sich das
Passwort in Ruhe durchprobieren."""
assert einstellungen().limit_anmelden == "10/hour"
codes = [
gast.post("/api/v1/auth/login", json={"passwort": "falsch"}).status_code
for _ in range(13)
]
assert codes[:10] == [401] * 10, f"unerwartet früh gebremst: {codes}"
assert 429 in codes[10:], f"Passwort liess sich unbegrenzt durchprobieren: {codes}"
# --------------------------------------------------------------- Header ---
def test_sicherheits_header(gast):
kopf = gast.get("/api/v1/items").headers
assert "script-src 'self'" in kopf["content-security-policy"]
assert kopf["x-content-type-options"] == "nosniff"
assert kopf["x-frame-options"] == "DENY"
assert "noindex" in kopf["x-robots-tag"]
def test_robots_txt_sperrt_suchmaschinen(gast):
antwort = gast.get("/robots.txt")
assert antwort.status_code == 200
assert "Disallow: /" in antwort.text