CVE-2024-31219: Insufficient Information (NVD-CWE-noinfo)
value: Discourse-reactions is a plugin that allows user to add their reactions to the post. When whispers are enabled on a site via `whispers_allowed_groups` and reactions are made on whispers on public topics, the contents of the whisper and the reaction data are shown on the `/u/:username/activity/reactions` endpoint.

CWE description: There is insufficient information about the issue to classify it; details are unkown or unspecified.
Published: 2024-04-15T18:15Z
Repository: https://github.com/discourse/discourse-reactions
Language: Ruby
Files changed: app/controllers/discourse_reactions/custom_reactions_controller.rb, plugin.rb, spec/requests/custom_reactions_controller_spec.rb
Commit date: 2024-04-15 13:40:26 +1000
Commit message: SECURITY: Hide whisper posts in reactions given endpoint (#286)

Users who were not allowed to whisper could see reactions
and whisper content of posts on public topics.
Diff: diff --git a/app/controllers/discourse_reactions/custom_reactions_controller.rb b/app/controllers/discourse_reactions/custom_reactions_controller.rb
index 42704f29..6354b52f 100644
--- a/app/controllers/discourse_reactions/custom_reactions_controller.rb
+++ b/app/controllers/discourse_reactions/custom_reactions_controller.rb
@@ -46,12 +46,16 @@ class DiscourseReactions::CustomReactionsController < ApplicationController
       )
     raise Discourse::NotFound unless guardian.can_see_profile?(user)
 
     reaction_users =
       DiscourseReactions::ReactionUser
-        .joins(:reaction, :post)
-        .joins("INNER JOIN topics t ON t.id = posts.topic_id AND t.deleted_at IS NULL")
+        .joins(
+          "INNER JOIN discourse_reactions_reactions ON discourse_reactions_reactions.id = discourse_reactions_reaction_users.reaction_id",
+        )
+        .joins("INNER JOIN posts p ON p.id = discourse_reactions_reaction_users.post_id")
+        .joins("INNER JOIN topics t ON t.id = p.topic_id AND t.deleted_at IS NULL")
+        .joins("INNER JOIN posts p2 ON p2.topic_id = t.id AND p2.post_number = 1")
         .joins("LEFT JOIN categories c ON c.id = t.category_id")
         .includes(:user, :post, :reaction)
         .where(user_id: user.id)
         .where("discourse_reactions_reactions.reaction_users_count IS NOT NULL")
 
@@ -269,12 +273,11 @@ class DiscourseReactions::CustomReactionsController < ApplicationController
     MessageBus.publish("/topic/#{post.topic.id}/reactions", message, opts)
   end
 
   def secure_reaction_users!(reaction_users)
     builder = DB.build("/*where*/")
-    UserAction.filter_private_messages(builder, current_user.id, guardian)
-    UserAction.filter_categories(builder, guardian)
+    UserAction.apply_common_filters(builder, current_user.id, guardian)
     reaction_users.where(builder.to_sql.delete_prefix("/*where*/").delete_prefix("WHERE"))
   end
 
   def translate_to_reactions(likes)
     likes.map do |like|
diff --git a/plugin.rb b/plugin.rb
index 83f7a8e1..9396dc37 100644
--- a/plugin.rb
+++ b/plugin.rb
@@ -1,11 +1,11 @@
 # frozen_string_literal: true
 
 # name: discourse-reactions
 # about: Allows users to react to a post with emojis.
 # meta_topic_id: 183261
-# version: 0.3
+# version: 0.5
 # author: Ahmed Gagan, Rafael dos Santos Silva, Kris Aubuchon, Joffrey Jaffeux, Kris Kotlarek, Jordan Vidrine
 # url: https://github.com/discourse/discourse-reactions
 
 enabled_site_setting :discourse_reactions_enabled
 
diff --git a/spec/requests/custom_reactions_controller_spec.rb b/spec/requests/custom_reactions_controller_spec.rb
index c1fdf0a8..2717979e 100644
--- a/spec/requests/custom_reactions_controller_spec.rb
+++ b/spec/requests/custom_reactions_controller_spec.rb
@@ -11,14 +11,20 @@ describe DiscourseReactions::CustomReactionsController do
   fab!(:user_5) { Fabricate(:user) }
   fab!(:admin)
   fab!(:post_2) { Fabricate(:post, user: user_1) }
   fab!(:private_topic) { Fabricate(:private_message_topic, user: user_2, recipient: admin) }
   fab!(:private_post) { Fabricate(:post, topic: private_topic) }
+  fab!(:whisper_post) do
+    Fabricate(:post, topic: Fabricate(:topic), post_type: Post.types[:whisper])
+  end
   fab!(:laughing_reaction) { Fabricate(:reaction, post: post_2, reaction_value: "laughing") }
   fab!(:open_mouth_reaction) { Fabricate(:reaction, post: post_2, reaction_value: "open_mouth") }
   fab!(:hugs_reaction) { Fabricate(:reaction, post: post_2, reaction_value: "hugs") }
   fab!(:hugs_reaction_private) { Fabricate(:reaction, post: private_post, reaction_value: "hugs") }
+  fab!(:laughing_reaction_whisper) do
+    Fabricate(:reaction, post: whisper_post, reaction_value: "laughing")
+  end
   fab!(:like) do
     Fabricate(
       :post_action,
       post: post_2,
       user: user_5,
@@ -38,10 +44,13 @@ describe DiscourseReactions::CustomReactionsController do
     Fabricate(:reaction_user, reaction: open_mouth_reaction, user: user_3, post: post_2)
   end
   fab!(:reaction_user_5) do
     Fabricate(:reaction_user, reaction: hugs_reaction_private, user: admin, post: private_post)
   end
+  fab!(:reaction_user_6) do
+    Fabricate(:reaction_user, reaction: laughing_reaction_whisper, user: user_2, post: whisper_post)
+  end
 
   before do
     SiteSetting.discourse_reactions_enabled = true
     SiteSetting.discourse_reactions_like_icon = "heart"
     SiteSetting.discourse_reactions_enabled_reactions =
@@ -223,10 +232,31 @@ describe DiscourseReactions::CustomReactionsController do
       get "/discourse-reactions/posts/reactions.json", params: { username: user_2.username }
       parsed = response.parsed_body
       expect(response.parsed_body.map { |reaction| reaction["post_id"] }).to include(secure_post.id)
     end
 
+    it "does not return reactions for whispers if the user is not in whispers_allowed_groups" do
+      sign_in(user_1)
+
+      get "/discourse-reactions/posts/reactions.json", params: { username: user_2.username }
+
+      parsed = response.parsed_body
+      expect(response.parsed_body.map { |reaction| reaction["post_id"] }).not_to include(
+        whisper_post.id,
+      )
+
+      SiteSetting.whispers_allowed_groups = Group::AUTO_GROUPS[:trust_level_0].to_s
+      Group.refresh_automatic_groups!
+
+      get "/discourse-reactions/posts/reactions.json", params: { username: user_2.username }
+
+      parsed = response.parsed_body
+      expect(response.parsed_body.map { |reaction| reaction["post_id"] }).to include(
+        whisper_post.id,
+      )
+    end
+
     describe "a post with one of your reactions has been deleted" do
       fab!(:deleted_post) { Fabricate(:post) }
       fab!(:kept_post) { Fabricate(:post) }
       fab!(:user)
       fab!(:reaction_on_deleted_post) do

Vulnerable code:         .joins(:reaction, :post)
        .joins("INNER JOIN topics t ON t.id = posts.topic_id AND t.deleted_at IS NULL")
    UserAction.filter_private_messages(builder, current_user.id, guardian)
    UserAction.filter_categories(builder, guardian)
# version: 0.3
Fixed code:         .joins(
          "INNER JOIN discourse_reactions_reactions ON discourse_reactions_reactions.id = discourse_reactions_reaction_users.reaction_id",
        )
        .joins("INNER JOIN posts p ON p.id = discourse_reactions_reaction_users.post_id")
        .joins("INNER JOIN topics t ON t.id = p.topic_id AND t.deleted_at IS NULL")
        .joins("INNER JOIN posts p2 ON p2.topic_id = t.id AND p2.post_number = 1")
    UserAction.apply_common_filters(builder, current_user.id, guardian)
# version: 0.5
  fab!(:whisper_post) do
    Fabricate(:post, topic: Fabricate(:topic), post_type: Post.types[:whisper])
  end
  fab!(:laughing_reaction_whisper) do
    Fabricate(:reaction, post: whisper_post, reaction_value: "laughing")
  end
  fab!(:reaction_user_6) do
    Fabricate(:reaction_user, reaction: laughing_reaction_whisper, user: user_2, post: whisper_post)
  end
    it "does not return reactions for whispers if the user is not in whispers_allowed_groups" do
      sign_in(user_1)

      get "/discourse-reactions/posts/reactions.json", params: { username: user_2.username }

      parsed = response.parsed_body
      expect(response.parsed_body.map { |reaction| reaction["post_id"] }).not_to include(
        whisper_post.id,
      )

      SiteSetting.whispers_allowed_groups = Group::AUTO_GROUPS[:trust_level_0].to_s
      Group.refresh_automatic_groups!

      get "/discourse-reactions/posts/reactions.json", params: { username: user_2.username }

      parsed = response.parsed_body
      expect(response.parsed_body.map { |reaction| reaction["post_id"] }).to include(
        whisper_post.id,
      )
    end