12 KiB
Sicherheit
Stand der Prüfung: nach Phase 9, Revision 0011, 75 Endpunkte.
Prüfungen, die sich wiederholen lassen
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:
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:
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.
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 imHttpOnly-Cookie. - CSRF: Double-Submit. Ein zweites, lesbares Cookie muss bei jeder
schreibenden Anfrage im Header
X-CSRF-Tokenwiederkommen. ZusätzlichSameSite=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_rehashbei 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_pointführt Markt, Artikel, Betrag und Zeitpunkt – keinuser_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.