Files
einkaufsapp/docs/sicherheit.md

279 lines
12 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 `<img src=x onerror=alert(1)>` 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`.