CVE-2023-25169: Insufficient Information (NVD-CWE-noinfo)
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.
CWE description: There is insufficient information about the issue to classify it; details are unkown or unspecified.
CVSS v3 base score: 5.3
Published: 2023-03-06T18:15Z
Repository: https://github.com/discourse/discourse-yearly-review
Language: Ruby
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 date: 2023-02-15 04:03:38 +0530
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.
Diff: diff --git a/app/helpers/yearly_review_helper.rb b/app/helpers/yearly_review_helper.rb
index 586dfa7..260c804 100644
--- a/app/helpers/yearly_review_helper.rb
+++ b/app/helpers/yearly_review_helper.rb
@@ -1,12 +1,14 @@
 # frozen_string_literal: true
 
 module YearlyReviewHelper
   include ActionView::Helpers::NumberHelper
 
+  AVATAR_SIZE ||= "50"
+
   def avatar_image(username, uploaded_avatar_id)
-    template = User.avatar_template(username, uploaded_avatar_id).gsub(/{size}/, "50")
+    template = User.avatar_template(username, uploaded_avatar_id).gsub(/{size}/, AVATAR_SIZE)
     "![avatar\\|25x25](#{template})"
   end
 
   def user_link(username)
     "@#{username}"
diff --git a/app/jobs/yearly_review.rb b/app/jobs/yearly_review.rb
index e24b296..cc56c68 100644
--- a/app/jobs/yearly_review.rb
+++ b/app/jobs/yearly_review.rb
@@ -35,10 +35,13 @@ module ::Jobs
         topic_opts = {
           title: title,
           raw: raw,
           category: SiteSetting.yearly_review_publish_category,
           skip_validations: true,
+          custom_fields: {
+            ::YearlyReview::POST_CUSTOM_FIELD => review_year,
+          },
         }
 
         post = PostCreator.create!(Discourse.system_user, topic_opts)
 
         if post.respond_to? :topic_id
@@ -78,11 +81,18 @@ module ::Jobs
         category_post_topics = category_post_topics category_id, review_start, review_end
         if category_post_topics[:topics]
           view.assign(category_topics: category_post_topics)
           raw = view.render partial: "yearly_review_category", layout: false
           unless raw.empty?
-            post_opts = { topic_id: topic_id, raw: raw, skip_validations: true }
+            post_opts = {
+              topic_id: topic_id,
+              raw: raw,
+              skip_validations: true,
+              custom_fields: {
+                ::YearlyReview::POST_CUSTOM_FIELD => review_start.year,
+              },
+            }
 
             PostCreator.create!(Discourse.system_user, post_opts)
           end
         end
       end
diff --git a/db/post_migrate/20230208123631_backfill_yearly_review_custom_fields.rb b/db/post_migrate/20230208123631_backfill_yearly_review_custom_fields.rb
new file mode 100644
index 0000000..ac81358
--- /dev/null
+++ b/db/post_migrate/20230208123631_backfill_yearly_review_custom_fields.rb
@@ -0,0 +1,23 @@
+# 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
diff --git a/plugin.rb b/plugin.rb
index 5edc5e4..a40a0e0 100644
--- a/plugin.rb
+++ b/plugin.rb
@@ -1,19 +1,20 @@
 # frozen_string_literal: true
 
 # name: discourse-yearly-review
 # about: Creates an automated Year in Review summary topic
-# version: 0.1
+# version: 0.2
 # author: Simon Cossar
 # url: https://github.com/discourse/discourse-yearly-review
 
 enabled_site_setting :yearly_review_enabled
 register_asset "stylesheets/yearly_review.scss"
 
 after_initialize do
   module ::YearlyReview
     PLUGIN_NAME = "yearly-review"
+    POST_CUSTOM_FIELD ||= "yearly_review"
 
     def self.current_year
       Time.now.year
     end
 
@@ -76,6 +77,19 @@ after_initialize do
       end
     doc
       .css("[data-review-topic-users] td table td:nth-child(2)")
       .each { |element| element["style"] = "padding-left: 4px;padding-bottom: 6px;" }
   end
+
+  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
 end
diff --git a/spec/jobs/yearly_review_spec.rb b/spec/jobs/yearly_review_spec.rb
index 2ad28b7..039f99f 100644
--- a/spec/jobs/yearly_review_spec.rb
+++ b/spec/jobs/yearly_review_spec.rb
@@ -23,10 +23,13 @@ describe Jobs::YearlyReview do
         Jobs::YearlyReview.new.execute({})
         topic = Topic.last
         expect(topic.title).to eq(
           I18n.t("yearly_review.topic_title", year: ::YearlyReview.last_year),
         )
+        expect(topic.first_post.custom_fields).to eq(
+          YearlyReview::POST_CUSTOM_FIELD => ::YearlyReview.last_year.to_s,
+        )
       end
     end
 
     describe "on February 1st" do
       before do
@@ -78,10 +81,34 @@ describe Jobs::YearlyReview do
         Jobs::YearlyReview.new.execute({})
         raw = Topic.last.first_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(/\@reviewed_user\|1/) }
       end
+
+      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
     end
 
     describe "most replies" do
       before do
         SiteSetting.max_consecutive_replies = 5
@@ -196,12 +223,11 @@ describe Jobs::YearlyReview do
         end
       end
 
       it "should rank likes given and received correctly" do
         Jobs::YearlyReview.new.execute({})
-        topic = Topic.last
-        raw = Post.where(topic_id: topic.id).first.raw
+        raw = Topic.last.first_post.raw
         expect(raw).to have_tag("div.likes-given") { with_text(/\@top_review_user\|11/) }
         expect(raw).to have_tag("div.likes-given") { with_text(/\@reviewed_user\|10/) }
 
         expect(raw).to have_tag("div.likes-received") { with_text(/\@reviewed_user\|11/) }
         expect(raw).to have_tag("div.likes-received") { with_text(/\@top_review_user\|10/) }

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