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,
This commit is contained in:
Daniel Waterworth 2024-02-22 13:47:15 -06:00 committed by Nat
parent 003b80e62f
commit 8cade1e825
No known key found for this signature in database
GPG Key ID: 4938B35D927EC773
8 changed files with 167 additions and 19 deletions

View File

@ -107,6 +107,11 @@ class Admin::UsersController < Admin::StaffController
def suspend def suspend
guardian.ensure_can_suspend!(@user) 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? if @user.suspended?
suspend_record = @user.suspend_record suspend_record = @user.suspend_record
@ -128,6 +133,10 @@ class Admin::UsersController < Admin::StaffController
all_users = [@user] all_users = [@user]
if Array === params[:other_user_ids] 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.concat(User.where(id: params[:other_user_ids]).to_a)
all_users.uniq! all_users.uniq!
end end
@ -360,6 +369,11 @@ class Admin::UsersController < Admin::StaffController
def silence def silence
guardian.ensure_can_silence_user! @user 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? if @user.silenced?
silenced_record = @user.silenced_record silenced_record = @user.silenced_record
@ -379,6 +393,10 @@ class Admin::UsersController < Admin::StaffController
all_users = [@user] all_users = [@user]
if Array === params[:other_user_ids] 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.concat(User.where(id: params[:other_user_ids]).to_a)
all_users.uniq! all_users.uniq!
end end

View File

@ -5,18 +5,26 @@ class ExportCsvController < ApplicationController
def export_entity def export_entity
guardian.ensure_can_export_entity!(export_params[:entity]) guardian.ensure_can_export_entity!(export_params[:entity])
entity = export_params[:entity]
raise Discourse::InvalidParameters.new(:entity) unless entity.is_a?(String) && entity.size < 100
if export_params[:entity] == "user_archive" (export_params[:args] || {}).each do |key, value|
unless value.is_a?(String) && value.size < 100
raise Discourse::InvalidParameters.new("args.#{key}")
end
end
if entity == "user_archive"
Jobs.enqueue(:export_user_archive, user_id: current_user.id, args: export_params[:args]) Jobs.enqueue(:export_user_archive, user_id: current_user.id, args: export_params[:args])
else else
Jobs.enqueue( Jobs.enqueue(
:export_csv_file, :export_csv_file,
entity: export_params[:entity], entity: entity,
user_id: current_user.id, user_id: current_user.id,
args: export_params[:args], args: export_params[:args],
) )
end end
StaffActionLogger.new(current_user).log_entity_export(export_params[:entity]) StaffActionLogger.new(current_user).log_entity_export(entity)
render json: success_json render json: success_json
rescue Discourse::InvalidAccess rescue Discourse::InvalidAccess
render_json_error I18n.t("csv_export.rate_limit_error") render_json_error I18n.t("csv_export.rate_limit_error")

View File

@ -11,6 +11,23 @@ class UserHistory < ActiveRecord::Base
belongs_to :topic belongs_to :topic
belongs_to :category belongs_to :category
# Each value in the context should be shorter than this
MAX_CONTEXT_LENGTH = 50_000
# We often store multiple values in details, particularly during post edits
# Let's allow space for 2 values + a little extra for padding
MAX_DETAILS_LENGTH = 110_000
MAX_JSON_LENGTH = 300_000
validates :details, length: { maximum: MAX_DETAILS_LENGTH }
validates :context, length: { maximum: MAX_CONTEXT_LENGTH }
validates :subject, length: { maximum: MAX_CONTEXT_LENGTH }
validates :ip_address, length: { maximum: MAX_CONTEXT_LENGTH }
validates :email, length: { maximum: MAX_CONTEXT_LENGTH }
validates :previous_value, length: { maximum: MAX_JSON_LENGTH }
validates :new_value, length: { maximum: MAX_JSON_LENGTH }
validates_presence_of :action validates_presence_of :action
scope :only_staff_actions, -> { where("action IN (?)", UserHistory.staff_action_ids) } scope :only_staff_actions, -> { where("action IN (?)", UserHistory.staff_action_ids) }

View File

@ -38,7 +38,7 @@ class StaffActionLogger
StaffActionLogger.base_attrs.each do |attr| StaffActionLogger.base_attrs.each do |attr|
attrs[attr] = details.delete(attr) if details.has_key?(attr) attrs[attr] = details.delete(attr) if details.has_key?(attr)
end end
attrs[:details] = details.map { |r| "#{r[0]}: #{r[1]}" }.join("\n") attrs[:details] = details.map { |r| "#{r[0]}: #{truncate(r[1].to_s)}" }.join("\n")
attrs[:acting_user_id] = @admin.id attrs[:acting_user_id] = @admin.id
attrs[:action] = UserHistory.actions[:custom_staff] attrs[:action] = UserHistory.actions[:custom_staff]
attrs[:custom_type] = custom_type attrs[:custom_type] = custom_type
@ -80,7 +80,7 @@ class StaffActionLogger
"user: #{username} (#{name})", "user: #{username} (#{name})",
"topic: #{topic_title}", "topic: #{topic_title}",
"post_number: #{deleted_post.post_number}", "post_number: #{deleted_post.post_number}",
"raw: #{deleted_post.raw}", "raw: #{truncate(deleted_post.raw)}",
] ]
UserHistory.create!( UserHistory.create!(
@ -105,7 +105,7 @@ class StaffActionLogger
] ]
if first_post = topic.ordered_posts.with_deleted.first if first_post = topic.ordered_posts.with_deleted.first
details << "raw: #{first_post.raw}" details << "raw: #{truncate(first_post.raw)}"
end end
UserHistory.create!( UserHistory.create!(
@ -179,7 +179,7 @@ class StaffActionLogger
params(opts).merge( params(opts).merge(
action: UserHistory.actions[:post_edit], action: UserHistory.actions[:post_edit],
post_id: post.id, post_id: post.id,
details: "#{opts[:old_raw]}\n\n---\n\n#{post.raw}", details: "#{truncate(opts[:old_raw])}\n\n---\n\n#{truncate(post.raw)}",
), ),
) )
end end
@ -261,15 +261,12 @@ class StaffActionLogger
raise Discourse::InvalidParameters.new(:new_theme) unless new_theme raise Discourse::InvalidParameters.new(:new_theme) unless new_theme
new_json = theme_json(new_theme) new_json = theme_json(new_theme)
old_json, new_json = strip_duplicates(old_json, new_json) old_json, new_json = strip_duplicates(old_json, new_json)
UserHistory.create!( UserHistory.create!(
params(opts).merge( params(opts).merge(json_params(old_json, new_json)).merge(
action: UserHistory.actions[:change_theme], action: UserHistory.actions[:change_theme],
subject: new_theme.name, subject: new_theme.name,
previous_value: old_json,
new_value: new_json,
), ),
) )
end end
@ -277,10 +274,9 @@ class StaffActionLogger
def log_theme_destroy(theme, opts = {}) def log_theme_destroy(theme, opts = {})
raise Discourse::InvalidParameters.new(:theme) unless theme raise Discourse::InvalidParameters.new(:theme) unless theme
UserHistory.create!( UserHistory.create!(
params(opts).merge( params(opts).merge(json_params(theme_json(theme), nil)).merge(
action: UserHistory.actions[:delete_theme], action: UserHistory.actions[:delete_theme],
subject: theme.name, subject: theme.name,
previous_value: theme_json(theme),
), ),
) )
end end
@ -804,7 +800,7 @@ class StaffActionLogger
"rejected_at: #{rejected_at}", "rejected_at: #{rejected_at}",
"user: #{username} (#{name})", "user: #{username} (#{name})",
"topic: #{topic_title}", "topic: #{topic_title}",
"raw: #{reviewable.payload["raw"]}", "raw: #{truncate(reviewable.payload["raw"])}",
] ]
UserHistory.create!( UserHistory.create!(
@ -1017,6 +1013,15 @@ class StaffActionLogger
private private
def json_params(previous_value, new_value)
if (previous_value && previous_value.length > UserHistory::MAX_JSON_LENGTH) ||
(new_value && new_value.length > UserHistory::MAX_JSON_LENGTH)
{ context: I18n.t("staff_action_logs.json_too_long") }
else
{ previous_value: previous_value, new_value: new_value }
end
end
def get_changes(changes) def get_changes(changes)
return unless changes return unless changes
@ -1046,4 +1051,12 @@ class StaffActionLogger
urls = section.sidebar_urls.map { |url| "#{url.name} - #{url.value}" } urls = section.sidebar_urls.map { |url| "#{url.name} - #{url.value}" }
"links: #{urls.join(", ")}" "links: #{urls.join(", ")}"
end end
def truncate(s)
if s.size > UserHistory::MAX_CONTEXT_LENGTH
"#{s.slice(..UserHistory::MAX_CONTEXT_LENGTH)}..."
else
s
end
end
end end

View File

@ -5320,6 +5320,7 @@ en:
custom: "Notification from %{username} on %{site_title}" custom: "Notification from %{username} on %{site_title}"
staff_action_logs: staff_action_logs:
json_too_long: "Values not logged because they exceed the column length limits"
not_found: "not found" not_found: "not found"
unknown: "unknown" unknown: "unknown"
user_merged: "%{username} was merged into this account" user_merged: "%{username} was merged into this account"

View File

@ -334,6 +334,18 @@ RSpec.describe Admin::UsersController do
end end
end end
it "fails the request if the reason is too long" do
expect(user).not_to be_suspended
put "/admin/users/#{user.id}/suspend.json",
params: {
reason: "x" * 301,
suspend_until: 5.hours.from_now,
}
expect(response.status).to eq(400)
user.reload
expect(user).not_to be_suspended
end
it "requires suspend_until and reason" do it "requires suspend_until and reason" do
expect(user).not_to be_suspended expect(user).not_to be_suspended
put "/admin/users/#{user.id}/suspend.json", params: {} put "/admin/users/#{user.id}/suspend.json", params: {}
@ -1596,6 +1608,18 @@ RSpec.describe Admin::UsersController do
expect(reg_user.reload).to be_silenced expect(reg_user.reload).to be_silenced
expect(other_user.reload).to be_silenced expect(other_user.reload).to be_silenced
end end
it "fails the request if the reason is too long" do
expect(user).not_to be_silenced
put "/admin/users/#{user.id}/silence.json",
params: {
reason: "x" * 301,
silenced_till: 5.hours.from_now,
}
expect(response.status).to eq(400)
user.reload
expect(user).not_to be_suspended
end
end end
context "when logged in as a moderator" do context "when logged in as a moderator" do

View File

@ -73,6 +73,16 @@ RSpec.describe ExportCsvController do
expect(log_entry.acting_user_id).to eq(admin.id) expect(log_entry.acting_user_id).to eq(admin.id)
expect(log_entry.subject).to eq("user_list") expect(log_entry.subject).to eq("user_list")
end end
it "fails requests where the entity is too long" do
post "/export_csv/export_entity.json", params: { entity: "x" * 200 }
expect(response.status).to eq(400)
end
it "fails requests where the name arg is too long" do
post "/export_csv/export_entity.json", params: { entity: "foo", args: { name: "x" * 200 } }
expect(response.status).to eq(400)
end
end end
end end

View File

@ -1,6 +1,7 @@
# frozen_string_literal: true # frozen_string_literal: true
RSpec.describe StaffActionLogger do RSpec.describe StaffActionLogger do
let(:long_string) { "Na " * 100_000 + "Batman!" }
fab!(:admin) fab!(:admin)
let(:logger) { described_class.new(admin) } let(:logger) { described_class.new(admin) }
@ -69,6 +70,13 @@ RSpec.describe StaffActionLogger do
log_post_deletion log_post_deletion
}.to change { UserHistory.count }.by(1) }.to change { UserHistory.count }.by(1)
end end
it "truncates overly long values" do
deleted_post.update!(raw: long_string, skip_validation: true)
expect { log_post_deletion }.to change { UserHistory.count }.by(1)
log = UserHistory.last
expect(log.details.size).to be_between(50_000, 110_000)
end
end end
describe "log_topic_delete_recover" do describe "log_topic_delete_recover" do
@ -90,6 +98,13 @@ RSpec.describe StaffActionLogger do
it "creates a new UserHistory record" do it "creates a new UserHistory record" do
expect { log_topic_delete_recover }.to change { UserHistory.count }.by(1) expect { log_topic_delete_recover }.to change { UserHistory.count }.by(1)
end end
it "truncates overly long values" do
Fabricate(:post, topic: topic, skip_validation: true, raw: long_string)
expect { log_topic_delete_recover }.to change { UserHistory.count }.by(1)
log = UserHistory.last
expect(log.details.size).to be_between(50_000, 110_000)
end
end end
context "when recovering topic" do context "when recovering topic" do
@ -112,6 +127,13 @@ RSpec.describe StaffActionLogger do
it "creates a new UserHistory record" do it "creates a new UserHistory record" do
expect { log_topic_delete_recover }.to change { UserHistory.count }.by(1) expect { log_topic_delete_recover }.to change { UserHistory.count }.by(1)
end end
it "truncates overly long values" do
Fabricate(:post, topic: topic, skip_validation: true, raw: long_string)
expect { log_topic_delete_recover }.to change { UserHistory.count }.by(1)
log = UserHistory.last
expect(log.details.size).to be_between(50_000, 110_000)
end
end end
end end
@ -187,14 +209,12 @@ RSpec.describe StaffActionLogger do
end end
describe "log_theme_change" do describe "log_theme_change" do
fab!(:theme)
it "raises an error when params are invalid" do it "raises an error when params are invalid" do
expect { logger.log_theme_change(nil, nil) }.to raise_error(Discourse::InvalidParameters) expect { logger.log_theme_change(nil, nil) }.to raise_error(Discourse::InvalidParameters)
end end
let! :theme do
Fabricate(:theme)
end
it "logs new site customizations" do it "logs new site customizations" do
log_record = logger.log_theme_change(nil, theme) log_record = logger.log_theme_change(nil, theme)
expect(log_record.subject).to eq(theme.name) expect(log_record.subject).to eq(theme.name)
@ -226,15 +246,28 @@ RSpec.describe StaffActionLogger do
], ],
) )
end end
it "doesn't log values when the json is too large" do
old_json = ThemeSerializer.new(theme, root: false).to_json
theme.set_field(target: :common, name: :scss, value: long_string)
log_record = logger.log_theme_change(old_json, theme)
expect(log_record.previous_value).not_to be_present
expect(log_record.new_value).not_to be_present
expect(log_record.context).to be_present
end
end end
describe "log_theme_destroy" do describe "log_theme_destroy" do
fab!(:theme)
it "raises an error when params are invalid" do it "raises an error when params are invalid" do
expect { logger.log_theme_destroy(nil) }.to raise_error(Discourse::InvalidParameters) expect { logger.log_theme_destroy(nil) }.to raise_error(Discourse::InvalidParameters)
end end
it "creates a new UserHistory record" do it "creates a new UserHistory record" do
theme = Fabricate(:theme)
theme.set_field(target: :common, name: :scss, value: "body{margin: 10px;}") theme.set_field(target: :common, name: :scss, value: "body{margin: 10px;}")
log_record = logger.log_theme_destroy(theme) log_record = logger.log_theme_destroy(theme)
@ -253,6 +286,15 @@ RSpec.describe StaffActionLogger do
], ],
) )
end end
it "doesn't log values when the json is too large" do
theme.set_field(target: :common, name: :scss, value: long_string)
log_record = logger.log_theme_destroy(theme)
expect(log_record.previous_value).not_to be_present
expect(log_record.new_value).not_to be_present
expect(log_record.context).to be_present
end
end end
describe "log_theme_setting_change" do describe "log_theme_setting_change" do
@ -398,6 +440,12 @@ RSpec.describe StaffActionLogger do
expect(logged.custom_type).to eq("clicked_something") expect(logged.custom_type).to eq("clicked_something")
expect(logged.topic_id).to be === 1234 expect(logged.topic_id).to be === 1234
end end
it "truncates overly long values" do
logged = logger.log_custom(:shower_thought, lyrics: long_string)
expect(logged).to be_valid
expect(logged.details.size).to be_between(50_000, 110_000)
end
end end
describe "log_category_settings_change" do describe "log_category_settings_change" do
@ -654,6 +702,15 @@ RSpec.describe StaffActionLogger do
expect(user_history.action).to eq(UserHistory.actions[:post_rejected]) expect(user_history.action).to eq(UserHistory.actions[:post_rejected])
expect(user_history.details).to include(reviewable.payload["raw"]) expect(user_history.details).to include(reviewable.payload["raw"])
end end
it "truncates overly long values" do
reviewable.payload["raw"] = long_string
reviewable.save!
expect { log_post_rejected }.to change { UserHistory.count }.by(1)
log = UserHistory.last
expect(log.details.size).to be_between(50_000, 110_000)
end
end end
describe "log_topic_closed" do describe "log_topic_closed" do