# Sicherheit Stand der Prüfung: nach Phase 9, Revision `0011`, 75 Endpunkte. ## Prüfungen, die sich wiederholen lassen ```bash python3 tools/check-routes.py # Endpunkte ohne Berechtigungsprüfung python3 tools/check-schema.py # schema.sql gegen die Modelle ./tools/check-env.sh .env # doppelte oder fehlerhafte Einträge node tools/check-js.mjs # undefinierte Bezeichner python3 tools/check-nginx.py web # Kopfzeilen, doppelte Direktiven node tools/test-barcode.mjs # Strichcode-Decoder cd backend && python3 -m compileall -q app alembic ``` ## SQL-Injection **Nicht möglich, soweit prüfbar.** Alle Abfragen laufen über SQLAlchemy-Ausdrücke; Werte werden als gebundene Parameter übergeben, nie in SQL-Text eingesetzt. Zwei Stellen verdienen eine Erklärung: **Rohes SQL** gibt es an genau zwei Stellen, beide ohne Nutzereingaben: ```python db.execute(text("SELECT 1")) # Readiness db.execute(text("DELETE FROM user_session WHERE expires_at <= :now"), {"now": utcnow()}) # gebunden ``` **Der einzige f-String in einer Abfrage** steht in der Artikelsuche: ```python needle = q.strip().replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") stmt = stmt.where(Article.name.like(f"%{needle}%", escape="\\")) ``` Der f-String baut den *Suchwert*, nicht das SQL – `.like()` bindet ihn als Parameter. Die Maskierung ist trotzdem nötig, sonst wäre die Eingabe `%` ein Platzhalter und `_` ein Jokerzeichen. Kein Sicherheitsproblem, aber ein Verhaltensproblem: Ohne sie fände die Suche nach „50%" den gesamten Bestand. **Keine dynamischen Sortier- oder Filterspalten.** Alle `order_by`-Ausdrücke verweisen auf feste Modellattribute. Ein `?sort=` mit Spaltennamen aus der Anfrage gibt es nirgends – das ist der übliche Weg, wie SQL-Injection trotz ORM zurückkommt. ## Cross-Site-Scripting **Im Browser:** Die Oberfläche setzt Text ausschließlich über `textContent` (Funktion `el()` in `dom.js`). Es gibt kein `innerHTML`, kein `insertAdjacentHTML`, kein `document.write`. ```bash grep -rn "innerHTML\|outerHTML\|insertAdjacentHTML\|document.write" web/html/js/ ``` **Serverseitig gerenderte Druckansicht:** Jinja2 mit `autoescape`. Geprüft mit eingeschleusten Artikelnamen wie `` – sie erscheinen als Text, nicht als Markup. **Content-Security-Policy:** `default-src 'self'` ohne `unsafe-inline` und ohne `unsafe-eval`. Das war der ausschlaggebende Grund gegen einen Browser-Babel und für ES-Module ohne Bundler. Die Kopfzeilen liegen in `web/security_headers.conf` und werden in **jede** `location` eingebunden, die eigene `add_header` setzt. Das ist keine Doppelmoppelei: nginx vererbt `add_header` nur, wenn die untergeordnete Ebene gar keines setzt – ein einzelnes `Cache-Control` in einer `location` lässt sonst alle Kopfzeilen des `server`-Blocks verschwinden, ohne Fehlermeldung. `tools/check-nginx.py` prüft das. ## Sitzungen und CSRF - Sitzungstoken: 32 Byte aus `secrets.token_urlsafe`, in der Datenbank nur als SHA-256-Hash. Der Klartext steht ausschließlich im `HttpOnly`-Cookie. - CSRF: Double-Submit. Ein zweites, lesbares Cookie muss bei jeder schreibenden Anfrage im Header `X-CSRF-Token` wiederkommen. Zusätzlich `SameSite=Lax`. - **Kein `SECRET_KEY`.** Er war bis zu dieser Prüfung als Pflichtfeld konfiguriert und wurde nirgends verwendet – Sitzungen und CSRF arbeiten mit serverseitig gespeicherten Zufallswerten, nicht mit signierten. Entfernt: Eine Einstellung, die Wichtigkeit vortäuscht, ist schlechter als keine. - Passwortwechsel verwirft alle bestehenden Sitzungen und stellt eine neue aus. ## Berechtigungen `tools/check-routes.py` listet alle Endpunkte mit ihren Berechtigungsabhängigkeiten. Aktuell: 75 Endpunkte, keiner ohne Schutz. Rollen: `viewer` liest, `editor` ändert, `owner` verwaltet Mitgliedschaften. Dazu das unabhängige Zusatzrecht `may_share_public`. **Wer kein Mitglied ist, bekommt `404` statt `403`.** Sonst ließe sich über die Statuscodes herausfinden, welche Listen-IDs existieren. **Objekte werden gegen die Liste geprüft.** `_check_belongs()` in `catalog.py` stellt sicher, dass ein übergebener Markt, eine Warengruppe oder ein Artikel wirklich zu der Liste gehört, um die es geht. Ohne das könnte ein Mitglied durch Angabe einer fremden ID Rückschlüsse auf andere Listen ziehen. Zwei Fälle prüfen im Rumpf statt über eine Abhängigkeit und sind deshalb im Skript als Ausnahme vermerkt: - `DELETE /api/lists/{id}/members/{uid}` – der Eigentümer darf andere entfernen, jedes Mitglied sich selbst - `/api/public/…` – geschützt durch den Token im Pfad ## Token in Adressen Öffentliche Links, Einladungen und Passwort-Zurücksetzen tragen ihren Token in der URL. Das ist üblich und unvermeidbar, hat aber eine Nebenwirkung, die leicht übersehen wird: **nginx schreibt die vollständige URL ins Zugriffsprotokoll.** Ein Protokoll wandert in Sicherungen, wird an Auswertungswerkzeuge gereicht und lebt länger als der Link. Wer Leserecht darauf hat, käme an alle geteilten Listen. Deshalb ist `access_log off` gesetzt für: ``` /s/ öffentliche Listenlinks /invite, /reset Links aus Einladungs- und Reset-Mails /api/public/, /api/invites/ /api/auth/verify ``` `Referrer-Policy: no-referrer` verhindert zusätzlich, dass der Token beim Weiterklicken an fremde Seiten gelangt. ## Passwörter - Argon2id (`argon2-cffi`), Standardparameter der Bibliothek - Mindestens 12 Zeichen, keine Zeichenklassenpflicht – Länge schlägt Sonderzeichen - Beim Anmelden läuft die Prüfung auch bei unbekanntem Konto gegen einen Dummy-Hash, damit die Antwortzeit keinen Rückschluss zulässt - `check_needs_rehash` bei jeder Anmeldung: Werden die Parameter später verschärft, wandern bestehende Konten von selbst mit ## Kontenaufzählung Registrierung und Passwort-Zurücksetzen antworten immer gleich, unabhängig davon, ob die Adresse existiert. Der Preis ist Diagnosekomfort: „Ich habe mich registriert und bekomme keine Mail" kann auch heißen, dass das Konto längst da ist. Beim Einladen gibt es bewusst eine Ausnahme (`409` bei bereits vorhandenem Zugriff) – das darf nur der Eigentümer, und der sieht die Mitgliederliste ohnehin. ## Missbrauchsbremsen | Wo | Grenze | Warum | |---|---|---| | Anmeldung | 20/15 min je IP **und** 8/15 min je Konto | Nur je IP hilft nicht gegen verteilte Angriffe; nur je Konto macht das Aussperren fremder Nutzer trivial | | Registrierung, Reset | 5/Stunde je IP | – | | Einladungen | 20/Stunde je Liste, 30/Stunde je IP | Jede Einladung erzeugt eine Mail an eine frei wählbare Adresse – sonst wäre die App eine Versandhilfe für unerwünschte Nachrichten | | Öffentliche Links | 120/15 min je IP | Begrenzt die Last, nicht das Raten (256 Bit sind nicht ratbar) | | nginx | 10/min auf `/api/auth/` | Zweite Verteidigungslinie | Zusätzlich: höchstens 100 Listen je Konto, 50 offene Einladungen je Liste, 10 aktive öffentliche Links je Liste, 20 Push-Geräte je Konto, 200 Operationen je Stapel. ## Datensparsamkeit - **Preisdaten ohne Nutzerbezug.** `price_point` führt Markt, Artikel, Betrag und Zeitpunkt – kein `user_id`. Aus „wer hat wann wo was gekauft" ließe sich ein Bewegungs- und Konsumprofil bilden. - **Öffentliche Ansicht anonymisiert serverseitig.** Wer welchen Artikel eingetragen hat, wird nicht ausgeliefert – nicht erst im Browser ausgeblendet. - **Push-Meldungen ohne Inhalt.** Listenname und wer geändert hat, keine Artikelnamen. Eine Benachrichtigung erscheint auf dem gesperrten Bildschirm. - **Zugriffszähler ohne Personenbezug.** Bei öffentlichen Links sieht der Eigentümer, *dass* der Link benutzt wird, nicht von wem. - **Produktabfragen über den Server.** Open Food Facts erfährt die IP-Adressen der Nutzer nicht. - **Nur die E-Mail-Adresse ist Pflichtangabe.** Anzeigename freiwillig, auch als Pseudonym. - Zwischen Mitgliedern wird der Anzeigename ausgeliefert, nicht die vollständige E-Mail-Adresse. ## Benutzerverwaltung **Administratorkonten sind vor Deaktivierung und Löschung geschützt**, auch gegenüber anderen Administratoren und dem eigenen Konto. Die Verwaltung soll sich nicht aussperren können – weder versehentlich noch durch jemanden, der kurzzeitig Zugriff erlangt hat. **Adressänderung bei Administratorkonten verlangt beide Bestätigungen.** Ohne diese Regel wäre der Ablauf ein Übernahmewerkzeug: Wer kurz Zugriff auf ein Administratorkonto hat, stellt die Adresse um und hat es dauerhaft – der rechtmäßige Inhaber kommt über „Passwort vergessen" nicht mehr hinein. Mit der Zustimmung von der alten Adresse geht das nur, wenn auch das Postfach übernommen wurde. **Kein vom Administrator vergebenes Passwort.** Neue Konten haben zunächst keinen gültigen Passwort-Hash; die Person setzt ihn über den Link aus der Willkommensnachricht. Ein vergebenes Passwort wäre dem Administrator bekannt und ginge im Klartext per Mail. **Willkommensnachricht nur vor der Einrichtung erneut versendbar.** Sonst hätte ein Administrator einen stillen Weg, sich Zugang zu fremden Konten zu verschaffen: Link anfordern, Postfach ist unbeteiligt, Passwort neu setzen. **Deaktivierungsmeldung erst nach erfolgreicher Passwortprüfung.** Vorher wäre sie ein Hinweis darauf, dass das Konto existiert. **Löschen verlangt die Eingabe der Adresse** als Bestätigung, und regelt die Listen des Kontos, statt sie mitzureißen. ## Datensparsamkeit im Betrieb **Keine Fremdressourcen.** Keine externen Schriften, Skripte, Karten oder Symbole. Verwendet wird die Systemschrift des Geräts. Die CSP führt `font-src 'self'` ausdrücklich auf. **Zugriffsprotokolle mit gekürzter IP-Adresse**, ohne `Referer` (dort stünde bei öffentlichen Listenlinks der Zugangsschlüssel). **Missbrauchsbremse ohne Klartextadressen.** Bis zur Sicherheitsprüfung lagen IP- und E-Mail-Adressen im Klartext in `rate_limit.bucket`. Jetzt ein Streuwert mit installationsweitem Zufallszusatz – Pseudonymisierung, keine Anonymisierung, aber die Adressen sind beim Blick in die Tabelle nicht mehr mitzulesen. Aufbewahrung ohnehin höchstens 24 Stunden. ## Bekannte Grenzen Ehrlichkeitshalber, keine dieser Punkte ist ein Fehler – aber jeder ist eine bewusste Abwägung, die man kennen sollte. **E-Mail-Adressen liegen im Klartext.** Verschlüsselung brächte wenig: Die Anwendung muss Mails versenden, der Schlüssel läge also neben der Datenbank. Der Schutz griffe nur gegen ein gestohlenes Backup ohne Serverzugriff. **Ein öffentlicher Link ist die Berechtigung.** Wer ihn weitergibt, gibt den Zugriff weiter. Abgefedert durch Pflicht-Ablaufdatum (höchstens 365 Tage), jederzeitigen Widerruf und Speicherung nur als Hash. **Preise sind nachträglich nicht zuordenbar.** Folge des fehlenden `user_id` – wer einen falschen Preis eingetragen hat, lässt sich nicht ermitteln. **Administratoren sehen alle E-Mail-Adressen.** Für die Verwaltung unumgänglich. Es gibt kein Protokoll darüber, wer wann in die Benutzerliste gesehen hat – bei der vorgesehenen Zahl von Administratoren wäre das mehr Aufwand als Nutzen. **Administratorrechte lassen sich nur in der Datenbank vergeben und entziehen.** Bewusst: Ein Endpunkt dafür wäre der direkteste Weg zur Rechteausweitung, wenn irgendwo anders ein Fehler steckt. Der Preis ist ein umständlicher Vorgang für einen seltenen Fall. **Kein Zwei-Faktor-Verfahren.** Für den vorgesehenen Nutzerkreis vertretbar; bei mehr Konten wäre TOTP der nächste Schritt. **Keine Ende-zu-Ende-Verschlüsselung der Listeninhalte.** Wer Zugriff auf die Datenbank hat, liest die Listen. Ende-zu-Ende würde die serverseitige Gruppierung, den Ausdruck und die öffentlichen Links unmöglich machen. **Der `api`-Container bindet `./backend` als Volume ein.** Praktisch beim Entwickeln, aber im Dauerbetrieb läuft damit Code vom Host statt aus dem geprüften Abbild. Für den Produktivbetrieb: Zeile entfernen und `--reload` aus `entrypoint.sh` nehmen. **`FORWARDED_ALLOW_IPS=*`** ist vertretbar, solange der `api`-Container keinen veröffentlichten Port hat – dann kann nur der `web`-Container den Header setzen. Sobald du den API-Port zum Debuggen freigibst, gehört dort die konkrete IP hinein, sonst lässt sich das Rate Limiting durch einen gefälschten `X-Forwarded-For` umgehen. **Sicherheitskopfzeilen kommen doppelt** (Container und OPNsense). Unschädlich, solange die OPNsense **keine eigene CSP** setzt: Browser wenden dann beide an und erlauben nur die Schnittmenge – die Anwendung würde stillschweigend aufhören zu funktionieren. Siehe `docs/reverse-proxy.md`.