CVE-2023-23624: Exposure of Sensitive Information to an Unauthorized Actor (CWE-200)
value: Discourse is an open-source discussion platform. Prior to version 3.0.1 on the `stable` branch and version 3.1.0.beta2 on the `beta` and `tests-passed` branches, someone can use the `exclude_tag param` to filter out topics and deduce which ones were using a specific hidden tag. This affects any Discourse site using hidden tags in public categories. This issue is patched in version 3.0.1 on the `stable` branch and version 3.1.0.beta2 on the `beta` and `tests-passed` branches. As a workaround, secure any categories that are using hidden tags, change any existing hidden tags to not include private data, or remove any hidden tags currently in use.
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: 5.3
Published: 2023-01-28T00:15Z
Repository: https://github.com/discourse/discourse
Language: Ruby
Files changed: lib/topic_query.rb, spec/lib/topic_query_spec.rb
Commit date: 2023-01-25 18:56:22 +0200
Commit message: SECURITY: Update to exclude tag topic filter (#20006)

Ignores tags specified in exclude_tag topics param that a user does not
have access to.

Co-authored-by: Blake Erickson <o.blakeerickson@gmail.com>
Diff: diff --git a/lib/topic_query.rb b/lib/topic_query.rb
index 4d3ea15bbd91..a7049b008e56 100644
--- a/lib/topic_query.rb
+++ b/lib/topic_query.rb
@@ -733,18 +733,21 @@ class TopicQuery
       elsif @options[:no_tags]
         # the following will do: ("topics"."id" NOT IN (SELECT DISTINCT "topic_tags"."topic_id" FROM "topic_tags"))
         result = result.where.not(id: TopicTag.distinct.pluck(:topic_id))
       end
 
-      result = result.where(<<~SQL, name: @options[:exclude_tag]) if @options[:exclude_tag].present?
-        topics.id NOT IN (
-          SELECT topic_tags.topic_id
-          FROM topic_tags
-          INNER JOIN tags ON tags.id = topic_tags.tag_id
-          WHERE tags.name = :name
-        )
-        SQL
+      if @options[:exclude_tag].present? &&
+           !DiscourseTagging.hidden_tag_names(@guardian).include?(@options[:exclude_tag])
+        result = result.where(<<~SQL, name: @options[:exclude_tag])
+          topics.id NOT IN (
+            SELECT topic_tags.topic_id
+            FROM topic_tags
+            INNER JOIN tags ON tags.id = topic_tags.tag_id
+            WHERE tags.name = :name
+          )
+          SQL
+      end
     end
 
     result = apply_ordering(result, options)
 
     all_listable_topics = @guardian.filter_allowed_categories(Topic.unscoped.listable_topics)
diff --git a/spec/lib/topic_query_spec.rb b/spec/lib/topic_query_spec.rb
index 8ba558a353ed..261a4cbb7ccd 100644
--- a/spec/lib/topic_query_spec.rb
+++ b/spec/lib/topic_query_spec.rb
@@ -407,17 +407,25 @@ RSpec.describe TopicQuery do
       fab!(:tagged_topic1) { Fabricate(:topic, tags: [tag]) }
       fab!(:tagged_topic2) { Fabricate(:topic, tags: [other_tag]) }
       fab!(:tagged_topic3) { Fabricate(:topic, tags: [tag, other_tag]) }
       fab!(:tagged_topic4) { Fabricate(:topic, tags: [uppercase_tag]) }
       fab!(:no_tags_topic) { Fabricate(:topic) }
+      fab!(:tag_group) do
+        Fabricate(:tag_group, permissions: { "staff" => 1 }, tag_names: [other_tag.name])
+      end
       let(:synonym) { Fabricate(:tag, target_tag: tag, name: "synonym") }
 
       it "excludes a tag if desired" do
         topics = TopicQuery.new(moderator, exclude_tag: tag.name).list_latest.topics
         expect(topics.any? { |t| t.tags.include?(tag) }).to eq(false)
       end
 
+      it "does not exclude a tagged topic without permission" do
+        topics = TopicQuery.new(user, exclude_tag: other_tag.name).list_latest.topics
+        expect(topics.map(&:id)).to include(tagged_topic2.id)
+      end
+
       it "returns topics with the tag when filtered to it" do
         expect(TopicQuery.new(moderator, tags: tag.name).list_latest.topics).to contain_exactly(
           tagged_topic1,
           tagged_topic3,
         )

Vulnerable code:       result = result.where(<<~SQL, name: @options[:exclude_tag]) if @options[:exclude_tag].present?
        topics.id NOT IN (
          SELECT topic_tags.topic_id
          FROM topic_tags
          INNER JOIN tags ON tags.id = topic_tags.tag_id
          WHERE tags.name = :name
        )
        SQL
Fixed code:       if @options[:exclude_tag].present? &&
           !DiscourseTagging.hidden_tag_names(@guardian).include?(@options[:exclude_tag])
        result = result.where(<<~SQL, name: @options[:exclude_tag])
          topics.id NOT IN (
            SELECT topic_tags.topic_id
            FROM topic_tags
            INNER JOIN tags ON tags.id = topic_tags.tag_id
            WHERE tags.name = :name
          )
          SQL
      end
      fab!(:tag_group) do
        Fabricate(:tag_group, permissions: { "staff" => 1 }, tag_names: [other_tag.name])
      end
      it "does not exclude a tagged topic without permission" do
        topics = TopicQuery.new(user, exclude_tag: other_tag.name).list_latest.topics
        expect(topics.map(&:id)).to include(tagged_topic2.id)
      end