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:
+62
-15
@@ -5,14 +5,47 @@ Verzeichnis - keine geteilten Zustände zwischen Tests, und die echte
|
||||
Datenbank unter data/ wird nie angefasst.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
import os
|
||||
|
||||
from app.config import einstellungen
|
||||
from app.database import Basis, engine_erzeugen, get_db
|
||||
from app.main import app
|
||||
from app.seed import stammdaten_anlegen
|
||||
TEST_PASSWORT = "test-passwort-123"
|
||||
|
||||
# MUSS vor dem Import von app.main stehen: dort wird die SessionMiddleware
|
||||
# beim Import aufgebaut und liest cookie_nur_https genau einmal. Käme das
|
||||
# erst als Fixture, bliebe das Cookie auf "Secure" - und der TestClient
|
||||
# spricht über http://testserver, verwürfe es also stillschweigend. Die
|
||||
# Anmeldung schlüge dann ohne erkennbaren Grund fehl.
|
||||
os.environ.setdefault("COOKIE_HTTPS_ONLY", "false")
|
||||
os.environ.setdefault("ADMIN_PASSWORD", TEST_PASSWORT)
|
||||
os.environ.setdefault("SECRET_KEY", "nur-fuer-tests")
|
||||
|
||||
import pytest # noqa: E402
|
||||
from fastapi.testclient import TestClient # noqa: E402
|
||||
from sqlalchemy.orm import sessionmaker # noqa: E402
|
||||
|
||||
from app import security # noqa: E402
|
||||
from app.config import einstellungen # noqa: E402
|
||||
from app.database import Basis, engine_erzeugen, get_db # noqa: E402
|
||||
from app.main import app # noqa: E402
|
||||
from app.seed import stammdaten_anlegen # noqa: E402
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def admin_eingerichtet(monkeypatch):
|
||||
"""Setzt ein Passwort für die Tests und leert die Zwischenspeicher.
|
||||
|
||||
autouse, weil sonst jeder Schreibzugriff mit 503 ("nicht eingerichtet")
|
||||
scheitern würde - das ist das gewollte Verhalten ohne Passwort.
|
||||
"""
|
||||
monkeypatch.setenv("ADMIN_PASSWORD", TEST_PASSWORT)
|
||||
monkeypatch.setenv("COOKIE_HTTPS_ONLY", "false")
|
||||
einstellungen.cache_clear()
|
||||
security.hash_zwischenspeicher_leeren()
|
||||
# Rate-Limits zwischen Tests zurücksetzen, sonst schlägt der zehnte
|
||||
# Anmeldeversuch über alle Tests hinweg zu.
|
||||
security.limiter.reset()
|
||||
yield
|
||||
einstellungen.cache_clear()
|
||||
security.hash_zwischenspeicher_leeren()
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
@@ -25,7 +58,7 @@ def upload_ordner(tmp_path, monkeypatch):
|
||||
"""
|
||||
ziel = tmp_path / "uploads"
|
||||
ziel.mkdir()
|
||||
monkeypatch.setenv("UPLOAD_PFAD", str(ziel))
|
||||
monkeypatch.setenv("UPLOAD_PATH", str(ziel))
|
||||
einstellungen.cache_clear()
|
||||
yield ziel
|
||||
einstellungen.cache_clear()
|
||||
@@ -43,20 +76,34 @@ def db_sitzung(tmp_path):
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(db_sitzung):
|
||||
"""TestClient, der auf dieselbe Testdatenbank zeigt wie db_sitzung."""
|
||||
def gast(db_sitzung):
|
||||
"""Nicht angemeldeter Besucher - sieht die Galerie, darf reservieren.
|
||||
|
||||
Bewusst ohne "with": nur dann startet Starlette den Lifespan, und der
|
||||
würde die echte Datenbank unter data/ anlegen und befüllen. Die
|
||||
Stammdaten stehen in der Testdatenbank ohnehin schon (db_sitzung).
|
||||
"""
|
||||
|
||||
def _db():
|
||||
yield db_sitzung
|
||||
|
||||
app.dependency_overrides[get_db] = _db
|
||||
# Bewusst ohne "with": nur dann startet Starlette den Lifespan, und der
|
||||
# würde die echte Datenbank unter data/ anlegen und befüllen. Die
|
||||
# Stammdaten stehen in der Testdatenbank ohnehin schon (db_sitzung).
|
||||
yield TestClient(app)
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def kategorie_id(client):
|
||||
return client.get("/api/v1/categories").json()[0]["id"]
|
||||
def client(gast):
|
||||
"""Angemeldeter Betreiber - darf alles.
|
||||
|
||||
Die meisten Tests brauchen das, weil Anlegen und Ändern seit Phase 3
|
||||
eine Anmeldung voraussetzen.
|
||||
"""
|
||||
antwort = gast.post("/api/v1/auth/login", json={"passwort": TEST_PASSWORT})
|
||||
assert antwort.status_code == 200, antwort.text
|
||||
return gast
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def kategorie_id(gast):
|
||||
return gast.get("/api/v1/categories").json()[0]["id"]
|
||||
|
||||
@@ -43,21 +43,26 @@ def test_freigeben_mit_token(client, item_id):
|
||||
assert antwort.json()["reserved_by"] is None
|
||||
|
||||
|
||||
def test_freigeben_ohne_token_wird_abgewiesen(client, item_id):
|
||||
def test_freigeben_ohne_token_wird_abgewiesen(client, gast, item_id):
|
||||
"""Der Kern der Entscheidung: sonst löscht jeder Besucher fremde
|
||||
Reservierungen."""
|
||||
client.post(f"/api/v1/items/{item_id}/reserve", json={"reserved_by": "Familie Meier"})
|
||||
Reservierungen.
|
||||
|
||||
assert client.post(f"/api/v1/items/{item_id}/release").status_code == 403
|
||||
assert client.post(f"/api/v1/items/{item_id}/release?token=falsch").status_code == 403
|
||||
Bewusst als Gast geprüft - der angemeldete Betreiber darf das sehr wohl
|
||||
ohne Token, damit er eine Missbrauchswelle aufräumen kann.
|
||||
"""
|
||||
client.post(f"/api/v1/items/{item_id}/reserve", json={"reserved_by": "Familie Meier"})
|
||||
client.post("/api/v1/auth/logout")
|
||||
|
||||
assert gast.post(f"/api/v1/items/{item_id}/release").status_code == 403
|
||||
assert gast.post(f"/api/v1/items/{item_id}/release?token=falsch").status_code == 403
|
||||
|
||||
# Reservierung steht unverändert
|
||||
item = client.get(f"/api/v1/items/{item_id}").json()
|
||||
item = gast.get(f"/api/v1/items/{item_id}").json()
|
||||
assert item["status"] == "reserved"
|
||||
assert item["reserved_by"] == "Familie Meier"
|
||||
|
||||
|
||||
def test_token_gilt_nach_dem_freigeben_nicht_mehr(client, item_id):
|
||||
def test_token_gilt_nach_dem_freigeben_nicht_mehr(client, gast, item_id):
|
||||
"""Sonst könnte ein alter Link später die Reservierung einer anderen
|
||||
Person aufheben."""
|
||||
token = client.post(
|
||||
@@ -66,7 +71,8 @@ def test_token_gilt_nach_dem_freigeben_nicht_mehr(client, item_id):
|
||||
client.post(f"/api/v1/items/{item_id}/release?token={token}")
|
||||
|
||||
client.post(f"/api/v1/items/{item_id}/reserve", json={"reserved_by": "Zweite"})
|
||||
assert client.post(f"/api/v1/items/{item_id}/release?token={token}").status_code == 403
|
||||
client.post("/api/v1/auth/logout")
|
||||
assert gast.post(f"/api/v1/items/{item_id}/release?token={token}").status_code == 403
|
||||
|
||||
|
||||
def test_abgegeben_loescht_den_namen(client, item_id):
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user