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
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
@ -128,6 +133,10 @@ class Admin::UsersController < Admin::StaffController
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
@ -360,6 +369,11 @@ class Admin::UsersController < Admin::StaffController
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
@ -379,6 +393,10 @@ class Admin::UsersController < Admin::StaffController
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

View File

@ -5,18 +5,26 @@ class ExportCsvController < ApplicationController
def export_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])
else
Jobs.enqueue(
:export_csv_file,
entity: export_params[:entity],
entity: entity,
user_id: current_user.id,
args: export_params[:args],
)
end
StaffActionLogger.new(current_user).log_entity_export(export_params[:entity])
StaffActionLogger.new(current_user).log_entity_export(entity)
render json: success_json
rescue Discourse::InvalidAccess
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 :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
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|
attrs[attr] = details.delete(attr) if details.has_key?(attr)
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[:action] = UserHistory.actions[:custom_staff]
attrs[:custom_type] = custom_type
@ -80,7 +80,7 @@ class StaffActionLogger
"user: #{username} (#{name})",
"topic: #{topic_title}",
"post_number: #{deleted_post.post_number}",
"raw: #{deleted_post.raw}",
"raw: #{truncate(deleted_post.raw)}",
]
UserHistory.create!(
@ -105,7 +105,7 @@ class StaffActionLogger
]
if first_post = topic.ordered_posts.with_deleted.first
details << "raw: #{first_post.raw}"
details << "raw: #{truncate(first_post.raw)}"
end
UserHistory.create!(
@ -179,7 +179,7 @@ class StaffActionLogger
params(opts).merge(
action: UserHistory.actions[:post_edit],
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
@ -261,15 +261,12 @@ class StaffActionLogger
raise Discourse::InvalidParameters.new(:new_theme) unless new_theme
new_json = theme_json(new_theme)
old_json, new_json = strip_duplicates(old_json, new_json)
UserHistory.create!(
params(opts).merge(
params(opts).merge(json_params(old_json, new_json)).merge(
action: UserHistory.actions[:change_theme],
subject: new_theme.name,
previous_value: old_json,
new_value: new_json,
),
)
end
@ -277,10 +274,9 @@ class StaffActionLogger
def log_theme_destroy(theme, opts = {})
raise Discourse::InvalidParameters.new(:theme) unless theme
UserHistory.create!(
params(opts).merge(
params(opts).merge(json_params(theme_json(theme), nil)).merge(
action: UserHistory.actions[:delete_theme],
subject: theme.name,
previous_value: theme_json(theme),
),
)
end
@ -804,7 +800,7 @@ class StaffActionLogger
"rejected_at: #{rejected_at}",
"user: #{username} (#{name})",
"topic: #{topic_title}",
"raw: #{reviewable.payload["raw"]}",
"raw: #{truncate(reviewable.payload["raw"])}",
]
UserHistory.create!(
@ -1017,6 +1013,15 @@ class StaffActionLogger
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)
return unless changes
@ -1046,4 +1051,12 @@ class StaffActionLogger
urls = section.sidebar_urls.map { |url| "#{url.name} - #{url.value}" }
"links: #{urls.join(", ")}"
end
def truncate(s)
if s.size > UserHistory::MAX_CONTEXT_LENGTH
"#{s.slice(..UserHistory::MAX_CONTEXT_LENGTH)}..."
else
s
end
end
end

View File

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

View File

@ -334,6 +334,18 @@ RSpec.describe Admin::UsersController do
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
expect(user).not_to be_suspended
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(other_user.reload).to be_silenced
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
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.subject).to eq("user_list")
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

View File

@ -1,6 +1,7 @@
# frozen_string_literal: true
RSpec.describe StaffActionLogger do
let(:long_string) { "Na " * 100_000 + "Batman!" }
fab!(:admin)
let(:logger) { described_class.new(admin) }
@ -69,6 +70,13 @@ RSpec.describe StaffActionLogger do
log_post_deletion
}.to change { UserHistory.count }.by(1)
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
describe "log_topic_delete_recover" do
@ -90,6 +98,13 @@ RSpec.describe StaffActionLogger do
it "creates a new UserHistory record" do
expect { log_topic_delete_recover }.to change { UserHistory.count }.by(1)
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
context "when recovering topic" do
@ -112,6 +127,13 @@ RSpec.describe StaffActionLogger do
it "creates a new UserHistory record" do
expect { log_topic_delete_recover }.to change { UserHistory.count }.by(1)
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
@ -187,14 +209,12 @@ RSpec.describe StaffActionLogger do
end
describe "log_theme_change" do
fab!(:theme)
it "raises an error when params are invalid" do
expect { logger.log_theme_change(nil, nil) }.to raise_error(Discourse::InvalidParameters)
end
let! :theme do
Fabricate(:theme)
end
it "logs new site customizations" do
log_record = logger.log_theme_change(nil, theme)
expect(log_record.subject).to eq(theme.name)
@ -226,15 +246,28 @@ RSpec.describe StaffActionLogger do
],
)
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
describe "log_theme_destroy" do
fab!(:theme)
it "raises an error when params are invalid" do
expect { logger.log_theme_destroy(nil) }.to raise_error(Discourse::InvalidParameters)
end
it "creates a new UserHistory record" do
theme = Fabricate(:theme)
theme.set_field(target: :common, name: :scss, value: "body{margin: 10px;}")
log_record = logger.log_theme_destroy(theme)
@ -253,6 +286,15 @@ RSpec.describe StaffActionLogger do
],
)
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
describe "log_theme_setting_change" do
@ -398,6 +440,12 @@ RSpec.describe StaffActionLogger do
expect(logged.custom_type).to eq("clicked_something")
expect(logged.topic_id).to be === 1234
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
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.details).to include(reviewable.payload["raw"])
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
describe "log_topic_closed" do