自助账户删除后仍残留指向已删除用户 ID 的数据行

摘要

DELETE /u/<username>.json 请求成功执行,users 表中的对应行已被删除,但仍有多个表保留了 user_id 指向已销毁用户 ID 的行。这些表均未设置外键、dependent: 关联、清理任务或文档化的保留原因。

其中一个是销毁器自身声明意图的明显遗漏:UserDestroyer#delete_posts 旨在将 topics.user_id 置空,但它遍历的是 user.posts,而该集合的默认作用域排除了已废弃(trashed)的帖子——因此,如果某个主题的首帖已被删除,该主题仍会指向已销毁的用户。

另外两个表 incoming_links 和 search_logs 是 Discourse 自身的匿名化处理路径中视为与用户关联的个人数据(参见 Jobs::AnonymizeUser#anonymize_ips)的表,且 UserMerger 会明确重新指向目标用户——但 UserDestroyer 完全未触及这些表。

在 v2026.7.1 版本上复现,使用默认设置且该账户不拥有任何帖子。截至 2026-09-25,main 分支上的 UserDestroyer#delete_posts 未发生变更。

版本

Discourse v2026.7.1(自托管,本地授权测试实例)。主要涉及核心功能;一个插件的行记录在下方单独列出。

复现步骤

最小复现案例无需更改站点设置,也无需帖子,因此在默认的 delete user self max post count 设置下即可工作:

  1. 注册一个普通测试账户。

  2. 以该账户登录时,执行搜索:GET /search.json?q=<unique marker>。

  3. 以登录状态访问某个主题的 HTML 页面,来源为外部引用(referer),并附带他人的分享参数:GET /t/<slug>/<topic_id>?u=<other-username>,请求头包含 Referer: https://example.invalid/x。

  4. 从未登录的浏览器访问任何主题,附带测试账户的分享参数:GET /t/<slug>/<topic_id>?u=<test-account>,并带有外部 Referer。

  5. 以测试账户身份删除账户:DELETE /u/<test-account>.json,请求头包含 context=/my/preferences/account。接口返回 {"success":"OK"},且 users 行消失。

  6. 执行查询:

    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>;
    

附带的 poc.py 脚本通过 HTTP 驱动步骤 1–5,并打印步骤 6 的确切 SQL。

预期行为

用户删除自己的账户后,标识该用户的行应被删除、置空或重新分配——正如 UserDestroyer 已经对 posts.user_id(置空)、categories.user_id(重新分配给系统用户)和 topics.user_id(意图置空)所做的那样。

实际行为

以下所有数据均在同一次运行中测量,此时 DELETE /u/<username>.json 已返回 200,且 SELECT count(*) FROM users WHERE id = <id> 返回 0。

表 / 列 剩余行数 行内容 备注
incoming_links.user_id 1 已删除用户的 ID + 访客的 ip_address + post_id 归属于已删除用户的分享链接点击
incoming_links.current_user_id 1 已删除用户的 ID + post_id + 引用来源 已删除用户自身的点击记录
search_logs.user_id 1 已删除用户的 ID + 他们输入的搜索词 保留 search query log max retention days 天,默认 365 天
topics.user_id 1 已删除用户的 ID 仅针对首帖已被废弃的主题——见下文
post_revisions.user_id 2 已删除用户的 ID + modifications['raw'],即其早期帖子正文
custom_emojis.user_id 1 已删除用户的 ID 全站表情符号的上传者归属
topic_localizations.localizer_user_id 1 已删除用户的 ID
policy_users.user_id 1 已删除用户的 ID + accepted_at discourse-policy 插件

随后运行内置的清理任务不会改变任何结果:我在 Jobs::UpdateScoresForToday、Jobs::CleanUpUnusedRegisteredUserApiKeyClients 和 PostDestroyer.destroy_stubs 之后重新读取了所有行,它们仍然存在。

作为对比,在同一次运行中,以下部分确实正常工作:users、user_profiles 和 email_tokens 行已消失,指向该账户的 chat_mentions 行已被删除,且 posts.user_id 和大多数 topics.user_id 已被置空。因此,这是一份具体遗漏的清单,而非声称删除功能完全无效。

这些行的创建方式:上述步骤 1–5 通过普通浏览创建了 incoming_links 和 search_logs 行。topics.user_id、post_revisions.user_id 和 policy_users 行来自普通发帖、自行删除主题以及接受策略。custom_emojis 和 topic_localizations 行直接在测试夹具中播种,因为上传自定义表情符号和编写主题本地化属于管理员/插件路径,而非普通账户的操作;针对这些行的删除行为测量结果在其他方面是相同的。

topics.user_id 案例是一个具体的作用域偏移(off-by-scope)错误

UserDestroyer#delete_posts 编写如下:

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 使用默认作用域,该作用域排除了已废弃的帖子。如果用户已删除自己的某个主题——该桩(stub)随后被 PostDestroyer.destroy_stubs 废弃——则该帖子不会被遍历,因此针对其主题的置空操作永远不会执行。在我的运行中,该用户的三个主题中有两个最终 user_id IS NULL,而第三个(其首帖在账户删除前已被废弃)仍保留 user_id = <deleted id>。

几行后的 Post.unscoped.where(user_id: result.id).update_all(user_id: nil) 确实使用了 unscoped,因此帖子本身被正确分离——只有主题被遗漏。

为什么 incoming_links 和 search_logs 格外突出

它们不仅仅是孤立的 ID。Discourse 已在其他地方将它们归类为与用户关联的个人数据:

  • app/jobs/regular/anonymize_user.rb:43 — IncomingLink.where(current_user_id: …).update_all(ip_address: new_ip),与 SearchLog、TopicLinkClick、TopicViewItem、UserProfileView 并列。
  • app/services/user_merger.rb:348 — IncomingLink.where(user_id: …) 和 .where(current_user_id: …) 均被重新指向目标用户。

用户匿名化和用户合并都处理这些表。用户删除则不处理。

影响

无未授权访问:这些行均未向匿名用户或普通用户提供服务,我也未作此声称。影响在于数据保留和引用完整性——在用户行使自助删除权后,站点仍保留以该用户为键的行,包括他们搜索的内容和点击的链接,且没有与删除绑定的过期机制,也没有操作员工具来清除它们。

这对于任何处理删除请求的站点来说都是尴尬的,并且与删除路径对 posts、categories 和 topics 自身的处理方式不一致。

建议修复

  1. 在 UserDestroyer#delete_posts 中,遍历 user.posts.with_deleted(或在单独的 Topic.unscoped.where(user_id: user.id).update_all(user_id: nil) 路径中置空主题),以便已废弃的首帖不会使其主题保留归属。
  2. 按照 Jobs::AnonymizeUser 已经列举的方式,将与用户关联的分析表添加到销毁路径中:删除或置空 IncomingLink.where(user_id:)、IncomingLink.where(current_user_id:) 和 SearchLog.where(user_id:)。
  3. 与 posts.user_id 保持一致地置空剩余的归属列——post_revisions.user_id、custom_emojis.user_id、topic_localizations.localizer_user_id。
  4. policy_users 属于 discourse-policy;它可以挂钩 user_destroyer_on_content_deletion_callbacks 或添加 dependent: :destroy。如果您愿意,我很乐意在插件仓库中单独开启此问题。

回归测试规范可以断言,在 UserDestroyer#destroy 之后,该集合中的任何行都不再保留已销毁用户的 ID。

1 個讚