0
0
Fork 0
mirror of https://github.com/discourse/discourse.git synced 2026-08-06 06:24:48 +08:00
discourse/app/serializers/post_revision_serializer.rb
Renato Atilio 8d36da1b68 SECURITY: Hidden post revisions leak through adjacent visible diffs [backport 2026.5]
Backport of #733 to release/2026.5.

---

## Summary

Applies security patch from triage.

## Security advisory

https://github.com/discourse/discourse/security/advisories/GHSA-q456-4f8q-42vx

## Source

- Patch Triage: https://patch.discourse.org/patch-triage/965
- Original commit:

---

🤖 Auto-generated from the patch diff via Patch Triage. Review carefully before merging.

Co-authored-by: discourse-patch-triage <272280883+discourse-patch-triage[bot]@users.noreply.github.com>
2026-06-30 16:17:06 +02:00

397 lines
9.9 KiB
Ruby
Vendored

# frozen_string_literal: true
class PostRevisionSerializer < ApplicationSerializer
attributes :created_at,
:post_id,
# which revision is hidden
:previous_hidden,
:current_hidden,
# dynamic & based on the current scope
:first_revision,
:previous_revision,
:current_revision,
:next_revision,
:last_revision,
# used for display
:current_version,
:version_count,
# from the user
:username,
:display_username,
:acting_user_name,
:avatar_template,
# all the changes
:edit_reason,
:body_changes,
:title_changes,
:user_changes,
:reply_to_post_number_changes,
:tags_changes,
:category_id_changes,
:can_edit,
:diff_error
# Creates a field called field_name_changes with previous and
# current members if a field has changed in this revision
def self.add_compared_field(field)
changes_name = :"#{field}_changes"
attributes changes_name
define_method(changes_name) do
return if suppress_hidden_diffs?
{ previous: previous[field], current: current[field] }
end
define_method("include_#{changes_name}?") do
!suppress_hidden_diffs? && previous[field] != current[field]
end
end
add_compared_field :wiki
add_compared_field :post_type
add_compared_field :locale
def previous_hidden
return previous["hidden"] if scope.can_view_hidden_post_revisions?
previous_side_hidden?
end
def current_hidden
!!(adjacent_current && adjacent_current["hidden"])
end
def first_revision
revisions.first["revision"]
end
def previous_revision
@previous_revision ||=
revisions
.select { |r| r["revision"] >= first_revision }
.select { |r| r["revision"] < current_revision }
.last
.try(:[], "revision")
end
def current_revision
object.number
end
def next_revision
@next_revision ||=
revisions
.select { |r| r["revision"] <= last_revision }
.select { |r| r["revision"] > current_revision }
.first
.try(:[], "revision")
end
def last_revision
@last_revision ||= revisions.select { |r| r["revision"] <= post.version }.last["revision"]
end
def current_version
@current_version ||= revisions.select { |r| r["revision"] <= current_revision }.count + 1
end
def version_count
revisions.count
end
def username
user.username_lower
end
def display_username
user.username
end
def acting_user_name
user.name
end
def include_acting_user_name?
SiteSetting.enable_names?
end
def avatar_template
user.avatar_template
end
def can_edit
scope.can_edit?(object.post)
end
def edit_reason
current["edit_reason"]
end
def include_edit_reason?
scope.can_view_hidden_post_revisions? || current["revision"] == previous["revision"] + 1
end
def body_changes
return if suppress_hidden_diffs?
cooked_diff = DiscourseDiff.new(previous["cooked"], current["cooked"])
raw_diff = DiscourseDiff.new(previous["raw"], current["raw"])
{
inline: cooked_diff.inline_html,
side_by_side: cooked_diff.side_by_side_html,
side_by_side_markdown: raw_diff.side_by_side_markdown,
}
rescue ONPDiff::DiffLimitExceeded
@diff_error = true
nil
end
def title_changes
return if suppress_hidden_diffs?
prev = "<div>#{previous["title"] && CGI.escapeHTML(previous["title"])}</div>"
cur = "<div>#{current["title"] && CGI.escapeHTML(current["title"])}</div>"
# always show the title for post_number == 1
return if object.post.post_number > 1 && prev == cur
diff = DiscourseDiff.new(prev, cur)
{ inline: diff.inline_html, side_by_side: diff.side_by_side_html }
rescue ONPDiff::DiffLimitExceeded
@diff_error = true
nil
end
def diff_error
@diff_error || false
end
def include_diff_error?
@diff_error
end
def include_title_changes?
object.post.post_number == 1 && !suppress_hidden_diffs?
end
def user_changes
return if suppress_hidden_diffs?
prev = previous["user_id"]
cur = current["user_id"]
# if stuff is messed up, default to system
previous = User.find_by(id: prev) || Discourse.system_user
current = User.find_by(id: cur) || Discourse.system_user
{
previous: {
username: previous.username_lower,
display_username: previous.username,
avatar_template: previous.avatar_template,
},
current: {
username: current.username_lower,
display_username: current.username,
avatar_template: current.avatar_template,
},
}
end
def include_user_changes?
!suppress_hidden_diffs? && previous["user_id"] != current["user_id"]
end
def reply_to_post_number_changes
return if suppress_hidden_diffs?
{
previous: reply_to_info(previous["reply_to_post_number"]),
current: reply_to_info(current["reply_to_post_number"]),
}
end
def include_reply_to_post_number_changes?
!suppress_hidden_diffs? && previous["reply_to_post_number"] != current["reply_to_post_number"]
end
def tags_changes
return if suppress_hidden_diffs?
pre = filter_tags previous["tags"]
cur = filter_tags current["tags"]
pre == cur ? nil : { previous: pre, current: cur }
end
def include_tags_changes?
!suppress_hidden_diffs? && previous["tags"] != current["tags"] && scope.can_see_tags?(topic)
end
def category_id_changes
return if suppress_hidden_diffs?
pre = filter_category_id previous["category_id"]
cur = filter_category_id current["category_id"]
pre == cur ? nil : { previous: pre, current: cur }
end
def include_category_id_changes?
!suppress_hidden_diffs? && previous["category_id"] != current["category_id"]
end
def locale_changes
return if suppress_hidden_diffs?
prev = previous["locale"].presence
cur = current["locale"].presence
{ previous: prev, current: cur }
end
protected
def post
@post ||= object.post
end
def topic
@topic ||= object.post.topic
end
def revisions
@revisions ||=
all_revisions.select { |r| scope.can_view_hidden_post_revisions? || !r["hidden"] }
end
def all_revisions
return @all_revisions if @all_revisions
post_revisions =
PostRevision.where(post_id: object.post_id).order(number: :desc).limit(99).to_a.reverse
latest_modifications = {
"raw" => [post.raw],
"cooked" => [post.cooked],
"edit_reason" => [post.edit_reason],
"wiki" => [post.wiki],
"post_type" => [post.post_type],
"user_id" => [post.user_id],
"locale" => [post.locale],
"reply_to_post_number" => [post.reply_to_post_number],
}
# Retrieve any `tracked_topic_fields`
PostRevisor.tracked_topic_fields.each_key do |field|
next unless topic.respond_to?(field)
topic
.public_send(field)
.then do |value|
next if value.try(:proxy_association)
latest_modifications[field.to_s] = [value]
end
end
latest_modifications["featured_link"] = [
topic.featured_link,
] if SiteSetting.topic_featured_link_enabled
latest_modifications["tags"] = [topic.tags.map(&:name).sort]
post_revisions << PostRevision.new(
number: post_revisions.last.number + 1,
hidden: post.hidden,
modifications: latest_modifications,
)
@all_revisions = []
# backtrack
post_revisions.each do |pr|
revision = ActiveSupport::HashWithIndifferentAccess.new
revision[:revision] = pr.number
revision[:hidden] = pr.hidden
pr.modifications.each { |field, (value, _)| revision[field] = value }
@all_revisions << revision
end
# waterfall
(@all_revisions.count - 1)
.downto(1)
.each do |r|
cur = @all_revisions[r]
prev = @all_revisions[r - 1]
cur.each_key { |field| prev[field] = prev.has_key?(field) ? prev[field] : cur[field] }
end
@all_revisions
end
def adjacent_previous
@adjacent_previous ||=
all_revisions.select { |revision| revision["revision"] < current_revision }.last
end
def adjacent_current
@adjacent_current ||=
all_revisions.select { |revision| revision["revision"] > current_revision }.first
end
def previous_side_hidden?
!!(previous["hidden"] || (adjacent_previous && adjacent_previous["hidden"]))
end
def suppress_hidden_diffs?
!scope.can_view_hidden_post_revisions? && (previous_side_hidden? || current_hidden)
end
def previous
@previous ||= revisions.select { |r| r["revision"] <= current_revision }.last
end
def current
@current ||= revisions.select { |r| r["revision"] > current_revision }.first
end
def user
# if stuff goes pear shape attribute to system
object.user || Discourse.system_user
end
def hidden_tags
@hidden_tags ||= DiscourseTagging.hidden_tag_names(scope)
end
def filter_tags(tags)
tags.is_a?(Array) && tags.any? ? tags - hidden_tags : tags
end
def filter_category_id(category_id)
return if category_id.blank?
Category.secured(scope).find_by(id: category_id)&.id
end
def reply_to_info(post_number)
return nil if post_number.blank?
target = Post.where(topic_id: topic.id, post_number: post_number).first
info = { post_number: post_number }
# Only enrich with the target's author if the current viewer can see
# that post. Deleted posts, whispers, and posts in restricted
# categories must not leak their author through the revision history.
if target && scope.can_see?(target) && target.user
info[:username] = target.user.username_lower
info[:display_username] = target.user.username
info[:avatar_template] = target.user.avatar_template
end
info
end
end