CVE-2024-38360 (Ruby, published 2024-07-15T20:15Z)
Description: value: Discourse is an open source platform for community discussion. In affected versions by creating replacement words with an almost unlimited number of characters, a moderator can reduce the availability of a Discourse instance. This issue has been addressed in stable version 3.2.3 and in current betas. Users are advised to upgrade. Users unable to upgrade may manually remove the long watched words either via SQL or Rails console.
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/models/watched_word.rb
app/models/watched_word_group.rb
spec/fabricators/watched_word_group_fabricator.rb
spec/models/watched_word_group_spec.rb
spec/models/watched_word_spec.rb
spec/requests/admin/watched_words_controller_spec.rb
spec/system/admin_watched_words_spec.rb
spec/system/page_objects/pages/admin_watched_words.rb
Commit message: SECURITY: limit the number of characters in watched word replacements.

The watch words controller creation function, create_or_update_word(), doesn’t validate the size of the replacement parameter, unlike the word parameter, when creating a replace watched word. So anyone with moderator privileges can create watched words with almost unlimited characters.


Vulnerable code:
  before_validation { self.word = WatchedWord.normalize_word(self.word) }
      self.save! if self.changed?
        watched_word =
          WatchedWord.create_or_update_word(
            params.merge(word: word, watched_word_group_id: self.id),
          )

        if !watched_word.valid?
          self.errors.merge!(watched_word.errors)
          raise ActiveRecord::Rollback
        end
Fabricator(:watched_word_group) { action WatchedWord.actions[:block] }
  fab!(:watched_word_1) { Fabricate(:watched_word, watched_word_group_id: watched_word_group.id) }
    it "leaves membership intact if update fails" do
      watched_word_group.create_or_update_members(
        words,
        action_key: WatchedWord.actions[watched_word_group.action],
      )

      expect(watched_word_group.reload.errors).not_to be_empty

      watched_words = watched_word_group.watched_words

      expect(watched_word_group.action).to eq(old_action)
      expect(watched_words.size).to eq(2)
      expect(watched_words.map(&:word)).to contain_exactly(watched_word_1.word, watched_word_2.word)
      expect(watched_words.map(&:action).uniq).to contain_exactly(old_action)
        watched_word.update!(watched_word_group: watched_word_group)

Fixed code:
  before_validation do
    self.word = WatchedWord.normalize_word(self.word)
    self.replacement = WatchedWord.normalize_word(self.replacement) if self.replacement.present?
  end
  validates :replacement, length: { maximum: 100 }
  validate :watched_words_validation
  def watched_words_validation
    watched_words.each { |word| errors.merge!(word.errors) }
    errors.add(:watched_words, :empty) if watched_words.empty?
  end

        watched_word = WatchedWord.create_or_update_word(params.merge(word: word))
        self.watched_words << watched_word

      self.save!
Fabricator(:watched_word_group) do
  action WatchedWord.actions[:block]
  watched_words { [Fabricate.build(:watched_word)] }
end
  let!(:watched_word_1) { watched_word_group.watched_words.first }
    it "raises an error if validation fails" do
      expect {
        watched_word_group.create_or_update_members(
          words,
          action_key: WatchedWord.actions[watched_word_group.action],
        )
      }.to raise_error(ActiveRecord::RecordInvalid)
  it "limits the number of characters in a word" do
    w = Fabricate.build(:watched_word, word: "a" * 101)
    expect(w).to_not be_valid
    expect(w.errors[:word]).to be_present
  end

  it "limits the number of characters in a replacement" do
    w = Fabricate.build(:watched_word, replacement: "a" * 101)
    expect(w).to_not be_valid
    expect(w.errors[:replacement]).to be_present
  end

        watched_word = watched_word_group.watched_words.first

  it "shows error when character limit is exceeded" do
    ww_page.visit
    ww_page.add_word "a" * 101

    expect(ww_page).to have_error("Word is too long (maximum is 100 characters)")
  end

      def has_error?(error)
        has_css?(".dialog-container .dialog-body", text: error)
      end