0
0
Fork 0
mirror of https://github.com/discourse/discourse.git synced 2026-08-06 13:08:40 +08:00
discourse/app/serializers/reviewable_score_serializer.rb
Isaac Janzen e56bd3ec58
SECURITY: Reviewable conversations expose post-voting flag PM excerpts to category moderators outside the PM (#41527)
## Summary

Prevent unauthorized access to private message excerpts in reviewable
responses by ensuring companion conversations are only serialized when
the viewer has permission to see the underlying topic. The patch also
ensures category moderators receive legitimate access to post-voting
flag conversations by including the relevant moderation groups in the
private message recipient list.

## Source

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

Co-authored-by: discourse-patch-triage
<272280883+discourse-patch-triage[bot]@users.noreply.github.com>
2026-07-07 15:37:22 -05:00

147 lines
4.2 KiB
Ruby
Vendored

# frozen_string_literal: true
class ReviewableScoreSerializer < ApplicationSerializer
REASONS_AND_SETTINGS = {
post_count: "approve_post_count",
trust_level: "approve_unless_trust_level",
group: "approve_unless_allowed_groups",
new_topics_unless_trust_level: "approve_new_topics_unless_trust_level",
new_topics_unless_allowed_groups: "approve_new_topics_unless_allowed_groups",
fast_typer: "first_post_typing_time",
auto_silence_regex: "auto_silence_first_post_regex",
staged: "approve_unless_staged",
must_approve_users: "must_approve_users",
invite_only: "invite_only",
email_spam: "email_in_spam_header",
suspect_user: "approve_suspect_users",
contains_media: "skip_media_review_groups",
}
attributes :id,
:score,
:agree_stats,
:reason,
:reason_type,
:reason_data,
:created_at,
:reviewed_at
attribute :status_for_database, key: :status
has_one :user, serializer: BasicUserSerializer, root: "users"
has_one :score_type, serializer: ReviewableScoreTypeSerializer
has_one :reviewable_conversation, serializer: ReviewableConversationSerializer
has_one :reviewed_by, serializer: BasicUserSerializer, root: "users"
def include_reviewable_conversation?
object.meta_topic.present? && scope&.can_see?(object.meta_topic)
end
def agree_stats
{
agreed: user.user_stat.flags_agreed,
disagreed: user.user_stat.flags_disagreed,
ignored: user.user_stat.flags_ignored,
}
end
def reason
return unless object.reason
link_text = setting_name_for_reason(object.reason)
link_text = I18n.t("reviewables.reasons.links.#{object.reason}", default: nil) if link_text.nil?
if link_text
link = build_link_for(object.reason, link_text)
if object.reason == "watched_word"
text = watched_word_reason(link)
else
text = I18n.t("reviewables.reasons.#{object.reason}", link: link, default: object.reason)
end
else
text = I18n.t("reviewables.reasons.#{object.reason}", default: object.reason)
end
text
end
def reason_type
object.reason
end
def reason_data
watched_words_found if object.reason == "watched_word"
end
def include_reason?
reason.present?
end
def setting_name_for_reason(reason)
setting_name = REASONS_AND_SETTINGS[reason.to_sym]
if setting_name.nil?
plugin_options = DiscoursePluginRegistry.reviewable_score_links
option = plugin_options.detect { |o| o[:reason] == reason.to_sym }
setting_name = option[:setting] if option
end
setting_name
end
private
def watched_word_reason(link)
words = watched_words_found
if words.nil? || words.empty?
I18n.t("reviewables.reasons.no_context.watched_word", link: link, default: "watched_word")
else
I18n.t(
"reviewables.reasons.watched_word",
link: link,
words: words.map { |w| CGI.escapeHTML(w) }.join(", "),
count: words.length,
default: "watched_word",
)
end
end
def watched_words_found
if object.context.nil?
# If the words weren't recorded, try to guess them based on current settings.
if object.reviewable.respond_to?(:post)
s = object.reviewable.post.raw.clone
s << " #{object.reviewable.post.topic.title}" if object.reviewable.post.post_number == 1
elsif object.reviewable.respond_to?(:payload)
s = object.reviewable.payload["raw"].clone
s << " #{object.reviewable.payload["title"]}" if object.reviewable.payload.key?("title")
end
words = WordWatcher.new(s).word_matches_across_all_actions
else
words = object.context.split(",")
end
words.map(&:downcase).uniq
end
def url_for(reason, text)
case reason
when "watched_word"
"#{Discourse.base_url}/admin/customize/watched_words"
when "category"
"#{Discourse.base_url}/c/#{object.reviewable.category&.slug}/edit/settings"
else
"#{Discourse.base_url}/admin/site_settings/category/all_results?filter=#{text}"
end
end
def build_link_for(reason, text)
return text.gsub("_", " ") unless scope.is_staff?
"<a href=\"#{url_for(reason, text)}\">#{text.gsub("_", " ")}</a>"
end
end