264 lines
12 KiB
Markdown
264 lines
12 KiB
Markdown
# 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.
|
||
|
||
## 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`.
|