CVE-2024-27100: Insufficient Information (NVD-CWE-noinfo)
value: Discourse is an open source platform for community discussion. In affected versions the endpoints for suspending users, silencing users and exporting CSV files weren't enforcing limits on the sizes of the parameters that they accept. This could lead to excessive resource consumption which could render an instance inoperable. A site could be disrupted by either a malicious moderator on the same site or a malicious staff member on another site in the same multisite cluster. This issue is patched in the latest stable, beta and tests-passed versions of Discourse. Users are advised to upgrade. There are no known workarounds for this vulnerability.
CWE description: There is insufficient information about the issue to classify it; details are unkown or unspecified.
Published: 2024-03-15T20:15Z
Repository: https://github.com/discourse/discourse
Language: Ruby
Files changed: app/controllers/admin/users_controller.rb, app/controllers/export_csv_controller.rb, app/models/user_history.rb, app/services/staff_action_logger.rb, config/locales/server.en.yml, spec/requests/admin/users_controller_spec.rb, spec/requests/export_csv_controller_spec.rb, spec/services/staff_action_logger_spec.rb
Commit date: 2024-03-15 14:24:04 +0800
Commit message: 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,

Diff: diff --git a/app/controllers/admin/users_controller.rb b/app/controllers/admin/users_controller.rb
index 779ac6ea54c09..01ef6bb0728a2 100644
--- a/app/controllers/admin/users_controller.rb
+++ b/app/controllers/admin/users_controller.rb
@@ -105,10 +105,15 @@ class Admin::UsersController < Admin::StaffController
     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(
@@ -126,10 +131,14 @@ class Admin::UsersController < Admin::StaffController
 
     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
@@ -358,10 +367,15 @@ class Admin::UsersController < Admin::StaffController
     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(
@@ -377,10 +391,14 @@ class Admin::UsersController < Admin::StaffController
       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
diff --git a/app/controllers/export_csv_controller.rb b/app/controllers/export_csv_controller.rb
index 833bd53b3030a..a053de20b3012 100644
--- a/app/controllers/export_csv_controller.rb
+++ b/app/controllers/export_csv_controller.rb
@@ -3,22 +3,30 @@
 class ExportCsvController < ApplicationController
   skip_before_action :preload_json, :check_xhr, only: [:show]
 
   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")
   end
 
diff --git a/app/models/user_history.rb b/app/models/user_history.rb
index 796dcd48bcbd4..5b6c6f006cc9e 100644
--- a/app/models/user_history.rb
+++ b/app/models/user_history.rb
@@ -9,10 +9,27 @@ class UserHistory < ActiveRecord::Base
 
   belongs_to :post
   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) }
 
   before_save :set_admin_only
diff --git a/app/services/staff_action_logger.rb b/app/services/staff_action_logger.rb
index 64a6d283a3571..039b3eddf2280 100644
--- a/app/services/staff_action_logger.rb
+++ b/app/services/staff_action_logger.rb
@@ -36,11 +36,11 @@ class StaffActionLogger
 
     attrs = {}
     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
 
     UserHistory.create!(attrs)
@@ -78,11 +78,11 @@ class StaffActionLogger
       "id: #{deleted_post.id}",
       "created_at: #{deleted_post.created_at}",
       "user: #{username} (#{name})",
       "topic: #{topic_title}",
       "post_number: #{deleted_post.post_number}",
-      "raw: #{deleted_post.raw}",
+      "raw: #{truncate(deleted_post.raw)}",
     ]
 
     UserHistory.create!(
       params(opts).merge(
         action: UserHistory.actions[:delete_post],
@@ -103,11 +103,11 @@ class StaffActionLogger
       "user: #{user}",
       "title: #{topic.title}",
     ]
 
     if first_post = topic.ordered_posts.with_deleted.first
-      details << "raw: #{first_post.raw}"
+      details << "raw: #{truncate(first_post.raw)}"
     end
 
     UserHistory.create!(
       params(opts).merge(
         action: UserHistory.actions[action.to_sym],
@@ -177,11 +177,11 @@ class StaffActionLogger
     raise Discourse::InvalidParameters.new(:post) unless post && post.is_a?(Post)
     UserHistory.create!(
       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
 
   def log_topic_closed(topic, opts = {})
@@ -259,30 +259,26 @@ class StaffActionLogger
 
   def log_theme_change(old_json, new_theme, opts = {})
     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
 
   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
 
   def log_theme_component_disabled(component)
@@ -802,11 +798,11 @@ class StaffActionLogger
     details = [
       "created_at: #{reviewable.created_at}",
       "rejected_at: #{rejected_at}",
       "user: #{username} (#{name})",
       "topic: #{topic_title}",
-      "raw: #{reviewable.payload["raw"]}",
+      "raw: #{truncate(reviewable.payload["raw"])}",
     ]
 
     UserHistory.create!(
       params(opts).merge(action: UserHistory.actions[:post_rejected], details: details.join("\n")),
     )
@@ -1015,10 +1011,19 @@ class StaffActionLogger
     )
   end
 
   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
 
     changes.delete("updated_at")
     old_values = []
@@ -1044,6 +1049,14 @@ class StaffActionLogger
 
   def custom_section_details(section)
     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
diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml
index 434dbcd9d7a21..3a815f4d86d1f 100644
--- a/config/locales/server.en.yml
+++ b/config/locales/server.en.yml
@@ -5318,10 +5318,11 @@ en:
       confirm_title: "Notifications enabled - %{site_title}"
       confirm_body: "Success! Notifications have been enabled."
       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"
     user_delete_self: "Deleted by self from %{url}"
     webhook_deactivation_reason: "Your webhook has been automatically deactivated. We received multiple '%{status}' HTTP status failure responses."
diff --git a/spec/requests/admin/users_controller_spec.rb b/spec/requests/admin/users_controller_spec.rb
index cf8f68c48c0a5..8114fe9791e0a 100644
--- a/spec/requests/admin/users_controller_spec.rb
+++ b/spec/requests/admin/users_controller_spec.rb
@@ -332,10 +332,22 @@ RSpec.describe Admin::UsersController do
           expect(job_args["id"]).to eq(user.id)
           expect(job_args["payload"]).to eq(WebHook.generate_payload(:user, user))
         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: {}
         expect(response.status).to eq(400)
         user.reload
@@ -1594,10 +1606,22 @@ RSpec.describe Admin::UsersController do
         put "/admin/users/#{reg_user.id}/silence.json", params: { other_user_ids: [other_user.id] }
         expect(response.status).to eq(200)
         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
       before { sign_in(moderator) }
 
diff --git a/spec/requests/export_csv_controller_spec.rb b/spec/requests/export_csv_controller_spec.rb
index 861d5274b1bb0..d55106cb60f5e 100644
--- a/spec/requests/export_csv_controller_spec.rb
+++ b/spec/requests/export_csv_controller_spec.rb
@@ -71,10 +71,20 @@ RSpec.describe ExportCsvController do
         log_entry = UserHistory.last
         expect(log_entry.action).to eq(UserHistory.actions[:entity_export])
         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
 
   context "while logged in as a moderator" do
     fab!(:moderator)
diff --git a/spec/services/staff_action_logger_spec.rb b/spec/services/staff_action_logger_spec.rb
index 4a60944f802d2..a6a2d28250074 100644
--- a/spec/services/staff_action_logger_spec.rb
+++ b/spec/services/staff_action_logger_spec.rb
@@ -1,8 +1,9 @@
 # frozen_string_literal: true
 
 RSpec.describe StaffActionLogger do
+  let(:long_string) { "Na " * 100_000 + "Batman!" }
   fab!(:admin)
   let(:logger) { described_class.new(admin) }
 
   describe "new" do
     it "raises an error when user is nil" do
@@ -67,10 +68,17 @@ RSpec.describe StaffActionLogger do
       expect {
         deleted_post.update_columns(user_id: nil)
         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
     fab!(:topic)
 
@@ -88,10 +96,17 @@ RSpec.describe StaffActionLogger do
       end
 
       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
       subject(:log_topic_delete_recover) do
         described_class.new(admin).log_topic_delete_recover(topic, "recover_topic")
@@ -110,10 +125,17 @@ RSpec.describe StaffActionLogger do
       end
 
       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
 
   describe "log_trust_level_change" do
     subject(:log_trust_level_change) do
@@ -185,18 +207,16 @@ RSpec.describe StaffActionLogger do
       expect(log_record.new_value).to be_nil
     end
   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)
       expect(log_record.previous_value).to eq(nil)
       expect(log_record.new_value).to be_present
@@ -224,19 +244,32 @@ RSpec.describe StaffActionLogger do
             "type_id" => 1,
           },
         ],
       )
     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)
       expect(log_record.previous_value).to be_present
       expect(log_record.new_value).to eq(nil)
@@ -251,10 +284,19 @@ RSpec.describe StaffActionLogger do
             "type_id" => 1,
           },
         ],
       )
     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
     it "raises an error when params are invalid" do
       expect { logger.log_theme_setting_change(nil, nil, nil, nil) }.to raise_error(
@@ -396,10 +438,16 @@ RSpec.describe StaffActionLogger do
       expect(logged.details).to eq("evil: trout\nclicked_on: thing")
       expect(logged.action).to eq(UserHistory.actions[:custom_staff])
       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
     let(:category) { Fabricate(:category, name: "haha") }
     let(:category_group) { Fabricate(:category_group, category: category, permission_type: 1) }
@@ -652,10 +700,19 @@ RSpec.describe StaffActionLogger do
       expect { log_post_rejected }.to change { UserHistory.count }.by(1)
       user_history = UserHistory.last
       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
     fab!(:topic)
 

Vulnerable code:     if export_params[:entity] == "user_archive"
        entity: export_params[:entity],
    StaffActionLogger.new(current_user).log_entity_export(export_params[:entity])
    attrs[:details] = details.map { |r| "#{r[0]}: #{r[1]}" }.join("\n")
      "raw: #{deleted_post.raw}",
      details << "raw: #{first_post.raw}"
        details: "#{opts[:old_raw]}\n\n---\n\n#{post.raw}",

      params(opts).merge(
        previous_value: old_json,
        new_value: new_json,
      params(opts).merge(
        previous_value: theme_json(theme),
      "raw: #{reviewable.payload["raw"]}",
    let! :theme do
      Fabricate(:theme)
    end

      theme = Fabricate(:theme)
Fixed code:     reason = params[:reason]

    if reason && (!reason.is_a?(String) || reason.size > 300)
      raise Discourse::InvalidParameters.new(:reason)
    end
      if params[:other_user_ids].size > MAX_SIMILAR_USERS
        raise Discourse::InvalidParameters.new(:other_user_ids)
      end

    reason = params[:reason]

    if reason && (!reason.is_a?(String) || reason.size > 300)
      raise Discourse::InvalidParameters.new(:reason)
    end
      if params[:other_user_ids].size > MAX_SIMILAR_USERS
        raise Discourse::InvalidParameters.new(:other_user_ids)
      end

    entity = export_params[:entity]
    raise Discourse::InvalidParameters.new(:entity) unless entity.is_a?(String) && entity.size < 100
    (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"
        entity: entity,
    StaffActionLogger.new(current_user).log_entity_export(entity)
  # 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 }

    attrs[:details] = details.map { |r| "#{r[0]}: #{truncate(r[1].to_s)}" }.join("\n")
      "raw: #{truncate(deleted_post.raw)}",
      details << "raw: #{truncate(first_post.raw)}"
        details: "#{truncate(opts[:old_raw])}\n\n---\n\n#{truncate(post.raw)}",
      params(opts).merge(json_params(old_json, new_json)).merge(
      params(opts).merge(json_params(theme_json(theme), nil)).merge(
      "raw: #{truncate(reviewable.payload["raw"])}",
  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 truncate(s)
    if s.size > UserHistory::MAX_CONTEXT_LENGTH
      "#{s.slice(..UserHistory::MAX_CONTEXT_LENGTH)}..."
    else
      s
    end
  end
    json_too_long: "Values not logged because they exceed the column length limits"
      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 "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

      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
  let(:long_string) { "Na " * 100_000 + "Batman!" }

    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

      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

      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
    fab!(:theme)


    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
    fab!(:theme)


    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

    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

    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