Erste Produktivversion

This commit is contained in:
2026-08-08 20:31:58 +02:00
parent d8ded2816d
commit 7b21e2d1b4
110 changed files with 18170 additions and 644 deletions

174
docs/betrieb.md Normal file
View File

@@ -0,0 +1,174 @@
# Betrieb: Sicherung, Wiederherstellung, Aufräumen
## Was gesichert werden muss
| Was | Wo | Ohne das … |
|---|---|---|
| Datenbank | Volume `einkaufsapp_db_data` | ist alles weg |
| `.env` | Projektverzeichnis | Sessions ungültig, Push-Anmeldungen tot, DB-Zugang verloren |
| Projektverzeichnis | Quelltext | neu aus dem Archiv holen kein Datenverlust |
Der Quelltext gehört ins Versionsverwaltungssystem, die `.env` ausdrücklich
**nicht**: Sie enthält Datenbankpasswort, `SECRET_KEY`, SMTP-Zugangsdaten und
den privaten VAPID-Schlüssel. Sie gehört in den Passwortmanager oder in eine
verschlüsselte Sicherung.
Warum die `.env` so wichtig ist: Ein verlorener `SECRET_KEY` ist verschmerzbar
(alle müssen sich neu anmelden), ein verlorener `VAPID_PRIVATE_KEY` auch (alle
müssen Push neu einschalten). Ein verlorenes `DB_PASSWORD` bei erhaltener
Datenbank ist dagegen ärgerlich dann kommt man an die eigenen Daten nur noch
über das Root-Passwort heran.
## Datenbanksicherung
```bash
cd /opt/einkaufsapp
# Konsistenter Auszug ohne Anhalten der Anwendung
docker compose exec -T db mariadb-dump \
-u root -p"$(grep '^DB_ROOT_PASSWORD=' .env | cut -d= -f2-)" \
--single-transaction --quick --routines --events \
--default-character-set=utf8mb4 \
einkaufsapp | gzip > "einkaufsapp-$(date +%F).sql.gz"
```
`--single-transaction` ist der entscheidende Schalter: Er nimmt einen
konsistenten Stand aus einem Zeitpunkt, ohne die Tabellen zu sperren. Ohne ihn
kann eine Sicherung mitten in einer Änderung entstehen zum Beispiel mit einem
Listeneintrag, dessen Artikel noch fehlt.
`--quick` verhindert, dass große Tabellen komplett in den Arbeitsspeicher
geladen werden.
### Als tägliche Aufgabe
```bash
sudo tee /etc/cron.daily/einkaufsapp-backup >/dev/null <<'EOF'
#!/bin/sh
set -eu
cd /opt/einkaufsapp
PASS=$(grep '^DB_ROOT_PASSWORD=' .env | cut -d= -f2-)
DEST=/var/backups/einkaufsapp
mkdir -p "$DEST"
docker compose exec -T db mariadb-dump -u root -p"$PASS" \
--single-transaction --quick --routines --events \
--default-character-set=utf8mb4 einkaufsapp \
| gzip > "$DEST/db-$(date +%F).sql.gz"
# Vierzehn Tage aufbewahren
find "$DEST" -name 'db-*.sql.gz' -mtime +14 -delete
EOF
sudo chmod +x /etc/cron.daily/einkaufsapp-backup
```
Der Auszug landet damit im Dateisystem und wird von deiner vorhandenen
Borgmatic-Sicherung mit erfasst. Das ist der bessere Weg, als das Volume direkt
zu sichern: Ein Dateisystemabbild einer laufenden Datenbank ist nicht
zuverlässig wiederherstellbar.
**Prüfen, dass die Sicherung etwas taugt:**
```bash
zcat /var/backups/einkaufsapp/db-$(date +%F).sql.gz | tail -5
```
Am Ende muss `-- Dump completed on …` stehen. Fehlt die Zeile, ist der Auszug
abgebrochen eine abgeschnittene Sicherung sieht sonst genauso aus wie eine
vollständige.
## Wiederherstellung
```bash
cd /opt/einkaufsapp
docker compose down
docker volume rm einkaufsapp_db_data # ACHTUNG: löscht den aktuellen Stand
docker compose up -d db
sleep 20 # MariaDB initialisiert sich
zcat einkaufsapp-2026-08-08.sql.gz | docker compose exec -T db \
mariadb -u root -p"$(grep '^DB_ROOT_PASSWORD=' .env | cut -d= -f2-)" einkaufsapp
docker compose up -d
docker compose logs --tail=20 api
```
Alembic bringt das Schema beim Start auf den neuesten Stand, falls die
Sicherung aus einer älteren Fassung stammt. Der umgekehrte Fall neuere
Sicherung, ältere Anwendung geht nicht; dann erst den Quelltext aktualisieren.
**Einmal im Jahr ausprobieren.** Eine Sicherung, die nie zurückgespielt wurde,
ist eine Vermutung, keine Sicherung.
## Aufräumen
Läuft von selbst: einmal 30 Sekunden nach dem Start des `api`-Containers,
danach alle `CLEANUP_INTERVAL_HOURS` (Voreinstellung 24).
| Was | Frist | Einstellung |
|---|---|---|
| Weich gelöschte Listen, Einträge, Artikel, Märkte, Warengruppen | 30 Tage | `CLEANUP_DELETED_DAYS` |
| Outbox-Quittungen | 7 Tage | `CLEANUP_OPS_DAYS` |
| Abgelaufene Sitzungen | sofort | |
| Rate-Limit-Zähler | 1 Tag | |
| Verbrauchte Mail-Token | 7 Tage nach Ablauf | |
| Abgelaufene Einladungen und öffentliche Links | 30 Tage nach Ablauf | `CLEANUP_DELETED_DAYS` |
| Produktzwischenspeicher | mindestens 1 Jahr | |
Manuell auslösen (als Administrator angemeldet):
```bash
curl -sS -b cookies.txt -H "X-CSRF-Token: $CSRF" \
-X POST https://einkauf.example.de/api/admin/cleanup
```
Im Log erscheint nach jedem Durchlauf eine Zeile wie
`Aufräumen: 12 gelöschte Einträge, 340 Outbox-Quittungen`.
**Warum die 30 Tage nicht kürzer sein sollten:** So lange kann ein Gerät
offline bleiben und beim nächsten Abgleich noch erfahren, dass eine Liste
gelöscht wurde. Wird früher aufgeräumt, taucht die Liste auf dem Gerät weiter
auf, bis jemand sie von Hand entfernt.
## Aktualisieren
```bash
cd /opt/einkaufsapp
# Sicherung ZUERST - Migrationen lassen sich nicht immer zurücknehmen
/etc/cron.daily/einkaufsapp-backup
# Neuen Stand einspielen, dann:
./tools/check-env.sh .env
node tools/check-js.mjs
python3 tools/check-nginx.py web
docker compose up -d --build
docker compose logs --tail=30 api # Migrationen beobachten
```
Nach Änderungen an den Dateien unter `web/html/js/` muss `VERSION` in
`web/html/sw.js` hochgezählt werden sonst behalten bereits installierte
Clients die alte Fassung.
**`docker compose up -d`, nicht `restart`:** Ein Neustart übernimmt keine
geänderten Werte aus der `.env`. Das hat in diesem Projekt schon mehrfach für
Verwirrung gesorgt.
## Speicherplatz im Blick behalten
```bash
docker compose exec db mariadb -u root -p einkaufsapp -e "
SELECT table_name AS Tabelle,
ROUND(data_length/1024/1024, 1) AS 'Daten MB',
ROUND(index_length/1024/1024, 1) AS 'Index MB',
table_rows AS 'Zeilen (geschätzt)'
FROM information_schema.tables
WHERE table_schema = 'einkaufsapp'
ORDER BY data_length DESC;"
```
Erwartungsgemäß wachsen `price_point` (jeder erfasste Preis) und
`product_cache` (jeder gescannte Strichcode) am stärksten. Beide sind gewollt:
Die Preisdatenbank ist der Zweck, und der Zwischenspeicher verhindert, dass
jeder Scan nach draußen geht.
Wächst `applied_op` unerwartet, liegt ein Gerät mit einer festhängenden Outbox
vor dann lohnt ein Blick ins Log des `api`-Containers.

115
docs/mail-zustellbarkeit.md Normal file
View File

@@ -0,0 +1,115 @@
# Zustellbarkeit ausgehender Mails
Die Anwendung setzt alle Kopfzeilen korrekt, aber ob eine Mail im Posteingang
oder im Spam landet, entscheidet sich überwiegend im DNS und beim Relay nicht
im Anwendungscode. Diese Notiz trennt beides sauber.
## Was die Anwendung erledigt
| Kopfzeile | Warum |
|---|---|
| `Date` | Fehlt sie, werten praktisch alle Filter ab |
| `Message-ID` | Mit der Domain aus `SMTP_ENVELOPE_FROM`, damit sie zur DKIM-Signatur passt |
| `From` mit Anzeigename | RFC-konform kodiert über `email.headerregistry` |
| `Reply-To` | Optional, wenn Antworten woanders hin sollen |
| `Auto-Submitted: auto-generated` | RFC 3834 unterdrückt Abwesenheitsschleifen |
| `X-Auto-Response-Suppress: All` | Dasselbe für Exchange/Outlook |
| EHLO-Hostname | Aus `SMTP_HELO_HOSTNAME` statt der Container-ID |
| Envelope-From getrennt vom `From` | SPF wird gegen den Envelope geprüft |
Der Versand wiederholt sich bei temporären Fehlern mit wachsendem Abstand
(2 s, 4 s, 8 s). Bei permanenter Ablehnung Empfänger unbekannt, Absender
abgelehnt, Authentifizierung fehlgeschlagen bricht er sofort ab, statt das
Relay weiter zu belasten.
## Was du im DNS einrichten musst
Ohne diese drei Einträge landen Mails auch bei perfektem Code im Spam. Alle
beziehen sich auf die Domain aus `SMTP_ENVELOPE_FROM`.
**SPF** erlaubt deinem Relay, für die Domain zu senden:
```
example.de. IN TXT "v=spf1 mx a:mail.example.de -all"
```
`-all` (hard fail) ist strenger als `~all` und wird von Empfängern besser
bewertet. Setze es erst, wenn du sicher bist, dass alle legitimen Absender
erfasst sind.
**DKIM** signiert ausgehende Mails. Die Signatur erzeugt das Relay, nicht
diese Anwendung. In Postfix über OpenDKIM oder rspamd, in mailcow ist es
eingebaut. Der öffentliche Schlüssel gehört ins DNS:
```
selector._domainkey.example.de. IN TXT "v=DKIM1; k=rsa; p=MIIBIjANBg..."
```
Wichtig: Die signierende Domain (`d=` in der Signatur) muss zur Domain im
`From`-Header passen, sonst schlägt die DMARC-Ausrichtung fehl.
**DMARC** sagt Empfängern, was bei Fehlschlägen passieren soll:
```
_dmarc.example.de. IN TXT "v=DMARC1; p=quarantine; rua=mailto:dmarc@example.de; adkim=s; aspf=s"
```
Fang mit `p=none` an und wertet die Reports ein paar Wochen aus, bevor du auf
`quarantine` oder `reject` gehst.
**PTR (Reverse DNS)** die IP deines Relays muss auf einen Namen auflösen, der
vorwärts wieder auf dieselbe IP zeigt. Bei Hetzner setzt du das im
Cloud-Console-Interface bzw. im Robot. Fehlt der PTR, weisen einige große
Anbieter direkt beim `MAIL FROM` ab.
## Ausrichtung von From und Envelope-From
DMARC verlangt, dass mindestens eine der beiden Prüfungen *ausgerichtet* ist:
- **SPF-Ausrichtung:** Domain in `SMTP_ENVELOPE_FROM` = Domain in `SMTP_FROM`
- **DKIM-Ausrichtung:** signierende Domain = Domain in `SMTP_FROM`
Mit `adkim=s; aspf=s` im DMARC-Record verlangst du exakte Übereinstimmung
(nicht nur die Organisationsdomain). Das ist strenger und sicherer, bedeutet
aber: `bounces@example.de` und `einkaufsapp@example.de` sind ausgerichtet,
`bounces@bounce.example.de` wäre es nicht mehr.
## Testen
Vor dem ersten echten Versand:
```bash
# Verbindung zum Relay prüfen, ohne etwas zu senden
curl -s -b cookies.txt localhost:8000/api/admin/mail/check | jq
# Testnachricht auslösen
CSRF=$(grep ea_csrf cookies.txt | awk '{print $7}')
curl -s -b cookies.txt -H "X-CSRF-Token: $CSRF" \
-X POST localhost:8000/api/admin/mail/test \
-H 'Content-Type: application/json' \
-d '{"to":"dein-postfach@example.de"}'
docker compose logs --tail=20 api
```
Für eine unabhängige Bewertung eignet sich ein Dienst, der dir eine
Wegwerf-Adresse gibt und die eingehende Mail auf SPF, DKIM, DMARC, PTR und
Inhaltsmerkmale prüft etwa `mail-tester.com`. Alles unterhalb von 8/10 lohnt
das Nachbessern.
Achte in der Auswertung besonders auf:
- `SPF: pass` mit der Envelope-Domain
- `DKIM: pass` mit `d=` gleich der From-Domain
- `DMARC: pass`
- kein Eintrag auf Spamhaus/Barracuda für deine Relay-IP
## Häufige Ursachen für Spam-Einstufung
| Symptom | Ursache |
|---|---|
| Landet bei Gmail im Spam, sonst nirgends | Fehlender oder falscher PTR-Eintrag |
| DKIM `pass`, DMARC `fail` | Signierende Domain weicht von der From-Domain ab |
| SPF `softfail` | Relay-IP nicht im SPF-Record der Envelope-Domain |
| Alles `pass`, trotzdem Spam | Neue Domain ohne Sendereputation das gibt sich nach einigen Wochen regelmäßigen Versands |
| Outlook/Hotmail blockt | Microsoft verlangt oft eine Anmeldung beim SNDS-Programm |

130
docs/reverse-proxy.md Normal file
View File

@@ -0,0 +1,130 @@
# Betrieb hinter dem Reverse Proxy (OPNsense)
Aufbau: Browser → HTTPS → nginx auf der OPNsense → HTTP → Port 46600 auf dem
Docker-Host → nginx im `web`-Container → `api`.
## Drei Werte in der `.env`
```
PUBLIC_BASE_URL=https://einkauf.example.de
COOKIE_SECURE=true
FORWARDED_ALLOW_IPS=<IP des Docker-Hosts oder *>
```
Nach jeder Änderung `docker compose up -d`, nicht `restart` ein Neustart
übernimmt keine geänderten Umgebungswerte.
**`PUBLIC_BASE_URL`** landet in jeder Verifikations-, Einladungs- und
Passwort-Reset-Mail. Steht dort noch `http://…:46600`, führen die Links ins
Leere, sobald jemand von außen klickt.
**`COOKIE_SECURE=true`** ist ab jetzt Pflicht, aber auch erst ab jetzt möglich:
Ein `Secure`-Cookie über reines HTTP wird vom Browser verworfen, und die
Anmeldung scheitert dann ohne verwertbare Meldung. Beides muss gleichzeitig
umgestellt werden.
## Was die OPNsense weiterreichen muss
Im nginx-Plugin unter *Http(s) → Location* für den Upstream:
```
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
```
`X-Forwarded-Proto` ist der wichtigste Wert: Ohne ihn hält die Anwendung die
Verbindung für unverschlüsselt.
Zusätzlich für Server-Sent Events (ab Phase 4):
```
proxy_buffering off;
proxy_read_timeout 3600s;
```
Ohne `proxy_buffering off` sammelt nginx die Ereignisse und gibt sie erst
gebündelt weiter die Liste aktualisiert sich dann mit Verzögerung oder gar
nicht.
## Client-IP fürs Rate Limiting
Mit zwei Proxys davor sieht die Anwendung ohne Weiterreichung nur die IP des
`web`-Containers. Dann würde eine einzige IP das Limit für alle auslösen.
`X-Forwarded-For` wird durch beide Stufen durchgereicht; uvicorn wertet es
wegen `--proxy-headers` aus.
`FORWARDED_ALLOW_IPS=*` ist vertretbar, solange der `api`-Container keinen
veröffentlichten Port hat dann kann den Header nur der `web`-Container
setzen. Sobald du den API-Port für Debugging freigibst, gehört dort die
konkrete IP hinein.
## PWA-Installation
Erst über HTTPS bietet der Browser „Zur Startseite hinzufügen" an und lässt den
Service Worker zu. Bei iOS ist die Installation zusätzlich Voraussetzung dafür,
dass Web Push in Phase 8 überhaupt funktioniert im normalen Safari-Tab gibt
es keine Benachrichtigungen.
## Prüfen
```bash
curl -sI https://einkauf.example.de/ | grep -i strict-transport
curl -s https://einkauf.example.de/readyz
```
Und nach der Anmeldung im Browser unter *Entwicklerwerkzeuge → Anwendung →
Cookies*: `ea_session` muss `Secure` und `HttpOnly` tragen, `ea_csrf` nur
`Secure`.
## Kopfzeilen: was aus dem Protokoll hervorgeht
Eine Auswertung der tatsächlich ausgelieferten Antworten hat drei Punkte
ergeben, die auf der OPNsense-Seite liegen.
### `X-XSS-Protection: 1` entfernen
Die OPNsense setzt diese Kopfzeile. Sie ist überholt: Chrome hat den
zugehörigen Filter entfernt, und in den Browsern, die ihn noch kennen, kann er
in Einzelfällen selbst Lücken aufreißen, indem er Teile der Seite unterdrückt.
Der Wert `1` ohne `mode=block` ist dabei die ungünstigste Variante.
Empfehlung: im nginx-Plugin abschalten oder auf `0` setzen. Der Schutz kommt
von der Content-Security-Policy, nicht von dieser Kopfzeile.
### HSTS: `preload` passt nicht zur Laufzeit
```
strict-transport-security: max-age=15768000; includeSubDomains; preload
```
Das sind gut sechs Monate. Für die Aufnahme in die Preload-Liste verlangen die
Browserhersteller mindestens ein Jahr (`31536000`); mit dem jetzigen Wert wird
das `preload` schlicht ignoriert.
Entweder auf `31536000` erhöhen dann aber im Bewusstsein, dass ein Eintrag in
der Preload-Liste die Domain für alle Unterdomains dauerhaft auf HTTPS
festlegt und sich nur mit Monaten Vorlauf rückgängig machen lässt oder das
`preload` weglassen.
### Doppelte Kopfzeilen
`X-Content-Type-Options`, `X-Frame-Options` und `Permissions-Policy` kommen
jeweils zweimal an: einmal vom `web`-Container, einmal von der OPNsense. Das
ist unschädlich, aber unnötig.
Wichtiger ist, was daraus folgt: **Eine doppelte
`Content-Security-Policy` wäre nicht unschädlich.** Browser wenden dann beide
an und lassen nur zu, was in *beiden* erlaubt ist. Eine zusätzliche CSP auf der
OPNsense könnte die Anwendung also lahmlegen, ohne dass eine Fehlermeldung
darauf hinweist sichtbar wäre nur, dass Module oder Stile nicht laden.
Derzeit setzt die OPNsense keine, das sollte so bleiben.
Die OPNsense überschreibt außerdem `Referrer-Policy` mit `same-origin` statt
des strengeren `no-referrer` aus dem Container. Unkritisch, solange die
Anwendung nicht nach außen verlinkt für die öffentlichen Listenlinks unter
`/s/<token>` bedeutet es, dass der Token nur an die eigene Herkunft übertragen
würde. Wer es strenger mag, stellt die OPNsense auf `no-referrer` um.

263
docs/sicherheit.md Normal file
View File

@@ -0,0 +1,263 @@
# 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`.