CVE-2024-27100 (Ruby, published 2024-03-15T20:15Z)
Description: 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.
Severity: nan (CVSS v3 base score: )
Weakness: NVD-CWE-noinfo \u2014 Insufficient Information
There is insufficient information about the issue to classify it; details are unkown or unspecified.
Repository: https://github.com/discourse/discourse
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 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,


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