0
0
Fork 0
mirror of https://github.com/discourse/discourse.git synced 2026-08-06 13:08:40 +08:00
discourse/app/serializers/user_card_serializer.rb
Isaac Janzen ac2074474f
SECURITY: Subscription contributors expose full user profile fields to anonymous viewers (#40891)
## Summary

Correctly enforce profile visibility restrictions in the subscriptions
contributors endpoint and user serializers. This prevents the exposure
of sensitive profile fields—including bio, location, and website—to
anonymous viewers when public profiles are disabled via site settings.
The fix also hardens the user and user card serializers to omit profile
details for unauthorized scopes as a defense-in-depth measure.

## Source

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

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

256 lines
5.7 KiB
Ruby
Vendored

# frozen_string_literal: true
class UserCardSerializer < BasicUserSerializer
include UserStatusMixin
attr_accessor :topic_post_count
def initialize(object, options = {})
super
options[:include_status] = true
end
def self.staff_attributes(*attrs)
attributes(*attrs)
attrs.each do |attr|
define_method "include_#{attr}?" do
scope.is_staff?
end
end
end
def self.private_attributes(*attrs)
attributes(*attrs)
attrs.each do |attr|
define_method "include_#{attr}?" do
if defined?(super)
super() && can_edit
else
can_edit
end
end
end
end
# attributes that are hidden for TL0 users when seen by anonymous
def self.untrusted_attributes(*attrs)
attributes(*attrs)
attrs.each do |attr|
method_name = "include_#{attr}?"
define_method(method_name) do
return false unless include_profile_details?
public_send(attr).present?
end
end
end
attributes :email,
:secondary_emails,
:unconfirmed_emails,
:last_posted_at,
:last_seen_at,
:created_at,
:ignored,
:muted,
:can_ignore_user,
:can_mute_user,
:can_send_private_messages,
:can_send_private_message_to_user,
:trust_level,
:moderator,
:admin,
:title,
:suspend_reason,
:suspended_till,
:silence_reason,
:silenced_till,
:badge_count,
:user_fields,
:custom_fields,
:topic_post_count,
:time_read,
:recent_time_read,
:primary_group_id,
:primary_group_name,
:flair_group_id,
:flair_name,
:flair_url,
:flair_bg_color,
:flair_color,
:featured_topic,
:timezone,
:pending_posts_count
untrusted_attributes :bio_excerpt, :website, :website_name, :location, :card_background_upload_url
staff_attributes :staged
has_many :featured_user_badges, embed: :ids, serializer: UserBadgeSerializer, root: :user_badges
delegate :user_stat, to: :object, private: true
delegate :pending_posts_count, to: :user_stat
def include_pending_posts_count?
scope.is_me?(object) || scope.is_staff?
end
def include_email?
(object.id && object.id == scope.user.try(:id)) || (scope.is_staff? && object.staged?)
end
alias_method :include_secondary_emails?, :include_email?
alias_method :include_unconfirmed_emails?, :include_email?
def bio_excerpt
object.user_profile.bio_excerpt(350, keep_newlines: true, keep_emoji_images: true)
end
def location
object.user_profile.location
end
def website
object.user_profile.website
end
def website_name
uri =
begin
URI(website.to_s)
rescue URI::Error
end
return if uri.nil? || uri.host.nil?
uri.host.sub(/\Awww\./, "") + uri.path
end
def ignored
scope_ignored_user_ids = scope.user&.ignored_user_ids || []
scope_ignored_user_ids.include?(object.id)
end
def muted
scope_muted_user_ids = scope.user&.muted_user_ids || []
scope_muted_user_ids.include?(object.id)
end
def can_mute_user
scope.can_mute_user?(object)
end
def can_ignore_user
scope.can_ignore_user?(object)
end
# Needed because 'send_private_message_to_user' will always return false
# when the current user is being serialized
def can_send_private_messages
scope.can_send_private_messages?
end
def can_send_private_message_to_user
scope.can_send_private_message?(object)
end
def include_suspend_reason?
scope.can_see_suspension_reason?(object) && object.suspended?
end
def include_suspended_till?
object.suspended?
end
def include_silence_reason?
scope.can_see_silencing_reason?(object) && object.silenced?
end
def include_silenced_till?
object.silenced?
end
def user_fields
allowed_keys = scope.allowed_user_field_ids(object)
object.user_fields(allowed_keys)
end
def include_user_fields?
include_profile_details? && user_fields.present?
end
def custom_fields
fields = custom_field_keys
if fields.present?
if object.custom_fields_preloaded?
{}.tap { |h| fields.each { |f| h[f] = object.custom_fields[f] } }
else
User.custom_fields_for_ids([object.id], fields)[object.id] || {}
end
else
{}
end
end
def include_topic_post_count?
topic_post_count.present?
end
def time_read
object.user_stat&.time_read
end
def recent_time_read
object.recent_time_read
end
def primary_group_name
object.primary_group&.name
end
def flair_name
object.flair_group&.name
end
def flair_url
object.flair_group&.flair_url
end
def flair_bg_color
object.flair_group&.flair_bg_color
end
def flair_color
object.flair_group&.flair_color
end
def include_featured_topic?
scope.can_see_topic?(object.user_profile.featured_topic)
end
def featured_topic
BasicTopicSerializer.new(object.user_profile.featured_topic, scope: scope, root: false).as_json
end
def include_timezone?
SiteSetting.display_local_time_in_user_card?
end
def timezone
object.user_option.timezone
end
def card_background_upload_url
object.card_background_upload&.url
end
private
def include_profile_details?
scope.public_can_see_profiles? && !scope.restrict_user_fields?(object)
end
def custom_field_keys
# Can be extended by other serializers
User.allowed_user_custom_fields(scope)
end
end