From f3361d695ef973473c56fe75f624da8aa6d7e980 Mon Sep 17 00:00:00 2001 From: patrick Date: Fri, 11 Sep 2026 22:55:50 +0200 Subject: [PATCH] =?UTF-8?q?fix(PROJ-11):=20BUG-20=20=E2=80=94=20stop=20gen?= =?UTF-8?q?erating=20guessable=20Matrix=20localparts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit toLocalpart() built vorname.nachname from real names, so anyone could construct another org's Matrix ID and DM them directly — completely sidestepping the user_directory.enabled=false mitigation from BUG-2, which only blocks *searching*, not addressing a known ID. Live-exploited during QA round 3 (cross-club DM succeeded). Now generates a random `u<16 hex chars>` localpart with no relation to the member's name. Display name (passed separately to createUser) still shows the real name in room member lists. sync-worker.ts and invitations.ts both used to retry on collision by appending a numeric suffix to the name-derived base; since there's no name relation left, collision retry now just draws a fresh random id. Verified end-to-end on talkhub/matrix: new org creator got @u747fcd631af6db20:matrix.perlbach24.de instead of a name-derived id. Test data cleaned up. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_0143yYvjesjHndBACoweyK67 --- backend/src/routes/invitations.ts | 5 ++--- backend/src/services/synapse.ts | 18 ++++++++++-------- backend/src/services/sync-worker.ts | 5 ++--- features/PROJ-11-chat.md | 10 +++++++++- 4 files changed, 23 insertions(+), 15 deletions(-) diff --git a/backend/src/routes/invitations.ts b/backend/src/routes/invitations.ts index 5876ce4..df82104 100644 --- a/backend/src/routes/invitations.ts +++ b/backend/src/routes/invitations.ts @@ -43,12 +43,11 @@ async function provisionMatrixUser( lastName: string, memberId: string, ): Promise { - const base = toLocalpart(firstName, lastName); const password = randomBytes(24).toString("hex"); const displayName = `${firstName} ${lastName}`; - for (let suffix = 0; suffix < 10; suffix++) { - const localpart = suffix === 0 ? base : `${base}${suffix + 1}`; + for (let attempt = 0; attempt < 10; attempt++) { + const localpart = toLocalpart(); try { await createUser(localpart, { password, displayName }); const matrixUserId = toMatrixUserId(localpart); diff --git a/backend/src/services/synapse.ts b/backend/src/services/synapse.ts index e8c1246..a382119 100644 --- a/backend/src/services/synapse.ts +++ b/backend/src/services/synapse.ts @@ -1,3 +1,5 @@ +import { randomBytes } from "node:crypto"; + const baseUrl = process.env.SYNAPSE_BASE_URL!; const adminToken = process.env.SYNAPSE_ADMIN_TOKEN!; const serverName = process.env.SYNAPSE_SERVER_NAME!; @@ -37,14 +39,14 @@ export function toMatrixUserId(localpart: string): string { return `@${localpart}:${serverName}`; } -export function toLocalpart(firstName: string, lastName: string): string { - const normalize = (part: string) => - part - .toLowerCase() - .normalize("NFKD") - .replace(/[̀-ͯ]/g, "") - .replace(/[^a-z0-9]/g, ""); - return `${normalize(firstName)}.${normalize(lastName)}`; +// BUG-20: this used to be `vorname.nachname` — guessable from a member's real +// name, which let anyone construct another org's Matrix ID and DM them +// directly, bypassing the user_directory.enabled=false mitigation (BUG-2) +// entirely. The display name (set separately via createUser's displayName +// param) still shows the real name in room member lists; the localpart itself +// carries no information about who the member is. +export function toLocalpart(): string { + return `u${randomBytes(8).toString("hex")}`; } export interface SynapseUser { diff --git a/backend/src/services/sync-worker.ts b/backend/src/services/sync-worker.ts index 7a21a40..a3dbd6d 100644 --- a/backend/src/services/sync-worker.ts +++ b/backend/src/services/sync-worker.ts @@ -41,12 +41,11 @@ const handlers: { return; // already provisioned, idempotent no-op } - const base = toLocalpart(firstName, lastName); const password = randomUUID().replace(/-/g, ""); const displayName = `${firstName} ${lastName}`; - for (let suffix = 0; suffix < 10; suffix++) { - const localpart = suffix === 0 ? base : `${base}${suffix + 1}`; + for (let attempt = 0; attempt < 10; attempt++) { + const localpart = toLocalpart(); try { await createUser(localpart, { password, displayName }); await db diff --git a/features/PROJ-11-chat.md b/features/PROJ-11-chat.md index 73f1b6c..7a14735 100644 --- a/features/PROJ-11-chat.md +++ b/features/PROJ-11-chat.md @@ -640,7 +640,7 @@ Was jetzt blockiert: **BUG-11 (High) — teilgemildert, nicht vollständig gelöst.** Start des App-weiten Unread-Clients läuft jetzt über `requestIdleCallback` (Fallback `setTimeout`), statt synchron beim Mount von `TeamsPage` zu feuern — nimmt die SDK-Ladezeit vom kritischen Pfad der Team-Liste. **Löst aber nicht** das eigentliche QA-Argument: Nutzer, die nie einen Chat öffnen, laden die ~830 KB SDK trotzdem, sobald sie die Team-Liste besuchen und mindestens ein Team einen Matrix-Raum hat. Eine vollständige Lösung bräuchte serverseitig aggregierte Ungelesen-Zahlen statt eines Browser-seitigen Matrix-Clients für reine Badge-Anzeige — als offene Architekturentscheidung dokumentiert, nicht stillschweigend als gelöst deklariert. -**Nicht in diesem Pass behoben:** BUG-20 (Klarnamen-Matrix-IDs, hebelt BUG-2-Mitigation aus — braucht eigene Entscheidung zu Localpart-Generierung, z.B. zufällige statt namensbasierte IDs), BUG-5/6/7/9/10/13/14/15/16/17/21/22. +**Nicht in diesem Pass behoben:** BUG-5/6/7/9/10/13/14/15/16/17/21/22. **Testdaten aufgeräumt:** Alle Test-Vereine/-User/-Räume aus Runde 3 (QA-Agent) sowie aus der eigenen Verifikation dieses Bugfix-Passes wurden gelöscht/erased (Postgres cascade + Synapse `deactivate?erase=true` + Room-Purge). @@ -648,6 +648,14 @@ Empfehlung: erneut `/qa`, mit Fokus auf BUG-18/19-Verifikation im Browser (nicht Bilanz: **1 Critical, 2 High neu; 2 High (BUG-11/12) unverändert offen.** Akzeptanzkriterien formal 7 PASS / 1 Vorbehalt / 0 FAIL — praktisch aber für alle per Einladung ins Team geholten Mitglieder wirkungslos, solange BUG-18 besteht. +## BUG-20-Fix (2026-09-11, separater Pass) + +**Behoben, live verifiziert.** `toLocalpart()` in `backend/src/services/synapse.ts` generierte bisher `vorname.nachname` — erratbar aus dem echten Namen, was die `user_directory.enabled: false`-Mitigation aus BUG-2 praktisch aushebelte (direktes Anschreiben ohne Suche). Jetzt: `toLocalpart()` erzeugt eine kryptographisch zufällige ID (`u` + 16 Hex-Zeichen aus `randomBytes(8)`), ohne jeden Bezug zum Namen. Der Anzeigename (`displayName`, separat an `createUser()` übergeben) zeigt weiterhin den echten Namen in Raum-Mitgliederlisten — nur die Matrix-ID selbst ist jetzt opak. + +Angepasst: `sync-worker.ts` und `invitations.ts` (beide riefen `toLocalpart(firstName, lastName)` mit anschließender Suffix-Schleife bei Kollision auf `base+suffix` — jetzt wird bei Kollision einfach eine neue Zufalls-ID gezogen, da der Namensbezug ohnehin weg ist). + +Live verifiziert: neuer Vereinsersteller bekommt `@u747fcd631af6db20:matrix.perlbach24.de` statt `@vorname.nachname:...` — kein Rückschluss auf den Namen möglich. Testdaten aufgeräumt. + **Empfohlene Fix-Reihenfolge:** BUG-18 → BUG-19 → BUG-12 → BUG-11 → BUG-20 (Architekturentscheidung: Raum-Erstellung sperren und/oder ein Synapse pro Verein) → BUG-1 (committen) → BUG-13/14 → BUG-5/15 → BUG-6/21 → Rest. **Nach den Fixes zwingend nachzuholen:** der bislang in allen drei Runden ausgefallene **Cross-Browser- und Responsive-Test (Chrome/Firefox/Safari; 375/768/1440 px)** inklusive XSS-Gegenprobe im echten Browser-Rendering.