fix(PROJ-11): BUG-20 — stop generating guessable Matrix localparts

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0143yYvjesjHndBACoweyK67
This commit is contained in:
patrickandClaude Sonnet 5 committed 2026-09-11 22:55:50 +02:00
1 parent 121d80e707
commit f3361d695e
4 files changed
+23 -15

No files matched your search

+2 -3
View File
@@ -43,12 +43,11 @@ async function provisionMatrixUser(
lastName: string, lastName: string,
memberId: string, memberId: string,
): Promise<void> { ): Promise<void> {
const base = toLocalpart(firstName, lastName);
const password = randomBytes(24).toString("hex"); const password = randomBytes(24).toString("hex");
const displayName = `${firstName} ${lastName}`; const displayName = `${firstName} ${lastName}`;
for (let suffix = 0; suffix < 10; suffix++) { for (let attempt = 0; attempt < 10; attempt++) {
const localpart = suffix === 0 ? base : `${base}${suffix + 1}`; const localpart = toLocalpart();
try { try {
await createUser(localpart, { password, displayName }); await createUser(localpart, { password, displayName });
const matrixUserId = toMatrixUserId(localpart); const matrixUserId = toMatrixUserId(localpart);
+10 -8
View File
@@ -1,3 +1,5 @@
import { randomBytes } from "node:crypto";
const baseUrl = process.env.SYNAPSE_BASE_URL!; const baseUrl = process.env.SYNAPSE_BASE_URL!;
const adminToken = process.env.SYNAPSE_ADMIN_TOKEN!; const adminToken = process.env.SYNAPSE_ADMIN_TOKEN!;
const serverName = process.env.SYNAPSE_SERVER_NAME!; const serverName = process.env.SYNAPSE_SERVER_NAME!;
@@ -37,14 +39,14 @@ export function toMatrixUserId(localpart: string): string {
return `@${localpart}:${serverName}`; return `@${localpart}:${serverName}`;
} }
export function toLocalpart(firstName: string, lastName: string): string { // BUG-20: this used to be `vorname.nachname` — guessable from a member's real
const normalize = (part: string) => // name, which let anyone construct another org's Matrix ID and DM them
part // directly, bypassing the user_directory.enabled=false mitigation (BUG-2)
.toLowerCase() // entirely. The display name (set separately via createUser's displayName
.normalize("NFKD") // param) still shows the real name in room member lists; the localpart itself
.replace(/[̀-ͯ]/g, "") // carries no information about who the member is.
.replace(/[^a-z0-9]/g, ""); export function toLocalpart(): string {
return `${normalize(firstName)}.${normalize(lastName)}`; return `u${randomBytes(8).toString("hex")}`;
} }
export interface SynapseUser { export interface SynapseUser {
+2 -3
View File
@@ -41,12 +41,11 @@ const handlers: {
return; // already provisioned, idempotent no-op return; // already provisioned, idempotent no-op
} }
const base = toLocalpart(firstName, lastName);
const password = randomUUID().replace(/-/g, ""); const password = randomUUID().replace(/-/g, "");
const displayName = `${firstName} ${lastName}`; const displayName = `${firstName} ${lastName}`;
for (let suffix = 0; suffix < 10; suffix++) { for (let attempt = 0; attempt < 10; attempt++) {
const localpart = suffix === 0 ? base : `${base}${suffix + 1}`; const localpart = toLocalpart();
try { try {
await createUser(localpart, { password, displayName }); await createUser(localpart, { password, displayName });
await db await db
+9 -1
View File
@@ -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. **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). **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. 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. **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. **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.