Vollstaendiger Neubau der Anwendung. Der vorherige Stand bleibt unveraendert im Zweig SONNET5 erhalten. Aufbau: apps/tesm (Anwendung), packages/tesm-core (gemeinsamer Kern), packages/tesm-licensing (Lizenzprotokoll), deploy (Installation, systemd, privilegierter Helfer), docs, tests. Der Lizenzserver liegt in seinem eigenen Repository; beide Repositorien bringen die gemeinsamen Pakete mit, damit sich jedes allein installieren laesst. Die wichtigsten Unterschiede zum Vorgaenger, jeweils an der Stelle im Code kommentiert, an der der Fehler entstanden ist: * Der Webprozess laeuft unprivilegiert. Alles, was Root braucht, geht ueber einen einzigen Helfer mit Positivlisten fuer jedes Argument. * CSRF-Schutz ueberhaupt -- der Vorgaenger hatte keinen. * Rechte werden serverseitig geprueft, nicht nur im Template ausgeblendet. * Die nginx-Site wird bei jedem Lauf inhaltlich verglichen und erneuert. * Jede erzeugte Konfiguration wird vor dem Uebernehmen geprueft (nginx, Kea). * Kein Hostname im Lizenz-Fingerabdruck. * Zwei Installationen auf einem Host stoeren sich nicht (eigener SITE_KEY). * Verschachtelte Datenbankverbindungen sind ein Fehler, kein Deadlock. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
184 lines
7.0 KiB
Markdown
184 lines
7.0 KiB
Markdown
# Sicherheit
|
|
|
|
Was geschuetzt wird, womit, und wo bewusst Grenzen gezogen sind.
|
|
|
|
---
|
|
|
|
## 1. Anmeldung
|
|
|
|
| | |
|
|
|---|---|
|
|
| Speicherung | Argon2id (argon2-cffi) |
|
|
| Altbestand | bcrypt wird noch geprueft und bei der naechsten erfolgreichen Anmeldung stillschweigend auf Argon2id umgestellt |
|
|
| Zweiter Faktor | TOTP, optional erzwingbar fuer Administratoren |
|
|
| Sperre je Konto | nach `LOGIN_MAX_ATTEMPTS` Versuchen |
|
|
| Sperre je Adresse | zusaetzlich `LOGIN_MAX_ATTEMPTS_PER_IP` -- sonst laesst sich die Kontosperre umgehen, indem man viele Konten je einmal probiert |
|
|
| Verzeichnisdienst | LDAP nur ueber TLS, Zertifikat wird geprueft. Ohne gueltiges Zertifikat keine Anmeldung -- kein Schalter, der das abschaltet |
|
|
|
|
Nach einer Anmeldung wird die Sitzungskennung neu vergeben (gegen Session
|
|
Fixation) und das CSRF-Token gewechselt.
|
|
|
|
### Erneute Bestaetigung ("sudo-Modus")
|
|
|
|
Alles, was Geheimnisse offenlegt oder Daten aus dem System heraustraegt,
|
|
verlangt eine frische Passworteingabe: Klartextanzeige von Zugangsdaten,
|
|
Ausfuhr, Herunterladen eines Lizenzbundles, Schluesselrotation,
|
|
2FA-Aenderungen, Deaktivierung einer Lizenz.
|
|
|
|
Ein uebernommenes Sitzungscookie allein reicht dafuer nicht.
|
|
|
|
---
|
|
|
|
## 2. Sitzungen
|
|
|
|
Serverseitig in SQLite, nicht im Cookie. Das Cookie traegt nur eine
|
|
Zufallskennung.
|
|
|
|
* `HttpOnly`, `SameSite=Lax`, `Secure` sobald HTTPS eingerichtet ist
|
|
* Leerlaufzeit **und** absolute Hoechstdauer
|
|
* Der Cookiename enthaelt den `SITE_KEY` -- zwei Installationen auf einem Host
|
|
koennen sich die Sitzung nicht gegenseitig ueberschreiben (Cookies
|
|
unterscheiden keine Ports)
|
|
* Aktive Sitzungen sind einsehbar und einzeln beendbar
|
|
|
|
---
|
|
|
|
## 3. CSRF
|
|
|
|
Drei voneinander unabhaengige Schichten:
|
|
|
|
1. `SameSite=Lax` am Sitzungscookie.
|
|
2. Ein Token je Sitzung, in jedem Formular, in konstanter Zeit verglichen.
|
|
3. Origin-/Referer-Pruefung gegen den Host der Anfrage.
|
|
|
|
Die dritte Schicht akzeptiert den Host mit und ohne Port sowie die
|
|
Standardports -- **nicht** aber beliebige Ports: ein anderer Dienst auf
|
|
demselben Rechner ist ein anderer Ursprung.
|
|
|
|
Der Vorgaenger hatte gar keinen CSRF-Schutz. Eine praeparierte Seite konnte im
|
|
Namen eines angemeldeten Administrators Geraete loeschen oder Benutzer anlegen.
|
|
|
|
Ausnahmen (`@csrf.exempt`) gibt es nur fuer Maschinenschnittstellen, die sich
|
|
**nicht** ueber Cookies autorisieren. Eine cookie-autorisierte Route ohne
|
|
CSRF-Schutz waere eine Luecke.
|
|
|
|
---
|
|
|
|
## 4. Header
|
|
|
|
Bei jeder Antwort:
|
|
|
|
* `Content-Security-Policy` mit einem Nonce je Anfrage. **Kein**
|
|
`unsafe-inline` fuer Skripte -- deshalb gibt es im ganzen Projekt kein
|
|
Inline-Script ohne Nonce, und JavaScript haengt ueber
|
|
`data-behavior`-Attribute am DOM. `style-src` erlaubt `unsafe-inline`, weil
|
|
einzelne berechnete Breiten (Fortschrittsbalken) als Style-Attribut gesetzt
|
|
werden; Style-Attribute fuehren keinen Code aus.
|
|
* `X-Content-Type-Options: nosniff`
|
|
* `X-Frame-Options: DENY`, `frame-ancestors 'none'`
|
|
* `Referrer-Policy: same-origin`
|
|
* `Permissions-Policy` -- Kamera, Mikrofon, Ort, Zahlung, USB abgeschaltet
|
|
* `Cross-Origin-Opener-Policy`, `Cross-Origin-Resource-Policy`
|
|
* `Strict-Transport-Security` nur, wenn TLS tatsaechlich terminiert wird
|
|
|
|
> HSTS gehoert **nicht** zu einem selbstsignierten Zertifikat. Der Browser
|
|
> merkt sich die Vorgabe monatelang; die Warnung laesst sich danach nicht mehr
|
|
> wegklicken. `install.sh` setzt HSTS deshalb nur zusammen mit Let's Encrypt.
|
|
|
|
---
|
|
|
|
## 5. Geheimnisse im Ruhezustand
|
|
|
|
Ein Schluesselspeicher mit AES-256-GCM (`data/data.keys`, Modus 0600):
|
|
|
|
* Versionierte Schluessel, rotierbar. Chiffrate tragen ihre Schluesselkennung
|
|
(`v1.<id>.<nonce>.<ct>`) und bleiben nach einer Rotation lesbar.
|
|
* Der Verwendungszweck geht als AAD in die Verschluesselung ein. Ein Chiffrat
|
|
aus einem Kontext laesst sich in einem anderen nicht entschluesseln.
|
|
* Beim Start wird geprueft, ob die Rechte auf Schluessel- und Lizenzdateien
|
|
noch stimmen; Abweichungen erscheinen unter *Diagnose*.
|
|
|
|
Passwoerter fuer Mounts gehen ueber `stdin` an den privilegierten Helfer, der
|
|
sie in eine 0600-Datei schreibt -- nie als Kommandozeilenargument (Prozessliste)
|
|
und nie ueber die Umgebung (`/proc/<pid>/environ`).
|
|
|
|
---
|
|
|
|
## 6. Rechtetrennung auf dem Host
|
|
|
|
Der Webprozess laeuft als `tesm` bzw. `tesm-license`. Root-Aktionen gehen durch
|
|
genau einen allowlisted Helfer (`/usr/local/lib/tesm/tesm-helper`), der per
|
|
sudoers ausschliesslich fuer diesen Pfad freigegeben ist.
|
|
|
|
Regeln, die im Repository durch Tests erzwungen werden:
|
|
|
|
* Jedes Verb, das die Anwendung kennt, existiert im Helfer.
|
|
* Kein `eval`, kein `bash -c`, kein `sh -c`.
|
|
* Jede Pfadpruefung weist `..` ab -- sonst laesst sich eine Positivliste ueber
|
|
den Basisnamen unterlaufen (`/etc/nginx/sites-available/../../../root/x` hat
|
|
den zulaessigen Basisnamen `x`).
|
|
* Der Instanzname ist auf `tesm[-<instanz>]` begrenzt; er landet in Pfaden.
|
|
* Keine sudoers-Regel ausser der einen. `NOPASSWD: ALL` gibt es nicht.
|
|
|
|
Die systemd-Units haben `NoNewPrivileges=yes`, `ProtectSystem=strict`,
|
|
`PrivateTmp=yes` und eine enge `RestrictAddressFamilies`-Liste.
|
|
|
|
Der Vorgaenger liess die komplette Flask-Anwendung als root laufen. Eine
|
|
einzige Luecke in irgendeinem Pfad haette den Host bedeutet.
|
|
|
|
---
|
|
|
|
## 7. Aenderungsprotokoll
|
|
|
|
Verkettet gehasht: jeder Eintrag enthaelt den Hash seines Vorgaengers. Ein
|
|
nachtraegliches Aendern oder Loeschen faellt auf und wird unter *Diagnose*
|
|
angezeigt, mitsamt dem Eintrag, ab dem die Kette bricht.
|
|
|
|
Protokolliert wird, wer was wann von welcher Adresse getan hat -- ausdruecklich
|
|
auch Lesezugriffe auf Geheimnisse (`credential.secret_revealed`).
|
|
|
|
Aeltere Tage wandern in JSONL-Archive und lassen sich als ZIP herunterladen.
|
|
|
|
---
|
|
|
|
## 8. Lizenzprotokoll
|
|
|
|
Siehe [ARCHITEKTUR.md](ARCHITEKTUR.md), Abschnitt Lizenzierung. Kurz:
|
|
getrennte Signaturkontexte, kanonisches JSON, Nonce gegen Wiedereinspielung,
|
|
Frischefenster, `seq` gegen Rueckstufung, Antwort an die Anfrage gebunden,
|
|
Aussteller-Pinning.
|
|
|
|
Das Auslieferungsbundle enthaelt den privaten Clientschluessel und ist damit
|
|
selbst ein Geheimnis: eigenes Recht (`licenses.export`), frische
|
|
Passwortbestaetigung, `Cache-Control: no-store`, Eintrag im Zustell- und im
|
|
Aenderungsprotokoll.
|
|
|
|
---
|
|
|
|
## 9. Ein- und Ausfuhr
|
|
|
|
Verschluesselte Umschlaege (Argon2id-Ableitung + AES-256-GCM) mit einem selbst
|
|
gewaehlten Kennwort. Der Import zeigt eine Vorschau, bevor etwas geschrieben
|
|
wird; die Vorschau liegt in der Datenbank, nicht im Prozessspeicher -- deshalb
|
|
braucht der Dienst kein `--workers 1` mehr.
|
|
|
|
---
|
|
|
|
## 10. Bewusst nicht getan
|
|
|
|
* **Kein Schalter, der die LDAP-Zertifikatspruefung abschaltet.** Wer ihn
|
|
einmal setzt, setzt ihn dauerhaft.
|
|
* **Kein CSRF-Freibrief fuer cookie-autorisierte Routen.**
|
|
* **Kein `unsafe-inline` fuer Skripte**, auch nicht "voruebergehend".
|
|
* **Keine Rechteausweitung**, auch nicht fuer Administratoren untereinander.
|
|
* **Kein Hostname im Lizenz-Fingerabdruck** -- Umbenennen darf die Bindung
|
|
nicht brechen.
|
|
* **Keine von Hand gepflegte nginx-Site.** Sie wird immer neu erzeugt.
|
|
|
|
---
|
|
|
|
## 11. Wenn Sie eine Schwachstelle finden
|
|
|
|
Bitte nicht oeffentlich melden. Wenden Sie sich an die im Anbieterprofil des
|
|
Lizenzservers hinterlegte Adresse.
|