CVE-2023-25169 (Ruby, published 2023-03-06T18:15Z)
Description: value: discourse-yearly-review is a discourse plugin which publishes an automated Year in Review topic. In affected versions a user present in a yearly review topic that is then anonymised will still have some data linked to its original account. This issue has been patched in commit `b3ab33bbf7` which is included in the latest version of the Discourse Yearly Review plugin. Users are advised to upgrade. Users unable to upgrade may disable the `yearly_review_enabled` setting to fully mitigate the issue. Also, it's possible to edit the anonymised user's old data in the yearly review topics manually.
Severity: nan (CVSS v3 base score: 5.3)
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-yearly-review
Files changed:
app/helpers/yearly_review_helper.rb
app/jobs/yearly_review.rb
db/post_migrate/20230208123631_backfill_yearly_review_custom_fields.rb
plugin.rb
spec/jobs/yearly_review_spec.rb
Commit message: SECURITY: update old username when the user is anonymized. (#55)

Previously, when a user is anonymized their new username is not updated in the yearly review topic.

Vulnerable code:
    template = User.avatar_template(username, uploaded_avatar_id).gsub(/{size}/, "50")
            post_opts = { topic_id: topic_id, raw: raw, skip_validations: true }
# version: 0.1
        topic = Topic.last
        raw = Post.where(topic_id: topic.id).first.raw

Fixed code:
  AVATAR_SIZE ||= "50"

    template = User.avatar_template(username, uploaded_avatar_id).gsub(/{size}/, AVATAR_SIZE)
          custom_fields: {
            ::YearlyReview::POST_CUSTOM_FIELD => review_year,
          },
            post_opts = {
              topic_id: topic_id,
              raw: raw,
              skip_validations: true,
              custom_fields: {
                ::YearlyReview::POST_CUSTOM_FIELD => review_start.year,
              },
            }
# frozen_string_literal: true

class BackfillYearlyReviewCustomFields < ActiveRecord::Migration[6.1]
  def change
    2017.upto(2022) do |year|
      topic_title = I18n.t("yearly_review.topic_title", year: year)
      DB.exec(
        <<~SQL,
        INSERT INTO post_custom_fields (post_id, name, value, created_at, updated_at)
        SELECT posts.id, :name, :value, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
        FROM posts
        INNER JOIN topics ON topics.id = posts.topic_id
        WHERE posts.user_id = :user_id AND topics.title = :title
        ON CONFLICT DO NOTHING
      SQL
        title: topic_title,
        user_id: Discourse::SYSTEM_USER_ID,
        name: YearlyReview::POST_CUSTOM_FIELD,
        value: year.to_s,
      )
    end
  end
end
# version: 0.2
    POST_CUSTOM_FIELD ||= "yearly_review"

  DiscourseEvent.on(:username_changed) do |old_username, new_username|
    Post
      .joins(:_custom_fields)
      .where(
        "post_custom_fields.name = ? AND posts.raw LIKE ?",
        YearlyReview::POST_CUSTOM_FIELD,
        "%/#{old_username}/%",
      )
      .update_all(
        "raw = REPLACE(raw, '/#{old_username}/', '/#{new_username}/'), baked_version = NULL",
      )
  end
        expect(topic.first_post.custom_fields).to eq(
          YearlyReview::POST_CUSTOM_FIELD => ::YearlyReview.last_year.to_s,
        )

      it "updates username correctly after anonymizing the user" do
        Jobs.run_immediately!
        UserActionManager.enable
        stub_image_size

        upload = Fabricate(:upload, user: top_review_user)
        top_review_user.user_avatar =
          UserAvatar.new(user_id: top_review_user.id, custom_upload_id: upload.id)
        top_review_user.uploaded_avatar_id = upload.id
        top_review_user.save!

        Jobs::YearlyReview.new.execute({})
        post = Topic.last.first_post
        raw = post.raw
        expect(raw).to have_tag("div.topics-created") { with_text(/\@top_review_user\|5/) }
        expect(raw).to have_tag("div.topics-created") { with_text(%r{/top_review_user/50/}) }

        user = UserAnonymizer.new(top_review_user, Discourse.system_user, {}).make_anonymous
        raw = post.reload.raw
        expect(raw).to have_tag("div.topics-created") { with_text(/\@#{user.username}\|5/) }
        expect(raw).to have_tag("div.topics-created") { with_text(%r{/#{user.username}/50/}) }
        expect(post.baked_version).to be_nil
      end
        raw = Topic.last.first_post.raw