CVE-2021-41082 (published 2021-09-20T21:15Z)

Discourse is a platform for community discussion. In affected versions any private message that includes a group had its title and participating user exposed to users that do not have access to the private messages. However, access control for the private messages was not compromised as users were not able to view the posts in the leaked private message despite seeing it in their inbox. The problematic commit was reverted around 32 minutes after it was made. Users are encouraged to upgrade to the latest commit if they are running Discourse against the `tests-passed` branch.

Severity: MEDIUM
CVSS v2 base score: 5.0 (AV:N/AC:L/Au:N/C:P/I:N/A:N)
CVSS v3 base score: 7.5, HIGH (CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N)

Weakness information:
cwe_id: CWE-863
cwe_name: Incorrect Authorization
description: The product performs an authorization check when an actor attempts to access a resource or perform an action, but it does not correctly perform the check. This allows attackers to bypass intended access restrictions.
extended_description: 
            Assuming a user with a given identity, authorization is the process of determining whether that user can access a given resource, based on the user's privileges and any permissions or other access-control specifications that apply to the resource.
            When access control checks are incorrectly applied, users are able to access data or perform actions that they should not be allowed to perform. This can lead to a wide range of problems, including information exposures, denial of service, and arbitrary code execution.
         
         

Fix information:
repo_url: https://github.com/discourse/discourse
commit_details: repo_url: https://github.com/discourse/discourse
author: Alan Guo Xiang Tan
author_date: 2021-09-15 10:29:42+08:00
msg: PERF: Improve query performance all inbox private messages. (#14304)

First reported in https://meta.discourse.org/t/-/202482/19

There are two optimizations being applied here:

1. Fetch a user's group ids in a seperate query instead of including it
   as a sub-query. When I tried a subquery, the query plan becomes very
inefficient.

1. Join against the `topic_allowed_users` and `topic_allowed_groups`
   table instead of doing an IN against a subquery where we UNION the
`topic_id`s from the two tables. From my profiling, this enables PG to
do a backwards index scan on the `index_topics_on_timestamps_private`
index.

This commit fixes a bug where listing all messages was incorrectly
excluding topics if a topic has been archived by a group even if the
user did not belong to the group.

This commit also fixes another bug where dismissing private messages
selectively was subjected to the default limit of 30.
repository: repo_url: https://github.com/discourse/discourse
repo_name: discourse/discourse
description: A platform for community discussion. Free, open, simple.
repo_language: Ruby
owner: discourse
file_changes: filename: topics_controller.rb
change_type: ModificationType.MODIFY
diff: @@ -952,7 +952,7 @@ class TopicsController < ApplicationController
   end
 
   def private_message_reset_new
-    topic_query = TopicQuery.new(current_user)
+    topic_query = TopicQuery.new(current_user, limit: false)
 
     if params[:topic_ids].present?
       unless Array === params[:topic_ids]
@@ -968,13 +968,12 @@ class TopicsController < ApplicationController
       params.require(:inbox)
       inbox = params[:inbox].to_s
       filter = private_message_filter(topic_query, inbox)
-      topic_query.options[:limit] = false
       topic_scope = topic_query.filter_private_message_new(current_user, filter)
     end
 
     topic_ids = TopicsBulkAction.new(
       current_user,
-      topic_scope.pluck(:id),
+      topic_scope.distinct(false).pluck(:id),
       type: "dismiss_topics"
     ).perform!
 
@@ -1246,7 +1245,11 @@ class TopicsController < ApplicationController
     if inbox = params[:private_message_inbox]
       filter = private_message_filter(topic_query, inbox)
       topic_query.options[:limit] = false
-      topics = topic_query.filter_private_messages_unread(current_user, filter)
+
+      topic_query
+        .filter_private_messages_unread(current_user, filter)
+        .distinct(false)
+        .pluck(:id)
     else
       topics = TopicQuery.unread_filter(topic_query.joined_topic_user, staff: guardian.is_staff?).listable_topics
       topics = TopicQuery.tracked_filter(topics, current_user.id) if params[:tracked].to_s == "true"
@@ -1265,9 +1268,9 @@ class TopicsController < ApplicationController
       if params[:tag_name].present?
         topics = topics.joins(:tags).where("tags.name": params[:tag_name])
       end
-    end
 
-    topics.pluck(:id)
+      topics.pluck(:id)
+    end
   end
 
   def private_message_filter(topic_query, inbox)

programming_language: Ruby
filename: private_message_lists.rb
change_type: ModificationType.MODIFY
diff: @@ -143,16 +143,20 @@ class TopicQuery
       elsif type == :user
         result = result.where("topics.id IN (SELECT topic_id FROM topic_allowed_users WHERE user_id = #{user.id.to_i})")
       elsif type == :all
-        result = result.where("topics.id IN (
-              SELECT topic_id
-              FROM topic_allowed_users
-              WHERE user_id = #{user.id.to_i}
-              UNION ALL
-              SELECT topic_id FROM topic_allowed_groups
-              WHERE group_id IN (
-                SELECT group_id FROM group_users WHERE user_id = #{user.id.to_i}
-              )
-      )")
+        group_ids = group_with_messages_ids(user)
+
+        result = result.joins(<<~SQL)
+        LEFT JOIN topic_allowed_users tau
+          ON tau.topic_id = topics.id
+          AND tau.user_id = #{user.id.to_i}
+        LEFT JOIN topic_allowed_groups tag
+          ON tag.topic_id = topics.id
+          #{group_ids.present? ? "AND tag.group_id IN (#{group_ids.join(",")})" : ""}
+        SQL
+
+        result = result
+          .where("tag.topic_id IS NOT NULL OR tau.topic_id IS NOT NULL")
+          .distinct
       end
 
       result = result.joins("LEFT OUTER JOIN topic_users AS tu ON (topics.id = tu.topic_id AND tu.user_id = #{user.id.to_i})")
@@ -229,8 +233,15 @@ class TopicQuery
     end
 
     def filter_archived(list, user, archived: true)
+      # Executing an extra query instead of a sub-query because it is more
+      # efficient for the PG planner. Caution should be used when changing the
+      # query here as it can easily lead to an inefficient query.
+      group_ids = group_with_messages_ids(user)
+
       list = list.joins(<<~SQL)
-      LEFT JOIN group_archived_messages gm ON gm.topic_id = topics.id
+      LEFT JOIN group_archived_messages gm
+        ON gm.topic_id = topics.id
+        #{group_ids.present? ? "AND gm.group_id IN (#{group_ids.join(",")})" : ""}
       LEFT JOIN user_archived_messages um
         ON um.user_id = #{user.id.to_i}
         AND um.topic_id = topics.id
@@ -264,5 +275,15 @@ class TopicQuery
     def user_first_unread_pm_at(user)
       UserStat.where(user: user).pluck_first(:first_unread_pm_at)
     end
+
+    def group_with_messages_ids(user)
+      @group_with_messages_ids ||= {}
+
+      if ids = @group_with_messages_ids[user.id]
+        return ids
+      end
+
+      @group_with_messages_ids[user.id] = user.groups.where(has_messages: true).pluck(:id)
+    end
   end
 end

programming_language: Ruby
filename: private_message_lists_spec.rb
change_type: ModificationType.MODIFY
diff: @@ -43,7 +43,7 @@ describe TopicQuery::PrivateMessageLists do
 
       expect(topics).to eq([])
 
-      GroupArchivedMessage.archive!(user_2.id, group_message)
+      GroupArchivedMessage.archive!(group.id, group_message)
 
       topics = TopicQuery.new(nil).list_private_messages_all(user_2).topics
 
@@ -75,7 +75,7 @@ describe TopicQuery::PrivateMessageLists do
       create_post(user: user_2, topic: group_message)
 
       UserArchivedMessage.archive!(user_2.id, private_message)
-      GroupArchivedMessage.archive!(user_2.id, group_message)
+      GroupArchivedMessage.archive!(group.id, group_message)
 
       topics = TopicQuery.new(nil).list_private_messages_all_sent(user_2).topics
 
@@ -86,7 +86,7 @@ describe TopicQuery::PrivateMessageLists do
   describe '#list_private_messages_all_archive' do
     it 'returns a list of all private messages that has been archived' do
       UserArchivedMessage.archive!(user_2.id, private_message)
-      GroupArchivedMessage.archive!(user_2.id, group_message)
+      GroupArchivedMessage.archive!(group.id, group_message)
 
       topics = TopicQuery.new(nil).list_private_messages_all_archive(user_2).topics
 

programming_language: Ruby
repo_url: https://github.com/discourse/discourse
commit_details: repo_url: https://github.com/discourse/discourse
author: Alan Guo Xiang Tan
author_date: 2021-09-15 11:32:10+08:00
msg: Partially revert "PERF: Improve query performance all inbox private messages. (#14304)" (#14344)

This partially reverts commit ddb458343dc39a7a8c99467dcd809b444514fe2c.

Seeing performance degrade on larger sites so back to drawing board on
this one. Instead of the DISTINCT LEFT JOIN, we switch back to
IN(subquery).
repository: repo_url: https://github.com/discourse/discourse
repo_name: discourse/discourse
description: A platform for community discussion. Free, open, simple.
repo_language: Ruby
owner: discourse
file_changes: filename: topics_controller.rb
change_type: ModificationType.MODIFY
diff: @@ -973,7 +973,7 @@ class TopicsController < ApplicationController
 
     topic_ids = TopicsBulkAction.new(
       current_user,
-      topic_scope.distinct(false).pluck(:id),
+      topic_scope.pluck(:id),
       type: "dismiss_topics"
     ).perform!
 
@@ -1245,11 +1245,7 @@ class TopicsController < ApplicationController
     if inbox = params[:private_message_inbox]
       filter = private_message_filter(topic_query, inbox)
       topic_query.options[:limit] = false
-
-      topic_query
-        .filter_private_messages_unread(current_user, filter)
-        .distinct(false)
-        .pluck(:id)
+      topics = topic_query.filter_private_messages_unread(current_user, filter)
     else
       topics = TopicQuery.unread_filter(topic_query.joined_topic_user, staff: guardian.is_staff?).listable_topics
       topics = TopicQuery.tracked_filter(topics, current_user.id) if params[:tracked].to_s == "true"
@@ -1268,9 +1264,9 @@ class TopicsController < ApplicationController
       if params[:tag_name].present?
         topics = topics.joins(:tags).where("tags.name": params[:tag_name])
       end
-
-      topics.pluck(:id)
     end
+
+    topics.pluck(:id)
   end
 
   def private_message_filter(topic_query, inbox)

programming_language: Ruby
filename: private_message_lists.rb
change_type: ModificationType.MODIFY
diff: @@ -145,18 +145,25 @@ class TopicQuery
       elsif type == :all
         group_ids = group_with_messages_ids(user)
 
-        result = result.joins(<<~SQL)
-        LEFT JOIN topic_allowed_users tau
-          ON tau.topic_id = topics.id
-          AND tau.user_id = #{user.id.to_i}
-        LEFT JOIN topic_allowed_groups tag
-          ON tag.topic_id = topics.id
-          #{group_ids.present? ? "AND tag.group_id IN (#{group_ids.join(",")})" : ""}
-        SQL
-
-        result = result
-          .where("tag.topic_id IS NOT NULL OR tau.topic_id IS NOT NULL")
-          .distinct
+        result =
+        if group_ids.present?
+          result.where(<<~SQL)
+            topics.id IN (
+              SELECT topic_id
+              FROM topic_allowed_users
+              WHERE user_id = #{user.id.to_i}
+              UNION ALL
+              SELECT topic_id FROM topic_allowed_groups
+              WHERE group_id IN (#{group_ids.join(",")})
+            )
+          SQL
+        else
+          result.joins(<<~SQL)
+          INNER JOIN topic_allowed_users tau
+            ON tau.topic_id = topics.id
+            AND tau.user_id = #{user.id.to_i}
+          SQL
+        end
       end
 
       result = result.joins("LEFT OUTER JOIN topic_users AS tu ON (topics.id = tu.topic_id AND tu.user_id = #{user.id.to_i})")
@@ -238,23 +245,30 @@ class TopicQuery
       # query here as it can easily lead to an inefficient query.
       group_ids = group_with_messages_ids(user)
 
-      list = list.joins(<<~SQL)
-      LEFT JOIN group_archived_messages gm
-        ON gm.topic_id = topics.id
-        #{group_ids.present? ? "AND gm.group_id IN (#{group_ids.join(",")})" : ""}
-      LEFT JOIN user_archived_messages um
-        ON um.user_id = #{user.id.to_i}
-        AND um.topic_id = topics.id
-      SQL
+      if group_ids.present?
+        list = list.joins(<<~SQL)
+          LEFT JOIN group_archived_messages gm
+            ON gm.topic_id = topics.id
+            AND gm.group_id IN (#{group_ids.join(",")})
+          LEFT JOIN user_archived_messages um
+            ON um.user_id = #{user.id.to_i}
+            AND um.topic_id = topics.id
+        SQL
 
-      list =
         if archived
           list.where("um.user_id IS NOT NULL OR gm.topic_id IS NOT NULL")
         else
           list.where("um.user_id IS NULL AND gm.topic_id IS NULL")
         end
+      else
+        list = list.joins(<<~SQL)
+          LEFT JOIN user_archived_messages um
+          ON um.user_id = #{user.id.to_i}
+          AND um.topic_id = topics.id
+        SQL
 
-      list
+        list.where("um.user_id IS #{archived ? "NOT NULL" : "NULL"}")
+      end
     end
 
     def not_archived(list, user)

programming_language: Ruby