Selbstständige Kontolöschung hinterlässt Zeilen, die noch auf die gelöschte Benutzer-ID verweisen

Zusammenfassung

DELETE /u/<username>.json wird erfolgreich ausgeführt und die Zeile in users ist verschwunden, aber in mehreren Tabellen bleiben Zeilen zurück, deren user_id noch die ID des gelöschten Benutzers enthält. Keiner dieser Tabellen liegt ein Fremdschlüssel, eine dependent:-Assoziation, ein Bereinigungsauftrag oder ein dokumentierter Aufbewahrungsgrund zugrunde.

Einer von ihnen ist ein schlichtes Versäumnis gegenüber der eigenen, ausdrücklichen Absicht des Löschers: UserDestroyer#delete_posts ist so geschrieben, dass topics.user_id auf null gesetzt wird, aber es iteriert über user.posts, dessen Standard-Scope gelöschte Beiträge ausschließt – daher verweist ein Thema, dessen erster Beitrag bereits gelöscht wurde, weiterhin auf den gelöschten Benutzer.

Zwei weitere, incoming_links und search_logs, sind Tabellen, die Discourses eigener Anonymisierungspfad als benutzerbezogene personenbezogene Daten behandelt (Jobs::AnonymizeUser#anonymize_ips), und die UserMerger explizit neu zuweist – aber die UserDestroyer überhaupt nicht anfasst.

Reproduziert unter v2026.7.1, mit Standardeinstellungen und einem Konto, das keine Beiträge besitzt. UserDestroyer#delete_posts ist auf main Stand 2026-09-25 unverändert.

Version

Discourse v2026.7.1 (selbst gehostet, lokale autorisierte Testinstanz). Vornehmlich Core; eine Plugin-Zeile wird unten separat aufgeführt.

Schritte zur Reproduktion

Der minimale Fall benötigt keine Änderung der Site-Einstellungen und keine Beiträge, funktioniert also unter der Standard-Einstellung delete user self max post count:

  1. Registriere ein normales Testkonto.

  2. Führe während der Anmeldung als dieses Konto eine Suche aus: GET /search.json?q=<einzigartiger Marker>.

  3. Besuche die HTML-Seite eines Themas während der Anmeldung, kommend von einem externen Referrer und mit dem Share-Parameter einer anderen Person: GET /t/<slug>/<topic_id>?u=<anderer-username> mit Referer: https://example.invalid/x.

  4. Besuche aus einem abgemeldeten Browser heraus ein beliebiges Thema mit dem Share-Parameter des Testkontos: GET /t/<slug>/<topic_id>?u=<test-account> mit einem externen Referer.

  5. Lösche als Testkonto das Konto: DELETE /u/<test-account>.json mit context=/my/preferences/account. Es wird {"success":"OK"} zurückgegeben und die Zeile in users verschwindet.

  6. Abfrage:

    
    SELECT id, user_id, current_user_id, ip_address, post_id
    
      FROM incoming_links WHERE user_id = <id> OR current_user_id = <id>;
    
    SELECT id, user_id, term FROM search_logs WHERE user_id = <id>;
    
    

Das beigefügte poc.py steuert die Schritte 1–5 über HTTP und gibt die exakte SQL-Abfrage für Schritt 6 aus.

Erwartetes Verhalten

Nachdem ein Benutzer sein eigenes Konto gelöscht hat, sollten Zeilen, die diesen Benutzer identifizieren, entfernt, auf null gesetzt oder neu zugewiesen werden – so wie UserDestroyer es bereits für posts.user_id (auf null gesetzt), categories.user_id (dem System neu zugewiesen) und topics.user_id (Soll auf null gesetzt werden) tut.

Tatsächliches Verhalten

Alles Folgende wurde in einem Durchlauf gemessen, nachdem DELETE /u/<username>.json 200 zurückgegeben und SELECT count(*) FROM users WHERE id = <id> 0 zurückgegeben hatte.

| Tabelle / Spalte | Verbliebene Zeilen | Inhalt der Zeile | Hinweise |

|—|—|—|—|

| incoming_links.user_id | 1 | die ID des gelöschten Benutzers + die ip_address des Besuchers + post_id | Klick auf Share-Link, der dem gelöschten Benutzer zugeordnet wird |

| incoming_links.current_user_id | 1 | die ID des gelöschten Benutzers + post_id + Referrer | der eigene Klickaufzeichnung des gelöschten Benutzers |

| search_logs.user_id | 1 | die ID des gelöschten Benutzers + der Suchbegriff, den sie eingegeben haben | wird für search query log max retention days aufbewahrt, Standard 365 |

| topics.user_id | 1 | die ID des gelöschten Benutzers | nur für ein Thema, dessen erster Beitrag bereits in den Papierkorb geworfen wurde – siehe unten |

| post_revisions.user_id | 2 | die ID des gelöschten Benutzers + modifications['raw'], d. h. ihre früheren Beitragsinhalte | |

| custom_emojis.user_id | 1 | die ID des gelöschten Benutzers | Upload-Zuordnung für eine site-weite Emoticon |

| topic_localizations.localizer_user_id | 1 | die ID des gelöschten Benutzers | |

| policy_users.user_id | 1 | die ID des gelöschten Benutzers + accepted_at | discourse-policy-Plugin |

Das anschließende Ausführen der mitgelieferten Bereinigungsaufträge ändert nichts: Ich habe jede Zeile nach Jobs::UpdateScoresForToday, Jobs::CleanUpUnusedRegisteredUserApiKeyClients und PostDestroyer.destroy_stubs erneut gelesen und sie waren alle noch da.

Zum Vergleich: Im selben Durchlauf haben diese tatsächlich funktioniert: Die Zeilen in users, user_profiles und email_tokens waren verschwunden, die Zeilen in chat_mentions, die auf das Konto zielten, wurden entfernt, und posts.user_id sowie die meisten topics.user_id wurden auf null gesetzt. Es handelt sich also um eine Liste spezifischer Versäumnisse, nicht um die Behauptung, dass die Löschung nichts bewirkt.

Wie die Zeilen erstellt wurden: Die Schritte 1–5 oben erstellen die Zeilen in incoming_links und search_logs durch normales Browsen. Die Zeilen in topics.user_id, post_revisions.user_id und policy_users stammen vom normalen Posten, dem Selbstlöschten eines Themas und dem Akzeptieren einer Richtlinie. Die Zeilen in custom_emojis und topic_localizations wurden direkt in der Test-Fixture angelegt, da das Hochladen einer benutzerdefinierten Emoticon und das Schreiben einer Themenlokalisierung Admin-/Plugin-Pfade sind und nicht etwas, das ein normales Konto tut; das gemessene Löschverhalten für sie ist ansonsten identisch.

Der Fall von topics.user_id ist ein konkreter Off-by-Scope-Bug

UserDestroyer#delete_posts ist wie folgt geschrieben:


user.posts.find_each do |post|

  ...

  if post.topic && post.is_first_post?

    Topic.unscoped.where(id: post.topic_id).update_all(user_id: nil)

  end

end

user.posts verwendet den Standard-Scope, der gelöschte Beiträge ausschließt. Wenn der Benutzer eines seiner eigenen Themen bereits gelöscht hatte – der Stub wurde später von PostDestroyer.destroy_stubs in den Papierkorb geworfen – wird dieser Beitrag nicht iteriert, sodass das Nullsetzen für sein Thema nie ausgeführt wird. In meinem Durchlauf endeten zwei der drei Themen des Betreffenden mit user_id IS NULL und das dritte, dessen erster Beitrag vor der Kontolöschung in den Papierkorb geworfen worden war, behielt user_id = <gelöschte ID>.

Post.unscoped.where(user_id: result.id).update_all(user_id: nil) einige Zeilen später verwendet unscoped, sodass der Beitrag selbst korrekt gelöst wird – nur das Thema wird verpasst.

Warum incoming_links und search_logs auffallen

Es handelt sich nicht um bloße verwaiste IDs. Discourse klassifiziert sie andernorts bereits als benutzerbezogene personenbezogene Daten:

  • app/jobs/regular/anonymize_user.rb:43 — IncomingLink.where(current_user_id: …).update_all(ip_address: new_ip), neben SearchLog, TopicLinkClick, TopicViewItem, UserProfileView.

  • app/services/user_merger.rb:348 — IncomingLink.where(user_id: …) und .where(current_user_id: …) werden beide auf den Zielbenutzer neu zugewiesen.

Das Anonymisieren eines Benutzers und das Zusammenführen eines Benutzers behandeln beide diese Tabellen. Das Löschen eines Benutzers nicht.

Auswirkung

Kein unbefugter Zugriff: Keine dieser Zeilen wird anonymen oder regulären Benutzern ausgeliefert, und ich behaupte auch nichts anderes. Die Auswirkung betrifft die Datenhaltung und die referentielle Integrität – nachdem ein Benutzer die Selbstlöschung ausgeübt hat, behält die Site weiterhin Zeilen, die auf ihn verweisen, einschließlich dessen, wonach er gesucht hat und welche Links er verfolgt hat, ohne Ablaufdatum im Zusammenhang mit der Löschung und ohne Operator-Tooling zum Bereinigen.

Das ist für jede Site, die eine Löschanfrage bearbeitet, unangenehm und widerspricht der eigenen Behandlung von posts, categories und topics im Löschpfad.

Vorschlag zur Behebung

  1. In UserDestroyer#delete_posts über user.posts.with_deleted iterieren (oder die Themen in einem separaten Topic.unscoped.where(user_id: user.id).update_all(user_id: nil)-Durchgang auf null setzen), damit bereits in den Papierkorb geworfene erste Beiträge ihr Thema nicht mehr zuordnen lassen.

  2. Die benutzerbezogenen Analysetabellen dem Löschpfad hinzufügen, so wie Jobs::AnonymizeUser sie bereits auflistet: IncomingLink.where(user_id:), IncomingLink.where(current_user_id:) und SearchLog.where(user_id:) löschen oder auf null setzen.

  3. Die verbleibenden Zuordnungsspalten auf null setzen — post_revisions.user_id, custom_emojis.user_id, topic_localizations.localizer_user_id — konsistent mit posts.user_id.

  4. policy_users gehört zu discourse-policy; es kann user_destroyer_on_content_deletion_callbacks hängen oder ein dependent: :destroy hinzufügen. Ich bin bereit, dies separat im Repository des Plugins zu eröffnen, wenn Sie das bevorzugen.

Ein Regressionstest könnte behaupten, dass nach UserDestroyer#destroy keine Zeile in dieser Menge noch die ID des gelöschten Benutzers enthält.

2 „Gefällt mir“