discourse-follow/app/models/user_follower.rb
Isaac Janzen 793b9aa967
Some checks failed
Discourse Plugin / ci (push) Has been cancelled
SECURITY: Follow feed exposes shared draft topic titles to followers (#187)
## 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:15 -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
#