CVE-2023-25169 (published 2023-03-06T18:15Z)

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.

CVSS v3 base score: 5.3, MEDIUM (CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:N/A:N)

Weakness information:
cwe_id: NVD-CWE-noinfo
cwe_name: Insufficient Information
description: There is insufficient information about the issue to classify it; details are unkown or unspecified.
extended_description: Insufficient Information

Fix information:
repo_url: https://github.com/discourse/discourse-yearly-review
commit_details: repo_url: https://github.com/discourse/discourse-yearly-review
author: Vinoth Kannan
author_date: 2023-02-15 04:03:38+05:30
msg: 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.
repository: repo_url: https://github.com/discourse/discourse-yearly-review
repo_name: discourse/discourse-yearly-review
description: Publishes an automated Year in Review topic
repo_language: Ruby
owner: discourse
file_changes: filename: yearly_review_helper.rb
change_type: ModificationType.MODIFY
diff: @@ -3,8 +3,10 @@
 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
 

programming_language: Ruby
filename: yearly_review.rb
change_type: ModificationType.MODIFY
diff: @@ -37,6 +37,9 @@ module ::Jobs
           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)
@@ -80,7 +83,14 @@ module ::Jobs
           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

programming_language: Ruby
filename: 20230208123631_backfill_yearly_review_custom_fields.rb
change_type: ModificationType.ADD
diff: @@ -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

programming_language: Ruby
filename: plugin.rb
change_type: ModificationType.MODIFY
diff: @@ -2,7 +2,7 @@
 
 # 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
 
@@ -12,6 +12,7 @@ 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
@@ -78,4 +79,17 @@ after_initialize do
       .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

programming_language: Ruby
filename: yearly_review_spec.rb
change_type: ModificationType.MODIFY
diff: @@ -25,6 +25,9 @@ describe Jobs::YearlyReview do
         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
 
@@ -80,6 +83,30 @@ describe Jobs::YearlyReview do
         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
@@ -198,8 +225,7 @@ describe Jobs::YearlyReview do
 
       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/) }
 

programming_language: Ruby