CVE-2021-37703: Exposure of Sensitive Information to an Unauthorized Actor (CWE-200)
value: Discourse is an open-source platform for community discussion. In Discourse before versions 2.7.8 and 2.8.0.beta5, a user's read state for a topic such as the last read post number and the notification level is exposed.
CWE description: The product exposes sensitive information to an actor that is not explicitly authorized to have access to that information.
CVSS v3 base score: 4.3
Published: 2021-08-13T16:15Z
Repository: https://github.com/discourse/discourse
Language: Ruby
Files changed: app/models/topic_tracking_state.rb, spec/components/post_creator_spec.rb, spec/models/topic_tracking_state_spec.rb
Commit date: 2021-08-12 12:16:45 +0800
Commit message: SECURITY: User's read state for topic is leaked to unauthorized clients.

A user's read state for a topic such as the last read post number and the notification level is exposed.

Diff: diff --git a/app/models/topic_tracking_state.rb b/app/models/topic_tracking_state.rb
index e7db1b888e8..3e44f0cd593 100644
--- a/app/models/topic_tracking_state.rb
+++ b/app/models/topic_tracking_state.rb
@@ -143,27 +143,34 @@ class TopicTrackingState
 
   def self.publish_unread(post)
     return unless post.topic.regular?
     # TODO at high scale we are going to have to defer this,
     #   perhaps cut down to users that are around in the last 7 days as well
+    tags = nil
+    tag_ids = nil
+    if include_tags_in_report?
+      tag_ids, tags = post.topic.tags.pluck(:id, :name).transpose
+    end
+
+    scope = TopicUser
+      .tracking(post.topic_id)
+      .includes(user: :user_stat)
 
     group_ids =
       if post.post_type == Post.types[:whisper]
         [Group::AUTO_GROUPS[:staff]]
       else
         post.topic.category && post.topic.category.secure_group_ids
       end
 
-    tags = nil
-    tag_ids = nil
-    if include_tags_in_report?
-      tag_ids, tags = post.topic.tags.pluck(:id, :name).transpose
+    if group_ids.present?
+      scope = scope
+        .joins("INNER JOIN group_users gu ON gu.user_id = topic_users.user_id")
+        .where("gu.group_id IN (?)", group_ids)
     end
 
-    TopicUser
-      .tracking(post.topic_id)
-      .includes(user: :user_stat)
+    scope
       .select([:user_id, :last_read_post_number, :notification_level])
       .each do |tu|
 
       payload = {
         last_read_post_number: tu.last_read_post_number,
@@ -186,11 +193,13 @@ class TopicTrackingState
         topic_id: post.topic_id,
         message_type: UNREAD_MESSAGE_TYPE,
         payload: payload
       }
 
-      MessageBus.publish(self.unread_channel_key(tu.user_id), message.as_json, group_ids: group_ids)
+      MessageBus.publish(self.unread_channel_key(tu.user_id), message.as_json,
+        user_ids: [tu.user_id]
+      )
     end
 
   end
 
   def self.publish_recover(topic)
diff --git a/spec/components/post_creator_spec.rb b/spec/components/post_creator_spec.rb
index f993f5aeedf..165162953e5 100644
--- a/spec/components/post_creator_spec.rb
+++ b/spec/components/post_creator_spec.rb
@@ -135,11 +135,12 @@ describe PostCreator do
 
       it "generates the correct messages for a secure topic" do
         Jobs.run_immediately!
         UserActionManager.enable
 
-        admin = Fabricate(:admin)
+        admin = Fabricate(:user)
+        admin.grant_admin!
 
         cat = Fabricate(:category)
         cat.set_permissions(admins: :full)
         cat.save
 
diff --git a/spec/models/topic_tracking_state_spec.rb b/spec/models/topic_tracking_state_spec.rb
index e25df41d22a..ddcfc37c312 100644
--- a/spec/models/topic_tracking_state_spec.rb
+++ b/spec/models/topic_tracking_state_spec.rb
@@ -46,15 +46,58 @@ describe TopicTrackingState do
         TopicTrackingState.publish_unread(post)
       end.first
 
       data = message.data
 
+      expect(message.user_ids).to contain_exactly(post.user.id)
+      expect(message.group_ids).to eq(nil)
       expect(data["topic_id"]).to eq(topic.id)
       expect(data["message_type"]).to eq(described_class::UNREAD_MESSAGE_TYPE)
       expect(data["payload"]["archetype"]).to eq(Archetype.default)
     end
 
+    it "does not publish whisper post to non-staff users" do
+      post.update!(post_type: Post.types[:whisper])
+
+      messages = MessageBus.track_publish(described_class.unread_channel_key(post.user_id)) do
+        TopicTrackingState.publish_unread(post)
+      end
+
+      expect(messages).to eq([])
+
+      post.user.grant_admin!
+
+      message = MessageBus.track_publish(described_class.unread_channel_key(post.user_id)) do
+        TopicTrackingState.publish_unread(post)
+      end.first
+
+      expect(message.user_ids).to contain_exactly(post.user_id)
+      expect(message.group_ids).to eq(nil)
+    end
+
+    it "correctly publishes unread for a post in a restricted category" do
+      group = Fabricate(:group)
+      category = Fabricate(:private_category, group: group)
+
+      post.topic.update!(category: category)
+
+      messages = MessageBus.track_publish(described_class.unread_channel_key(post.user_id)) do
+        TopicTrackingState.publish_unread(post)
+      end
+
+      expect(messages).to eq([])
+
+      group.add(post.user)
+
+      message = MessageBus.track_publish(described_class.unread_channel_key(post.user_id)) do
+        TopicTrackingState.publish_unread(post)
+      end.first
+
+      expect(message.user_ids).to contain_exactly(post.user_id)
+      expect(message.group_ids).to eq(nil)
+    end
+
     describe 'for a private message' do
       before do
         TopicUser.change(
           private_message_topic.allowed_users.first.id,
           private_message_topic.id,

Vulnerable code:     tags = nil
    tag_ids = nil
    if include_tags_in_report?
      tag_ids, tags = post.topic.tags.pluck(:id, :name).transpose
    TopicUser
      .tracking(post.topic_id)
      .includes(user: :user_stat)
      MessageBus.publish(self.unread_channel_key(tu.user_id), message.as_json, group_ids: group_ids)
        admin = Fabricate(:admin)
Fixed code:     tags = nil
    tag_ids = nil
    if include_tags_in_report?
      tag_ids, tags = post.topic.tags.pluck(:id, :name).transpose
    end

    scope = TopicUser
      .tracking(post.topic_id)
      .includes(user: :user_stat)
    if group_ids.present?
      scope = scope
        .joins("INNER JOIN group_users gu ON gu.user_id = topic_users.user_id")
        .where("gu.group_id IN (?)", group_ids)
    scope
      MessageBus.publish(self.unread_channel_key(tu.user_id), message.as_json,
        user_ids: [tu.user_id]
      )
        admin = Fabricate(:user)
        admin.grant_admin!
      expect(message.user_ids).to contain_exactly(post.user.id)
      expect(message.group_ids).to eq(nil)
    it "does not publish whisper post to non-staff users" do
      post.update!(post_type: Post.types[:whisper])

      messages = MessageBus.track_publish(described_class.unread_channel_key(post.user_id)) do
        TopicTrackingState.publish_unread(post)
      end

      expect(messages).to eq([])

      post.user.grant_admin!

      message = MessageBus.track_publish(described_class.unread_channel_key(post.user_id)) do
        TopicTrackingState.publish_unread(post)
      end.first

      expect(message.user_ids).to contain_exactly(post.user_id)
      expect(message.group_ids).to eq(nil)
    end

    it "correctly publishes unread for a post in a restricted category" do
      group = Fabricate(:group)
      category = Fabricate(:private_category, group: group)

      post.topic.update!(category: category)

      messages = MessageBus.track_publish(described_class.unread_channel_key(post.user_id)) do
        TopicTrackingState.publish_unread(post)
      end

      expect(messages).to eq([])

      group.add(post.user)

      message = MessageBus.track_publish(described_class.unread_channel_key(post.user_id)) do
        TopicTrackingState.publish_unread(post)
      end.first

      expect(message.user_ids).to contain_exactly(post.user_id)
      expect(message.group_ids).to eq(nil)
    end