Erste Produktivversion
This commit is contained in:
174
docs/betrieb.md
Normal file
174
docs/betrieb.md
Normal 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
115
docs/mail-zustellbarkeit.md
Normal 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
130
docs/reverse-proxy.md
Normal 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
263
docs/sicherheit.md
Normal 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`.
|
||||
Reference in New Issue
Block a user