discourse/app/controllers/user_actions_controller.rb
Alan Guo Xiang Tan 14ff4decf4 SECURITY: Enforce Guardian checks in UserActionsController#show
What is the problem?

- `UserActionsController#index` enforces user-level visibility via
  `Guardian#can_see_profile?` and `Guardian#can_see_user_actions?`
  before returning results
- `UserActionsController#show` bypasses these checks entirely, only
  applying content-level filters (topic/post/category visibility)
  through `UserAction.stream_item`
- This allows retrieval of activity records for users with hidden
  profiles or when the activity tab is globally disabled, via
  sequential ID enumeration

What is the solution?

- After fetching the stream item, look up the activity owner via
  `User.find_by` and apply `Guardian#can_see_profile?` and
  `Guardian#can_see_user_actions?` checks, matching the `index`
  action's authorization pattern
- All denial paths raise `Discourse::NotFound` to prevent
  enumeration of record existence
- Add test coverage for `UserActionsController#show` covering hidden
  profiles, the `hide_user_activity_tab` setting, private action
  types, staff bypass, and self-access
2026-03-19 15:21:28 +00:00

69 lines
2.1 KiB
Ruby

# frozen_string_literal: true
class UserActionsController < ApplicationController
def index
user_actions_params.require(:username)
user =
fetch_user_from_params(
include_inactive:
current_user.try(:staff?) || (current_user && SiteSetting.show_inactive_accounts),
)
offset = [0, user_actions_params[:offset].to_i].max
action_types = (user_actions_params[:filter] || "").split(",").map(&:to_i)
limit = user_actions_params.fetch(:limit, 30).to_i
ensure_user_actions_visible!(user, action_types)
if action_types.empty? && !guardian.can_see_user_actions?(user, UserAction.private_types)
action_types = UserAction.types.values - UserAction.private_types
end
opts = {
user_id: user.id,
user: user,
offset: offset,
limit: limit,
action_types: action_types,
guardian: guardian,
ignore_private_messages: params[:filter].blank?,
acting_username: params[:acting_username],
}
stream = UserAction.stream(opts).to_a
response = { user_actions: serialize_data(stream, UserActionSerializer) }
if guardian.can_lazy_load_categories?
category_ids = stream.map(&:category_id).compact.uniq
categories = Category.secured(guardian).with_parents(category_ids)
response[:categories] = serialize_data(categories, CategoryBadgeSerializer)
end
render json: response
end
def show
params.require(:id)
stream_item = UserAction.stream_item(params[:id], guardian)
raise Discourse::NotFound if stream_item.blank?
user = User.find_by(id: stream_item.target_user_id)
raise Discourse::NotFound if user.blank?
ensure_user_actions_visible!(user, [stream_item.action_type])
render_serialized(stream_item, UserActionSerializer)
end
private
def ensure_user_actions_visible!(user, action_types)
raise Discourse::NotFound unless guardian.can_see_profile?(user)
raise Discourse::NotFound unless guardian.can_see_user_actions?(user, action_types)
end
def user_actions_params
@user_actions_params ||= params.permit(:username, :filter, :offset, :acting_username, :limit)
end
end