discourse-follow/app/models/user_follower.rb
discourse-patch-triage[bot] 3cd46af512
SECURITY: Follow feed exposes shared draft topic titles to followers [backport 2026.6] (#190)
Backport of #187 to d-compat/2026.6.

---

## Summary

Prevent the leakage of shared draft topic titles and post content in the follow feed by correctly filtering out topics with associated shared drafts for unauthorized users. The query now explicitly checks the viewer's shared draft visibility before returning posts from followed users.

## Source

- Patch Triage: https://patch.discourse.org/patch-triage/1405

Co-authored-by: discourse-patch-triage <272280883+discourse-patch-triage[bot]@users.noreply.github.com>
2026-07-10 09:57:55 -05:00

100 lines
3.5 KiB
Ruby

# frozen_string_literal: true
class UserFollower < ActiveRecord::Base
def self.posts_for(user, current_user:, limit: nil, created_before: nil, created_after: nil)
visible_post_types = [Post.types[:regular], Post.types[:moderator_action]]
visible_post_types << Post.types[:whisper] if current_user.staff?
results =
Post
.joins(:topic, :user, topic: :category)
.joins("INNER JOIN user_followers ON user_followers.user_id = users.id")
.preload(:user, topic: :category)
.where(post_type: visible_post_types)
.where("topics.archetype != ?", Archetype.private_message)
.where("topics.visible")
.where("user_followers.follower_id = ?", user.id)
.where(action_code: nil)
.order(created_at: :desc)
guardian = Guardian.new(current_user)
results = filter_opted_out_users(results)
results = filter_shared_drafts(results, guardian)
results = guardian.filter_allowed_categories(results)
results = guardian.filter_hidden_posts(results)
results = results.limit(limit) if limit
results = results.where("posts.created_at < ?", created_before) if created_before
results = results.where("posts.created_at > ?", created_after) if created_after
results
end
def self.topics_for(user, current_user:, limit: nil, created_before: nil, created_after: nil)
results =
Topic
.joins(:user)
.joins("INNER JOIN user_followers ON user_followers.user_id = users.id")
.preload(:user, :category)
.where("user_followers.follower_id = ?", user.id)
.listable_topics
.visible
.order(created_at: :desc)
results = filter_opted_out_users(results)
results = Guardian.new(current_user).filter_allowed_categories(results)
results = results.limit(limit) if limit
results = results.where("topics.created_at < ?", created_before) if created_before
results = results.where("topics.created_at > ?", created_after) if created_after
results
end
def self.filter_shared_drafts(relation, guardian)
return relation if guardian.can_see_shared_draft?
relation.where.not(topic_id: SharedDraft.select(:topic_id))
end
def self.filter_opted_out_users(relation)
truthy_values = sanitize_sql(["?", HasCustomFields::Helpers::CUSTOM_FIELD_TRUE])
if SiteSetting.default_allow_people_to_follow_me
relation = relation.joins(<<~SQL)
LEFT OUTER JOIN user_custom_fields ucf
ON ucf.name = 'allow_people_to_follow_me'
AND ucf.user_id = users.id
AND ucf.value NOT IN (#{truthy_values})
SQL
relation = relation.where("ucf.user_id IS NULL")
else
relation = relation.joins(<<~SQL)
INNER JOIN user_custom_fields ucf
ON ucf.name = 'allow_people_to_follow_me'
AND ucf.user_id = users.id
AND ucf.value IN (#{truthy_values})
SQL
end
relation.joins("LEFT OUTER JOIN user_options uo ON uo.user_id = users.id").where(
"uo.user_id IS NULL OR NOT uo.hide_profile",
)
end
belongs_to :follower_user, class_name: "User", foreign_key: :follower_id
belongs_to :followed_user, class_name: "User", foreign_key: :user_id
end
# == Schema Information
#
# Table name: user_followers
#
# id :bigint not null, primary key
# user_id :bigint not null
# follower_id :bigint not null
# level :integer not null
# created_at :datetime not null
# updated_at :datetime not null
#
# Indexes
#
# index_user_followers_on_user_id_and_follower_id (user_id,follower_id) UNIQUE
#