discourse/lib/composer_messages_finder.rb
Alan Guo Xiang Tan b3ded56549
FIX: Enforce Guardian checks in ComposerMessagesFinder (#38005)
`ComposerMessagesController#index` accepts `topic_id` and `post_id`
parameters and passes them directly to `ComposerMessagesFinder`, which
loads the topic and post without verifying the current user can see
them. This means any authenticated user can probe arbitrary topic and
post IDs to trigger composer hints that should only appear for
accessible content. In particular,
`ComposerMessagesFinder#check_dont_feed_the_trolls` reveals whether a
post has active flags, leaking moderation state.
`ComposerMessagesFinder#check_dominating_topic` and
`ComposerMessagesFinder#check_get_a_room` can also create `UserHistory`
records referencing topics the user has no access to.

Add a `Guardian#can_see?` check in the `ComposerMessagesFinder`
constructor that nils out the topic when the user cannot see it. This is
safe because the class already handles a nil topic everywhere — it is
the normal state when no `topic_id` is provided. Add a second
`Guardian#can_see?` check in
`ComposerMessagesFinder#check_dont_feed_the_trolls` after the post is
loaded, returning early when the user cannot see the post.
2026-02-25 11:45:42 +08:00

204 lines
5.5 KiB
Ruby

# frozen_string_literal: true
class ComposerMessagesFinder
def initialize(user, details)
@user = user
@details = details
@topic = Topic.find_by(id: details[:topic_id]) if details[:topic_id]
@topic = nil if @topic && !@user.guardian.can_see?(@topic)
end
def self.check_methods
@check_methods ||= instance_methods.find_all { |m| m =~ /\Acheck\_/ }
end
def find
return if editing_post?
self.class.check_methods.each do |m|
msg = public_send(m)
return msg if msg.present?
end
nil
end
# Determines whether to show the user education text
def check_education_message
return if @topic&.private_message?
education_key = creating_topic? ? "education.new-topic" : "education.new-reply"
count = @user.topic_count + @user.post_count
if count < SiteSetting.educate_until_posts
return(
{
id: "education",
templateName: "education",
wait_for_typing: true,
body:
PrettyText.cook(
I18n.t(
education_key,
education_posts_text:
I18n.t("education.until_posts", count: SiteSetting.educate_until_posts),
site_name: SiteSetting.title,
base_path: Discourse.base_path,
),
),
}
)
end
nil
end
# New users have a limited number of replies in a topic
def check_new_user_many_replies
return unless replying? && @user.posted_too_much_in_topic?(@topic&.id)
{
id: "too_many_replies",
templateName: "education",
body:
PrettyText.cook(
I18n.t(
"education.too_many_replies",
newuser_max_replies_per_topic: SiteSetting.newuser_max_replies_per_topic,
),
),
}
end
def check_dominating_topic
return unless educate_reply?(:notified_about_dominating_topic)
if @topic.blank? || @topic.user_id == @user.id ||
@topic.posts_count < SiteSetting.summary_posts_required || @topic.private_message?
return
end
posts_by_user = @user.posts.where(topic_id: @topic.id).count
ratio = (posts_by_user.to_f / @topic.posts_count.to_f)
return if ratio < (SiteSetting.dominating_topic_minimum_percent.to_f / 100.0)
# Log the topic notification
UserHistory.create!(
action: UserHistory.actions[:notified_about_dominating_topic],
target_user_id: @user.id,
topic_id: @details[:topic_id],
)
{
id: "dominating_topic",
templateName: "dominating-topic",
wait_for_typing: true,
extraClass: "education-message dominating-topic-message",
body: PrettyText.cook(I18n.t("education.dominating_topic")),
}
end
def check_get_a_room(min_users_posted: 5)
return unless @user.guardian.can_send_private_messages?
return unless educate_reply?(:notified_about_get_a_room)
return if @details[:post_id].blank?
return if @topic.category&.read_restricted
reply_to_user_id = Post.where(id: @details[:post_id]).pluck(:user_id)[0]
# Users's last x posts in the topic
last_x_replies =
@topic
.posts
.where(user_id: @user.id)
.order("created_at desc")
.limit(SiteSetting.get_a_room_threshold)
.pluck(:reply_to_user_id)
.find_all { |uid| uid != @user.id && uid == reply_to_user_id }
return if last_x_replies.size != SiteSetting.get_a_room_threshold
return if @topic.posts.count("distinct user_id") < min_users_posted
UserHistory.create!(
action: UserHistory.actions[:notified_about_get_a_room],
target_user_id: @user.id,
topic_id: @details[:topic_id],
)
reply_username = User.where(id: last_x_replies[0]).pick(:username)
{
id: "get_a_room",
templateName: "get-a-room",
wait_for_typing: true,
reply_username: reply_username,
extraClass: "education-message get-a-room",
body:
PrettyText.cook(
I18n.t(
"education.get_a_room",
count: SiteSetting.get_a_room_threshold,
reply_username: reply_username,
base_path: Discourse.base_path,
),
),
}
end
def check_dont_feed_the_trolls
return if !replying?
post =
if @details[:post_id]
Post.find_by(id: @details[:post_id])
else
@topic&.first_post
end
return if post.blank?
return if !@user.guardian.can_see?(post)
flags = post.flags.active.group(:user_id).count
flagged_by_replier = flags[@user.id].to_i > 0
flagged_by_others = flags.values.sum >= SiteSetting.dont_feed_the_trolls_threshold
return if !flagged_by_replier && !flagged_by_others
{
id: "dont_feed_the_trolls",
templateName: "education",
wait_for_typing: false,
extraClass: "urgent",
body: PrettyText.cook(I18n.t("education.dont_feed_the_trolls")),
}
end
def self.user_not_seen_in_a_while(usernames)
User
.where(username_lower: usernames)
.where("last_seen_at < ?", SiteSetting.pm_warn_user_last_seen_months_ago.months.ago)
.pluck(:username)
.sort
end
private
def educate_reply?(type)
replying? && @details[:topic_id] && (@topic.present? && !@topic.private_message?) &&
(@user.post_count >= SiteSetting.educate_until_posts) &&
!UserHistory.exists_for_user?(@user, type, topic_id: @details[:topic_id])
end
def creating_topic?
@details[:composer_action] == "createTopic"
end
def replying?
@details[:composer_action] == "reply"
end
def editing_post?
@details[:composer_action] == "edit"
end
end