← Zurück zum Blog
pullmdmulti-userauthsecurityoss9. Mai 2026 · 7 Min. Lesezeit

PullMD Teil 11: Multi-User-Pivot

DISABLED · SINGLE ADMIN · MULTI USER → v2.0KI-generiertes Bild
Editorial-Diagramm: drei Auth-Mode-Karten oben (DISABLED, SINGLE ADMIN, MULTI USER) speisen einen zentralen User-Group-Hub, verbunden mit OAUTH- und CONNECTOR-Modulen; rechts ein Security-Review-Findings-Panel mit Critical/Checked-Items; unten Icons für User, Sessions, API-Keys und Verbindungen, plus ein v1.2 → v2.0 Versionspfeil.Editorial-Diagramm: drei Auth-Mode-Karten oben (DISABLED, SINGLE ADMIN, MULTI USER) speisen einen zentralen User-Group-Hub, verbunden mit OAUTH- und CONNECTOR-Modulen; rechts ein Security-Review-Findings-Panel mit Critical/Checked-Items; unten Icons für User, Sessions, API-Keys und Verbindungen, plus ein v1.2 → v2.0 Versionspfeil.

Am Ende von Teil 9 stand Issue #5 oben in der Liste: „Architecture: Phase 1 - Multi-user system with sessions + API keys.“ Ich hatte das nicht geplant. Nicht für diese Woche.

Warum Multi-User Phase 1 wurde, nicht Phase 2

Die eigentliche Erkenntnis kam nicht aus dem Issue selbst, sondern aus den zwei Issues danach. Issue #6 wollte OAuth für den claude.ai-Web-Connector. Issue #10 fragte, wie man Claude Desktop mit einem Self-Hosted PullMD authentifiziert. Mit dem Hinweis, dass Claude Desktops UI nur OAuth unterstützt.

Beide Issues wären ohne ein User-Modell nicht lösbar. Kein User-Modell heißt kein OAuth. Kein OAuth heißt kein web-connector-fähiger Self-Host.

Die Reihenfolge war also nicht eine Entscheidung zwischen Prioritäten, sondern eine Frage der Abhängigkeiten. Ich konnte #6 und #10 nicht angehen, ohne #5 zu lösen.

Plan: drei Auth-Modi

Der wichtigste Designpunkt war mir, dass das Update nicht breaking sein durfte. Jemand, der PullMD seit dem Reddit-Post am 28. April deployed hatte, sollte nach einem docker compose pull nicht plötzlich ausgesperrt sein. Laut GitHub Insights waren das 320 Unique Cloner in zwei Wochen.

Drei Modi:

  • disabled (Default): verhält sich identisch zu v1.x. Kein Login, keine Sessions, keine neue Konfiguration notwendig.
  • single-admin: ein User, kein Self-Signup. Für Hobby-Self-Hoster, die ihr eigenes PullMD betreiben.
  • multi-user: mehrere Konten mit selbst verwalteten Passwörtern, für shared Instanzen.

Wer nichts in der .env ändert, bekommt disabled. Die Migration ist additiv: neue Tabellen, eine neue Spalte in conversions, aber nichts geht kaputt. Wer v2 mit Auth haben will, setzt drei Zeilen in der .env und startet neu.

Implementation: 30 Commits zwischen v1.2.0 und v2.0.0

Der git-log zwischen den beiden Tags sieht lang aus, ist es aber nicht: 30 Commits, davon ein großer Schwung Tests und Dokumentation. Das eigentliche Auth-System sind ein Middleware-Layer und vier neue Tabellen.

Die Tabellen heißen users, sessions, api_keys und user_fetches; dazu kommt eine user_id-Spalte in conversions.

In sessions steckt außerdem eine flash_data-Spalte. Darüber kommt eine Meldung nach dem Login zum Browser, ohne dass sie in der Adresszeile landet.

Passwörter liegen als Argon2id-Hash in der Datenbank. Das Verfahren ist absichtlich langsam und speicherhungrig, damit Durchprobieren teuer wird.

Die Parameter t=3, m=64 MiB, p=4 sind die OWASP-Empfehlung für den Produktivbetrieb: drei Durchgänge über 64 Megabyte Arbeitsspeicher, viermal parallel.

Meldet sich jemand mit einem Konto an, das es gar nicht gibt, prüft der Dienst trotzdem einen Dummy-Hash. Sonst käme die Antwort messbar schneller und würde verraten, welche Konten existieren.

Nach dem Login liegt ein Cookie beim Nutzer, HttpOnly und SameSite=Lax, damit kein fremdes JavaScript drankommt. Der Token darin ist randomBytes(32).toString('hex'), also 256 Bit Zufall.

Er gilt sieben Tage, gleitend gerechnet. Wer den Dienst benutzt, bleibt angemeldet, wer ihn zwei Wochen liegen lässt, meldet sich neu an.

Für Agenten und Skripte gibt es stattdessen API-Keys im Format pmd_<32-base62>. Gespeichert wird davon nur der SHA-256-Hash.

Sichtbar bleibt das Präfix. Damit erkennt man in der Liste, welcher Schlüssel welcher ist, ohne ihn irgendwo im Klartext liegen zu haben.

Beim Löschen eines Kontos räumt die Datenbank selbst auf, mit PRAGMA foreign_keys = ON und CASCADE auf die abhängigen Zeilen.

Nur conversions.user_id steht auf SET NULL. Der Cache-Eintrag bleibt also liegen, wenn sein Nutzer geht, denn die abgerufene Seite ist ja immer noch dieselbe.

Dazu ein kleines Kommandozeilen-Werkzeug: node scripts/admin.js reset-password, list-users, make-admin. Das braucht man an genau einem Tag, nämlich dem, an dem jemand das Admin-Passwort verlegt hat.

Über allem liegt eine dünne Schicht, die aus den drei Wegen dasselbe macht: Session-Cookie, API-Key und der alte Token aus v1 landen alle in req.user.

Jede Route fragt also nur noch, wer da ist, und nicht, wie er hereingekommen ist.

Deshalb lässt sich OAuth aus Issue #6 später als vierter Weg danebenhängen. Die bestehenden Routen für API und MCP müssen dafür nicht angefasst werden.

REVIEW-FINDINGS.md öffentlich mitveröffentlicht

Vor dem Tag habe ich einen eigenen Security-Review-Pass gemacht: Subagent-driven, mit Claude Code als security-reviewer in der Kritiker-Rolle. Das Ergebnis: 1 CRITICAL und 2 SHOULD-FIX, die vor dem Tag gefixt wurden.

Das CRITICAL war ein echter Bug: ein neu erzeugter API-Key wurde per Redirect-URL übergeben. POST /api/keys leitete auf /settings?new_key=pmd_... um. Der Key landete damit in der Browser-History, in Server-Logs und potenziell in Referer-Headern. Fix: Flash-Message via flash_data-Spalte in der Session, Redirect ohne Query-Parameter.

Die beiden SHOULD-FIX waren ein Open-Redirect über ?next=/\evil.com im Login-Flow (Browser normalisieren \ zu /) und ein fehlender Admin-Check auf DELETE /api/cache* im Multi-User-Modus.

Nach dem Fix blieben 6 NICE-TO-HAVE offen: Modulo-Bias bei der Key-Generation, fehlende FK-Constraint auf user_fetches.cache_id, Share-Link-IDs mit 32-Bit-Entropie (vorhandenes v1-Problem), kein Rate-Limit auf Login/Signup, X-Forwarded-Proto-Trust ohne Proxy-Allowlist, Legacy-Token ohne Mindest-Entropie-Empfehlung.

Alle sechs blieben dokumentiert: in REVIEW-FINDINGS.md, die ich bewusst öffentlich ins Repo gelegt habe. Die Datei ist maschinenlesbar und greppbar: Wer self-hosten will und kein Bauchgefühl für das eigene Threat-Model haben mag, kann sie gegen das eigene Setup abgleichen.

Statt die Punkte still zu beheben oder zu verstecken: Wer PullMD self-hostet, soll selbst entscheiden können, ob sie für das eigene Threat-Model relevant sind.

Privacy-Slip in den Release-Notes

Beim Schreiben der v2.0-Release-Notes ist Claude ein interner Staging-Hostname versehentlich rausgerutscht: eine Test-Adresse aus meinem Heimnetz, die in einem public Release-Text nichts zu suchen hatte.

gh release edit war der Hotfix, innerhalb von Minuten nach dem Publish.

Ich hatte keinen automatischen Privacy-Lint im Release-Workflow. Das wäre einen Pre-Push-Hook wert. Steht als Backlog-Item.

Kein großer Schaden, aber ein sauberes Beispiel dafür, dass Release-Texte derselben Pre-Flight-Aufmerksamkeit brauchen wie Code-Commits.

:latest bleibt auf v1, für zwei Wochen

Die Entscheidung, den :latest-Docker-Tag nicht automatisch auf v2.0 zu schieben, war bewusst.

Wer image: aeternalabshq/pullmd:latest in der docker-compose.yml hat und docker compose pull macht, bekommt weiterhin v1. Das neue Auth-System erscheint nicht über Nacht auf einer Instanz, die jemand als Single-URL-Tool ohne Auth betreibt.

Wer v2 explizit will, pinnt auf :2. Ein Scheduled Agent flippt den :latest-Pointer am 16. Mai via PR: review-gated, kein Auto-Merge.

Der Commit 2bf9cb0 disablet die entsprechende CI-Zeile; der Agent kommentiert sie wieder ein.

Ich wollte mich selbst daran hindern, die neue Version voreilig zu empfehlen.

Ein Migrations-Bug und drei falsche CSS-Iterationen

In den letzten Stunden vor dem Tag gingen dann noch zwei Sachen schief.

Die erste: /api/history war nach der Migration leer, obwohl der Cache voll war. Alle Einträge lagen da, die Liste blieb trotzdem blank.

Der Backfill hatte cache.user_id korrekt gesetzt, aber user_fetches ausgelassen. Das ist die Tabelle, die festhält, wer welchen Cache-Eintrag abgerufen hat, und genau daraus liest /api/history.

Ein klassisches „hat lokal funktioniert, weil mein Test-Datensatz sauber war“-Problem. Wer frisch aufsetzt, hat die Tabelle von Anfang an gefüllt. Wer von v1 hochzieht, eben nicht.

Der Fix in Commit 808990a schreibt die fehlenden Zeilen nach, per LEFT JOIN ... WHERE uf.id IS NULL. Damit kann die Migration mehrfach laufen, ohne Dubletten anzulegen.

Kleineres Drama, anderes Register: die Login-Page.

Ich habe dreimal am falschen Ende geschraubt. Erst mehr Padding, dann flex: 1, dann flex: 1 wieder raus.

Schuld war ein button { width: 100% } aus dem alten Stylesheet. Die Regel galt global und zog das ganze Card-Layout mit.

Commit 69ccc60 räumt das mit einem .card-head-Flex-Pattern auf, dazu ein explizites width: auto für den Sprach-Umschalter. Die drei Fehlversuche habe ich per Force-Push aus der History geworfen, was hier ausnahmsweise in Ordnung war, weil der Branch noch nur lokal lag.

Die eigentliche Lehre

Der Pivot war weniger Code als erwartet. Argon2id plus Sessions plus API-Keys sind kein großer Architektur-Umbau: das sind vier Tabellen und ein Auth-Middleware-Layer. Vier Tage von v1.2.0 bis v2.0.0, inklusive Security-Review, Tests und Dokumentation.

Das eigentliche Lehrstück ist nicht das Auth-System selbst: Die Security-Review gehört mit ins Release. Wenn sechs NICE-TO-HAVE offen bleiben, sollen die Self-Hoster das wissen, nicht ich es allein.

Cliffhanger

v2.0 ship am 2. Mai abends. Die nächste logische Phase: OAuth für claude.ai-Web, Issue #6. Der Branch lag bereit als feat/oauth.

Was dann passiert ist, war eines der frustrierendsten Debugging-Erlebnisse seit langem. Und das Frustrierende war, dass ich lange am falschen Ende gesucht habe.


Teil 11 von 15 der PullMD Serie.

← PullMD Teil 10: Absichern, aber richtig | PullMD Teil 12: Spec-Compliant ist nicht genug →

Kommentare

Zum Kommentieren genügt eine E-Mail-Bestätigung oder ein GitHub-Login. Die Kommentare laufen auf einem eigenen Server, nicht bei einem Werbeanbieter — Details in der Datenschutzerklärung.