discourse/app/controllers/admin/users_controller.rb
Daniel Waterworth e9a8c059ec
SECURITY: Prevent large staff actions causing DoS
This commit operates at three levels of abstraction:

 1. We want to prevent user history rows from being unbounded in size.
    This commit adds rails validations to limit the sizes of columns on
    user_histories,

 2. However, we don't want to prevent certain actions from being
    completed if these columns are too long. In those cases, we truncate
    the values that are given and store the truncated versions,

 3. For endpoints that perform staff actions, we can further control
    what is permitted by explicitly validating the params that are given
    before attempting the action,
2024-03-15 14:37:15 +08:00

655 lines
18 KiB
Ruby

# frozen_string_literal: true
class Admin::UsersController < Admin::StaffController
MAX_SIMILAR_USERS = 10
before_action :fetch_user,
only: %i[
suspend
unsuspend
log_out
revoke_admin
revoke_moderation
grant_moderation
approve
activate
deactivate
silence
unsilence
trust_level
trust_level_lock
add_group
remove_group
primary_group
anonymize
merge
reset_bounce_score
disable_second_factor
delete_posts_batch
sso_record
]
def index
users = ::AdminUserIndexQuery.new(params).find_users
opts = {}
if params[:show_emails] == "true"
StaffActionLogger.new(current_user).log_show_emails(users, context: request.path)
opts[:emails_desired] = true
end
render_serialized(users, AdminUserListSerializer, opts)
end
def show
@user = User.find_by(id: params[:id])
raise Discourse::NotFound unless @user
similar_users = User.real.where.not(id: @user.id).where(ip_address: @user.ip_address)
render_serialized(
@user,
AdminDetailedUserSerializer,
root: false,
similar_users: similar_users.limit(MAX_SIMILAR_USERS),
similar_users_count: similar_users.count,
)
end
def delete_posts_batch
deleted_posts = @user.delete_posts_in_batches(guardian)
# staff action logs will have an entry for each post
render json: { posts_deleted: deleted_posts.length }
end
# DELETE action to delete penalty history for a user
def penalty_history
# We don't delete any history, we merely remove the action type
# with a removed type. It can still be viewed in the logs but
# will not affect TL3 promotions.
sql = <<~SQL
UPDATE user_histories
SET action = CASE
WHEN action = :silence_user THEN :removed_silence_user
WHEN action = :unsilence_user THEN :removed_unsilence_user
WHEN action = :suspend_user THEN :removed_suspend_user
WHEN action = :unsuspend_user THEN :removed_unsuspend_user
END
WHERE target_user_id = :user_id
AND action IN (
:silence_user,
:suspend_user,
:unsilence_user,
:unsuspend_user
)
SQL
DB.exec(
sql,
UserHistory
.actions
.slice(
:silence_user,
:suspend_user,
:unsilence_user,
:unsuspend_user,
:removed_silence_user,
:removed_unsilence_user,
:removed_suspend_user,
:removed_unsuspend_user,
)
.merge(user_id: params[:user_id].to_i),
)
render json: success_json
end
def suspend
guardian.ensure_can_suspend!(@user)
reason = params[:reason]
if reason && (!reason.is_a?(String) || reason.size > 300)
raise Discourse::InvalidParameters.new(:reason)
end
if @user.suspended?
suspend_record = @user.suspend_record
message =
I18n.t(
"user.already_suspended",
staff: suspend_record.acting_user.username,
time_ago:
AgeWords.time_ago_in_words(
suspend_record.created_at,
true,
scope: :"datetime.distance_in_words_verbose",
),
)
return render json: failed_json.merge(message: message), status: 409
end
params.require(%i[suspend_until reason])
all_users = [@user]
if Array === params[:other_user_ids]
if params[:other_user_ids].size > MAX_SIMILAR_USERS
raise Discourse::InvalidParameters.new(:other_user_ids)
end
all_users.concat(User.where(id: params[:other_user_ids]).to_a)
all_users.uniq!
end
user_history = nil
all_users.each do |user|
user.suspended_till = params[:suspend_until]
user.suspended_at = DateTime.now
message = params[:message]
User.transaction do
user.save!
user_history =
StaffActionLogger.new(current_user).log_user_suspend(
user,
params[:reason],
message: message,
post_id: params[:post_id],
)
end
user.logged_out
if message.present?
Jobs.enqueue(
:critical_user_email,
type: "account_suspended",
user_id: user.id,
user_history_id: user_history.id,
)
end
DiscourseEvent.trigger(
:user_suspended,
user: user,
reason: params[:reason],
message: message,
user_history: user_history,
post_id: params[:post_id],
suspended_till: params[:suspend_until],
suspended_at: DateTime.now,
)
end
perform_post_action
render_json_dump(
suspension: {
suspend_reason: params[:reason],
full_suspend_reason: user_history.try(:details),
suspended_till: @user.suspended_till,
suspended_at: @user.suspended_at,
suspended_by: BasicUserSerializer.new(current_user, root: false).as_json,
},
)
end
def unsuspend
guardian.ensure_can_suspend!(@user)
@user.suspended_till = nil
@user.suspended_at = nil
@user.save!
StaffActionLogger.new(current_user).log_user_unsuspend(@user)
DiscourseEvent.trigger(:user_unsuspended, user: @user)
render_json_dump(suspension: { suspended_till: nil, suspended_at: nil })
end
def log_out
if @user
@user.user_auth_tokens.destroy_all
@user.logged_out
render json: success_json
else
render json: { error: I18n.t("admin_js.admin.users.id_not_found") }, status: 404
end
end
def revoke_admin
guardian.ensure_can_revoke_admin!(@user)
@user.revoke_admin!
StaffActionLogger.new(current_user).log_revoke_admin(@user)
render_serialized(@user, AdminDetailedUserSerializer, root: false)
end
def grant_admin
result = run_second_factor!(SecondFactor::Actions::GrantAdmin)
if result.no_second_factors_enabled?
render json: success_json.merge(email_confirmation_required: true)
else
render json: success_json
end
end
def revoke_moderation
guardian.ensure_can_revoke_moderation!(@user)
@user.revoke_moderation!
StaffActionLogger.new(current_user).log_revoke_moderation(@user)
render_serialized(@user, AdminDetailedUserSerializer, root: false)
end
def grant_moderation
guardian.ensure_can_grant_moderation!(@user)
@user.grant_moderation!
StaffActionLogger.new(current_user).log_grant_moderation(@user)
render_serialized(@user, AdminDetailedUserSerializer, root: false)
end
def add_group
group = Group.find(params[:group_id].to_i)
raise Discourse::NotFound unless group
return render_json_error(I18n.t("groups.errors.can_not_modify_automatic")) if group.automatic
guardian.ensure_can_edit!(group)
group.add(@user)
GroupActionLogger.new(current_user, group).log_add_user_to_group(@user)
render body: nil
end
def remove_group
group = Group.find(params[:group_id].to_i)
raise Discourse::NotFound unless group
return render_json_error(I18n.t("groups.errors.can_not_modify_automatic")) if group.automatic
guardian.ensure_can_edit!(group)
if group.remove(@user)
GroupActionLogger.new(current_user, group).log_remove_user_from_group(@user)
end
render body: nil
end
def primary_group
if params[:primary_group_id].present?
primary_group_id = params[:primary_group_id].to_i
if group = Group.find(primary_group_id)
guardian.ensure_can_change_primary_group!(@user, group)
@user.primary_group_id = primary_group_id if group.user_ids.include?(@user.id)
end
else
@user.primary_group_id = nil
end
@user.save!
render body: nil
end
def trust_level
guardian.ensure_can_change_trust_level!(@user)
level = params[:level].to_i
if @user.manual_locked_trust_level.nil?
if [0, 1, 2].include?(level) && Promotion.public_send("tl#{level + 1}_met?", @user)
@user.manual_locked_trust_level = level
@user.save
elsif level == 3 && Promotion.tl3_lost?(@user)
@user.manual_locked_trust_level = level
@user.save
end
end
@user.change_trust_level!(level, log_action_for: current_user)
render_serialized(@user, AdminUserSerializer)
rescue Discourse::InvalidAccess => e
render_json_error(e.message)
end
def trust_level_lock
guardian.ensure_can_change_trust_level!(@user)
new_lock = params[:locked].to_s
return render_json_error I18n.t("errors.invalid_boolean") unless new_lock =~ /true|false/
@user.manual_locked_trust_level = (new_lock == "true") ? @user.trust_level : nil
@user.save
StaffActionLogger.new(current_user).log_lock_trust_level(@user)
Promotion.recalculate(@user, current_user)
render body: nil
end
def approve
guardian.ensure_can_approve!(@user)
reviewable =
ReviewableUser.find_by(target: @user) ||
Jobs::CreateUserReviewable.new.execute(user_id: @user.id).reviewable
reviewable.perform(current_user, :approve_user)
render body: nil
end
def approve_bulk
Reviewable.bulk_perform_targets(current_user, :approve_user, "ReviewableUser", params[:users])
render body: nil
end
def activate
guardian.ensure_can_activate!(@user)
# ensure there is an active email token
if !@user.email_tokens.active.exists?
@user.email_tokens.create!(email: @user.email, scope: EmailToken.scopes[:signup])
end
@user.activate
StaffActionLogger.new(current_user).log_user_activate(@user, I18n.t("user.activated_by_staff"))
render json: success_json
end
def deactivate
guardian.ensure_can_deactivate!(@user)
@user.deactivate(current_user)
StaffActionLogger.new(current_user).log_user_deactivate(
@user,
I18n.t("user.deactivated_by_staff"),
params.slice(:context),
)
refresh_browser @user
render json: success_json
end
def silence
guardian.ensure_can_silence_user! @user
reason = params[:reason]
if reason && (!reason.is_a?(String) || reason.size > 300)
raise Discourse::InvalidParameters.new(:reason)
end
if @user.silenced?
silenced_record = @user.silenced_record
message =
I18n.t(
"user.already_silenced",
staff: silenced_record.acting_user.username,
time_ago:
AgeWords.time_ago_in_words(
silenced_record.created_at,
true,
scope: :"datetime.distance_in_words_verbose",
),
)
return render json: failed_json.merge(message: message), status: 409
end
all_users = [@user]
if Array === params[:other_user_ids]
if params[:other_user_ids].size > MAX_SIMILAR_USERS
raise Discourse::InvalidParameters.new(:other_user_ids)
end
all_users.concat(User.where(id: params[:other_user_ids]).to_a)
all_users.uniq!
end
user_history = nil
all_users.each do |user|
silencer =
UserSilencer.new(
user,
current_user,
silenced_till: params[:silenced_till],
reason: params[:reason],
message_body: params[:message],
keep_posts: true,
post_id: params[:post_id],
)
if silencer.silence
user_history = silencer.user_history
Jobs.enqueue(
:critical_user_email,
type: "account_silenced",
user_id: user.id,
user_history_id: user_history.id,
)
end
end
perform_post_action
render_json_dump(
silence: {
silenced: true,
silence_reason: user_history.try(:details),
silenced_till: @user.silenced_till,
silenced_at: @user.silenced_at,
silenced_by: BasicUserSerializer.new(current_user, root: false).as_json,
},
)
end
def unsilence
guardian.ensure_can_unsilence_user! @user
UserSilencer.unsilence(@user, current_user)
render_json_dump(
unsilence: {
silenced: false,
silence_reason: nil,
silenced_till: nil,
silenced_at: nil,
},
)
end
def disable_second_factor
guardian.ensure_can_disable_second_factor!(@user)
user_second_factor = @user.user_second_factors
user_security_key = @user.security_keys
raise Discourse::InvalidParameters if user_second_factor.empty? && user_security_key.empty?
user_second_factor.destroy_all
user_security_key.destroy_all
StaffActionLogger.new(current_user).log_disable_second_factor_auth(@user)
Jobs.enqueue(:critical_user_email, type: "account_second_factor_disabled", user_id: @user.id)
render json: success_json
end
def destroy
user = User.find_by(id: params[:id].to_i)
guardian.ensure_can_delete_user!(user)
options = params.slice(:context, :delete_as_spammer)
%i[delete_posts block_email block_urls block_ip].each do |param_name|
options[param_name] = ActiveModel::Type::Boolean.new.cast(params[param_name])
end
options[:prepare_for_destroy] = true
hijack do
begin
if UserDestroyer.new(current_user).destroy(user, options)
render json: { deleted: true }
else
render json: {
deleted: false,
user: AdminDetailedUserSerializer.new(user, root: false).as_json,
}
end
rescue UserDestroyer::PostsExistError
render json: {
deleted: false,
message:
I18n.t(
"user.cannot_delete_has_posts",
username: user.username,
count: user.posts.joins(:topic).count,
),
},
status: 403
end
end
end
def badges
end
def tl3_requirements
end
def ip_info
params.require(:ip)
render json: DiscourseIpInfo.get(params[:ip], resolve_hostname: true)
end
def sync_sso
return render body: nil, status: 404 unless SiteSetting.enable_discourse_connect
begin
sso =
DiscourseConnect.parse(
"sso=#{params[:sso]}&sig=#{params[:sig]}",
secure_session: secure_session,
)
rescue DiscourseConnect::ParseError
return(
render json: failed_json.merge(message: I18n.t("discourse_connect.login_error")),
status: 422
)
end
begin
user = sso.lookup_or_create_user
DiscourseEvent.trigger(:sync_sso, user)
render_serialized(user, AdminDetailedUserSerializer, root: false)
rescue ActiveRecord::RecordInvalid => ex
render json: failed_json.merge(message: ex.message), status: 403
rescue DiscourseConnect::BlankExternalId => ex
render json: failed_json.merge(message: I18n.t("discourse_connect.blank_id_error")),
status: 422
end
end
def delete_other_accounts_with_same_ip
params.require(:ip)
params.require(:exclude)
params.require(:order)
user_destroyer = UserDestroyer.new(current_user)
options = {
delete_posts: true,
block_email: true,
block_urls: true,
block_ip: true,
delete_as_spammer: true,
context: I18n.t("user.destroy_reasons.same_ip_address", ip_address: params[:ip]),
}
AdminUserIndexQuery
.new(params)
.find_users(50)
.each { |user| user_destroyer.destroy(user, options) }
render json: success_json
end
def total_other_accounts_with_same_ip
params.require(:ip)
params.require(:exclude)
params.require(:order)
render json: { total: AdminUserIndexQuery.new(params).count_users }
end
def anonymize
guardian.ensure_can_anonymize_user!(@user)
opts = {}
opts[:anonymize_ip] = params[:anonymize_ip] if params[:anonymize_ip].present?
if user = UserAnonymizer.new(@user, current_user, opts).make_anonymous
render json: success_json.merge(username: user.username)
else
render json:
failed_json.merge(user: AdminDetailedUserSerializer.new(user, root: false).as_json)
end
end
def merge
target_username = params.require(:target_username)
target_user = User.find_by_username(target_username)
raise Discourse::NotFound if target_user.blank?
guardian.ensure_can_merge_users!(@user, target_user)
Jobs.enqueue(
:merge_user,
user_id: @user.id,
target_user_id: target_user.id,
current_user_id: current_user.id,
)
render json: success_json
end
def reset_bounce_score
guardian.ensure_can_reset_bounce_score!(@user)
@user.user_stat&.reset_bounce_score!
StaffActionLogger.new(current_user).log_reset_bounce_score(@user)
render json: success_json
end
def sso_record
guardian.ensure_can_delete_sso_record!(@user)
@user.single_sign_on_record.destroy!
render json: success_json
end
private
def perform_post_action
return unless params[:post_id].present? && params[:post_action].present?
if post = Post.where(id: params[:post_id]).first
case params[:post_action]
when "delete"
PostDestroyer.new(current_user, post).destroy if guardian.can_delete_post_or_topic?(post)
when "delete_replies"
if guardian.can_delete_post_or_topic?(post)
PostDestroyer.delete_with_replies(current_user, post)
end
when "edit"
revisor = PostRevisor.new(post)
# Take what the moderator edited in as gospel
revisor.revise!(
current_user,
{ raw: params[:post_edit] },
skip_validations: true,
skip_revision: true,
)
end
end
end
def fetch_user
@user = User.find_by(id: params[:user_id])
raise Discourse::NotFound unless @user
end
def refresh_browser(user)
MessageBus.publish "/file-change", ["refresh"], user_ids: [user.id]
end
end