Commit Graph
6 Commits
Author SHA1 Message Date
Florian Wilde 1e0d04d0db Befunde der Sicherheitspruefung abarbeiten
S1 (hoch): Die OIDC-Rueckkehr verknuepft eine Identitaet mit einem
bestehenden Konto nur bei bestaetigter E-Mail — geprueft wurde das auch,
nur war die Bestaetigung als `email_verified !== false` berechnet. Damit
galt auch ein fehlender Claim als bestaetigt, und genau den schicken
manche Anbieter gar nicht. Jetzt zaehlt allein ein ausdrueckliches `true`.

S2 (hoch): `isAuthEnabled()` verlangte den Wert "true"; alles andere hiess
"aus". Eine vergessene Variable ergab eine Anwendung, in der jeder
Besucher alles darf, und nichts sah danach aus. Umgedreht: Abgeschaltet
wird nur, wer ausdruecklich "false" setzt. Die Vorgaben in Compose und
.env.example stehen auf true, und der Container warnt beim Start
mehrzeilig, solange die Anmeldung aus ist.

S5 (mittel): Der OIDC-Bootstrap las `user.count()` und legte danach an —
zwei gleichzeitige erste Anmeldungen konnten beide Administrator werden.
Jetzt in einer serialisierbaren Transaktion, wie die lokale
Ersteinrichtung es laengst tut.

S6 (niedrig): Die Herkunftspruefung verglich nur den Host, nicht das
Schema. Das Schema kommt jetzt aus BASE_URL — die eine Angabe zum
Ursprung, ueber die der Aufrufer nicht mitbestimmt.

S3, S4 und S7 bleiben offen und stehen begruendet in docs/sicherheit.md.
Bei S3 wurde der naheliegende Ausweg geprueft und verworfen: Waehrend der
Sperre weiter Passwoerter zu pruefen kostet 128 MiB je Versuch und
tauschte eine Sperre gegen ein Konto gegen eine gegen alle.

Refs #20
2026-08-24 01:02:03 +02:00
Florian Wilde 46e7eb0b59 Sprechende Kurz-Links: selbst vergeben, aendern, frueher gueltig halten
Schliesst Issue #1. Der Wunsch betraf den Kurz-Link, nicht das Ziel: Er soll
sich von Anfang an bestimmen und spaeter aendern lassen.

Beim Anlegen gibt es dafuer ein Feld, im Bearbeiten-Dialog laesst er sich
aendern. Erlaubt sind Kleinbuchstaben, Ziffern, Bindestriche und „/" als
Trenner — damit sind Adressen wie kunde-a/sommer moeglich. Umlaute bleiben
draussen, weil sie in Adressen unleserlich kodiert werden; Gross- und
Kleinschreibung ist beim Aufrufen egal.

Beim Aendern bleibt die vorherige Adresse gueltig. Das ist der Kern: Auf
gedruckten Codes steht die alte, und genau deren Bestand ist der Sinn eines
dynamischen Codes. Frueher vergebene Adressen stehen im Dialog und lassen
sich einzeln freigeben — fuer den Tippfehler, mit dem noch nichts gedruckt
ist. Solange eine Adresse gehalten wird, kann sie kein anderer Code
uebernehmen; sonst liessen sich fremde gedruckte Codes umlenken.

Vergeben und Aendern ist standardmaessig Administratoren vorbehalten. Ein
Kurz-Link ist eine oeffentliche Adresse, und wer ihn aendert, kann Aushaenge
entwerten. Unter Verwaltung -> Einstellungen laesst sich das fuer Bearbeiter
freigeben; ohne das Recht erscheint das Feld gar nicht, Codes anlegen und
deren Ziel aendern geht weiterhin. Eigene Aktion in der Policy statt Teil von
codes:update, weil sie mehr wiegt.

Technisch: /r und /s sind Sammelrouten, weil ein Kurz-Link „/" enthalten
darf. Die Eindeutigkeit gilt zusaetzlich auf der kleingeschriebenen Form; die
Migration bricht ab, falls der Bestand das nicht zulaesst, statt still Daten
zu verlieren. Der bestehende exakte Index bleibt — er traegt den haeufigen
Fall, den gescannten Code.
2026-08-17 10:41:57 +02:00
Florian Wilde c38b27ca85 Anmeldung: Host-Abweichung frueh erkennen, weniger auf der Anmeldeseite verraten
Im Betrieb aufgetreten: Die Anwendung lief hinter einem Reverse Proxy unter
einer Domain, in der .env stand aber noch die interne Adresse aus der
Einrichtung. Der Anmeldeknopf setzt den Cookie mit dem laufenden Vorgang auf
dem Host, ueber den der Browser da ist — die Rueckkehradresse fuer den
Anbieter kommt dagegen aus BASE_URL. Der Anbieter schickte den Browser also
auf den anderen Host, der Cookie blieb zurueck, und am Ende stand „Der
Anmeldevorgang ist abgelaufen". Eine Meldung, die die Ursache gut versteckt.

Jetzt prueft der Start beide Adressen und bricht sofort mit einer Erklaerung
ab. Auf dem Bildschirm steht nur die aufgerufene Adresse — die interne gehoert
ins Protokoll, nicht auf eine Seite, die jeder sieht.

Aus demselben Grund faellt der Hinweis „Die Anmeldung mit Passwort ist
abgeschaltet (OIDC_ONLY=true)" weg. Wie die Anmeldung eingerichtet ist, geht
Fremde nichts an; wer hierher gehoert, sieht den einen Knopf und weiss, was zu
tun ist. Beim allerersten Aufruf bleibt der Hinweis, dass der Erste
Administrator wird — der ist dann nuetzlich.

Dazu die Anleitungen: Der Reverse-Proxy-Abschnitt im README nennt jetzt die
drei Kopfzeilen, die die Anwendung vom Proxy erwartet, und was ohne sie
passiert; dazu client_max_body_size fuer Uploads, ein Weg zum Nachpruefen und
die Frage, fuer wen die Codes erreichbar sein sollen. In authentik.md steht,
warum BASE_URL und die Adresse im Browser dieselbe sein muessen.
2026-08-17 00:28:31 +02:00
Florian Wilde 75252b32b4 Anmeldung und Sitzungen nach OWASP durchgesehen und gehaertet
Zehn Befunde, alle behoben. Die wichtigsten:

scrypt lief mit N=2^15; das OWASP Password Storage Cheat Sheet nennt 2^17.
Jetzt 2^17, alte Hashes bleiben gueltig und werden beim naechsten Anmelden
still erneuert — das ist der einzige Moment, in dem das Klartextpasswort
vorliegt.

Passwoerter hatten keine Obergrenze. Ein Passwort von mehreren Megabyte haette
scrypt beschaeftigt, und zwar an einem Endpunkt ohne Anmeldung. Jetzt 128
Zeichen, geprueft vor dem Hashen.

Als CSRF-Schutz gab es nur SameSite=Lax. Das Merkblatt sagt ausdruecklich,
dass das allein nicht genuegt — besonders beim Upload, der multipart
entgegennimmt und damit von einem fremden Formular erreichbar waere. Jetzt
wird bei jedem schreibenden Zugriff der Origin gegen den Host der Anfrage
geprueft (nicht gegen BASE_URL, die Anwendung laeuft unter mehreren Domains).

Es gab keinen einzigen Schutzheader — die Verwaltung liess sich einbetten und
per Clickjacking fernsteuern. Jetzt frame-ancestors none, X-Frame-Options,
nosniff, Referrer-Policy und Permissions-Policy; X-Powered-By ist weg.

Sitzungen hatten keine absolute Obergrenze: eine gestohlene Sitzung haette
durch regelmaessige Nutzung unbegrenzt weitergelebt. Jetzt SESSION_MAX_DAYS
neben SESSION_IDLE_DAYS, beide einstellbar.

Dazu: Secure auch hinter einem TLS-Proxy, weniger Auskunft ueber fremde
Konten bei der Anmeldung, eine Bremse je Absender gegen breites
Durchprobieren, Ersteinrichtung ohne Wettlauf, und abgelaufene Sitzungen
werden aufgeraeumt.

Die Datei heisst proxy.ts statt middleware.ts — Next 16 hat die Konvention
umbenannt. /api/uploads bleibt davon ausgenommen: Der Proxy puffert
Anfragekoerper nur bis 10 MB, eine Datei an der Groessengrenze kaeme sonst
unvollstaendig an. Die Herkunftspruefung macht die Route dort selbst.

Befund, Begruendungen und was bewusst offen bleibt: docs/sicherheit.md
2026-08-15 08:31:29 +02:00
Florian Wilde c8d60d8006 Anmeldung ueber Authentik und andere OIDC-Anbieter
Anmeldefluss mit PKCE (S256), State und Nonce. Das ID-Token wird gegen den
JWKS des Anbieters geprueft — Signatur, Aussteller, Empfaenger, Ablauf und
Nonce; alles Noetige kommt aus dem Discovery-Dokument, damit die Anbindung
nicht auf Authentik festgelegt ist.

Wiedererkannt wird jemand an der Kennung des Anbieters (sub), nicht an der
E-Mail-Adresse — Adressen aendern sich. Ein bestehendes Konto mit derselben
Adresse wird verknuepft statt verdoppelt, aber nur bei bestaetigter Adresse:
sonst koennte sich beim Anbieter jemand eine fremde Adresse eintragen und
damit ein hiesiges Konto uebernehmen.

Uebernommen wird bewusst nur, wer jemand ist — plus die Instanz-Administration,
wenn OIDC_ADMIN_GROUP gesetzt ist. Gruppen und Rollen bleiben Sache dieser
Anwendung: die Gruppen im Verzeichnis bedeuten etwas anderes als die hier.

Konten aus dem Verzeichnis haben kein Passwort mehr (passwordHash ist jetzt
optional); Konto-Bereich und Passwort-Anmeldung sagen das statt an einer
leeren Pruefung zu scheitern.

Anleitung mit allen Feldern in Authentik: docs/authentik.md
2026-08-15 00:12:21 +02:00
Florian Wilde f1cdb3da13 Phase B1: Anmeldung, Benutzer, Gruppen und Rollen
Umschaltbar ueber AUTH_ENABLED in der .env. Steht sie auf false, verhaelt
sich alles wie bisher - geprueft mit den 200 bestehenden Pruefungen.

Kern ist eine Actor- und Policy-Schicht: getActor() liefert bei
ausgeschalteter Anmeldung einen system-Actor mit Vollzugriff, sonst den
angemeldeten Benutzer. Dadurch braucht keine einzige Route ein
'if (anmeldungAktiv)' - sie fragen alle nur can() bzw. requirePermission().

- Datenmodell: User, Session, Group, Membership; Codes bekommen Gruppe und
  Ersteller. Die Migration legt eine Standardgruppe an und ordnet ihr
  bestehende Codes zu, bevor die Spalte verpflichtend wird - sonst
  scheiterte sie auf jeder bereits benutzten Installation.
- Passwoerter mit scrypt aus der Node-Standardbibliothek. Bewusst kein
  Argon2: das braeuchte ein natives Modul passend zur Container-Plattform.
- Sitzungen in der Datenbank, im Cookie nur ein Zufallstoken; gespeichert
  wird dessen SHA-256. Deaktivieren oder ein neues Passwort beendet alle
  offenen Sitzungen sofort.
- Aussperrschutz: der letzte aktive Administrator kann sich nicht selbst
  entmachten, deaktivieren oder loeschen. Der Schalter liegt in der .env,
  nicht in der Oberflaeche - so bleibt der Weg zurueck immer offen.
- Ersteinrichtung legt den ersten Administrator an und schliesst sich danach.
- Die Weiterleitung /r/{slug} bleibt oeffentlich, sonst liesse sich kein
  gedruckter Code mehr scannen.

Zwei Fehler dabei gefunden und behoben:

- 'return handleLogin(...)' innerhalb eines try-Blocks gibt das Promise
  zurueck, bevor es sich aufloest. Die Ablehnung kam nie im catch an und
  wurde zu einem nackten 500er statt einer 401. Jetzt mit await.
- Die Fehlerklassen wurden ueber instanceof erkannt. type-module.ts landet
  aber sowohl im Server- als auch im Client-Buendel, es gibt also zwei
  Klassenidentitaeten - saubere 400er wurden dadurch still zu 500ern. Die
  Erkennung laeuft jetzt ueber eine Eigenschaft.

Geprueft: 51 neue Pruefungen fuer Anmeldung und die vollstaendige
Rollenmatrix, dazu die 200 bestehenden bei ausgeschalteter Anmeldung.
2026-08-14 12:31:16 +02:00