CVE-2022-41913: Insufficient Information (NVD-CWE-noinfo)
value: Discourse-calendar is a plugin for the Discourse messaging platform which adds the ability to create a dynamic calendar in the first post of a topic. Members of private groups or public groups with private members can be listed by users, who can create and edit post events. This vulnerability only affects sites which have discourse post events enabled. This issue has been patched in commit `ca5ae3e7e` which will be included in future releases. Users unable to upgrade should disable the `discourse_post_event_enabled` setting to fully mitigate the issue. Also, it's possible to prevent regular users from using this vulnerability by removing all groups from the `discourse_post_event_allowed_on_groups` but note that moderators will still be able to use it.
CWE description: There is insufficient information about the issue to classify it; details are unkown or unspecified.
CVSS v3 base score: 5.4
Published: 2022-11-14T21:15Z
Repository: https://github.com/discourse/discourse-calendar
Language: Ruby
Files changed: config/locales/server.en.yml, jobs/regular/discourse_post_event/bulk_invite.rb, lib/discourse_post_event/event_validator.rb, spec/integration/post_spec.rb, spec/requests/events_controller_spec.rb
Commit date: 2022-11-14 18:18:15 +0400
Commit message: SECURITY: Do not expose private group members (#345)


Diff: diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml
index 621faeb..cf1359e 100644
--- a/config/locales/server.en.yml
+++ b/config/locales/server.en.yml
@@ -79,8 +79,10 @@ en:
           end_must_be_a_valid_date: "End date must be a valid date."
           invalid_recurrence: "Recurrence must be one of: every_month, every_week, every_two_weeks, every_day, every_weekday."
           invalid_timezone: "Timezone not recognized."
           acting_user_not_allowed_to_create_event: "Current user is not allowed to create events."
           acting_user_not_allowed_to_act_on_this_event: "Current user is not allowed to act on this event."
+          invalid_allowed_groups: "Invalid allowed groups."
+          acting_user_not_allowed_to_invite_these_groups: "Current user is not allowed to invite these groups."
           custom_field_is_invalid: "The custom field `%{field}` is not allowed."
           name:
             length: "Event name length must be between %{minimum} and %{maximum} characters."
diff --git a/jobs/regular/discourse_post_event/bulk_invite.rb b/jobs/regular/discourse_post_event/bulk_invite.rb
index 9601796..89ce28c 100644
--- a/jobs/regular/discourse_post_event/bulk_invite.rb
+++ b/jobs/regular/discourse_post_event/bulk_invite.rb
@@ -31,10 +31,12 @@ module Jobs
     end
 
     private
 
     def process_invitees(invitees)
+      invitees = filter_out_unavailable_groups(invitees)
+
       max_bulk_invitees = SiteSetting.discourse_post_event_max_bulk_invitees
 
       invitees.each do |invitee|
         break if @processed >= max_bulk_invitees
         process_invitee(invitee)
@@ -111,7 +113,20 @@ module Jobs
             logs: @logs.join("\n")
           )
         end
       end
     end
+
+    def invitee_groups(invitees)
+      Group.where(name: invitees.map { |i| i[:identifier] })
+    end
+
+    def filter_out_unavailable_groups(invitees)
+      groups = invitee_groups(invitees)
+      invitees.filter do |i|
+        group = groups.find { |g| g.name === i[:identifier] }
+
+        !group || (@guardian.can_see_group?(group) && @guardian.can_see_group_members?(group))
+      end
+    end
   end
 end
diff --git a/lib/discourse_post_event/event_validator.rb b/lib/discourse_post_event/event_validator.rb
index 2d5d997..030f07e 100644
--- a/lib/discourse_post_event/event_validator.rb
+++ b/lib/discourse_post_event/event_validator.rb
@@ -31,10 +31,12 @@ module DiscoursePostEvent
         return false
       end
 
       extracted_event = extracted_events.first
 
+      return false unless can_invite_groups?(extracted_event)
+
       if @post.acting_user && @post.event
         if !@post.acting_user.can_act_on_discourse_post_event?(@post.event)
           @post.errors.add(:base, I18n.t("discourse_post_event.errors.models.event.acting_user_not_allowed_to_act_on_this_event"))
           return false
         end
@@ -81,7 +83,30 @@ module DiscoursePostEvent
         end
       end
 
       true
     end
+
+    private
+
+    def can_invite_groups?(event)
+      guardian = Guardian.new(@post.acting_user)
+      return true unless event[:"allowed-groups"]
+
+      event[:"allowed-groups"].split(',').each do |group_name|
+        group = Group.find_by(name: group_name)
+
+        if !group || !guardian.can_see_group?(group)
+          @post.errors.add(:base, I18n.t("discourse_post_event.errors.models.event.invalid_allowed_groups"))
+          return false
+        end
+
+        if !guardian.can_see_group_members?(group)
+          @post.errors.add(:base, I18n.t("discourse_post_event.errors.models.event.acting_user_not_allowed_to_invite_these_groups"))
+          return false
+        end
+      end
+
+      true
+    end
   end
 end
diff --git a/spec/integration/post_spec.rb b/spec/integration/post_spec.rb
index 36baeb9..1423763 100644
--- a/spec/integration/post_spec.rb
+++ b/spec/integration/post_spec.rb
@@ -250,10 +250,13 @@ describe Post do
               DiscoursePostEvent::Event.statuses[:standalone]
             )
           end
 
           it 'works with allowedGroups attribute' do
+            Fabricate(:group, name: "euro")
+            Fabricate(:group, name: "america")
+
             post = create_post_with_event(user, 'allowedGroups="euro"').reload
             expect(post.event.raw_invitees).to eq([])
 
             post =
               create_post_with_event(user, 'status="public" allowedGroups="euro"')
@@ -567,10 +570,41 @@ describe Post do
 
         expect(event_1.raw_invitees).to eq(['trust_level_0'])
         expect(event_1.status).to eq(Event.statuses[:public])
       end
     end
+
+    it "rejects private groups in allowedGroups" do
+      moderator = Fabricate(:user, moderator: true)
+      private_group = Fabricate(
+        :group,
+        visibility_level: Group.visibility_levels[:owners])
+
+      expect {
+        create_post_with_event(moderator, "allowedGroups='#{private_group.name}'")
+      }.to raise_error(ActiveRecord::RecordNotSaved)
+    end
+
+    it "rejects non-existent groups in allowedGroups" do
+      moderator = Fabricate(:user, moderator: true)
+
+      expect {
+        create_post_with_event(moderator, "allowedGroups='non-existent_group_name'")
+      }.to raise_error(ActiveRecord::RecordNotSaved)
+    end
+
+    it "rejects public groups with private members in allowedGroups" do
+      moderator = Fabricate(:user, moderator: true)
+      public_group_with_private_members = Fabricate(
+        :group,
+        visibility_level: Group.visibility_levels[:public],
+        members_visibility_level: Group.visibility_levels[:owners])
+
+      expect {
+        create_post_with_event(moderator, "allowedGroups='#{public_group_with_private_members.name}'")
+      }.to raise_error(ActiveRecord::RecordNotSaved)
+    end
   end
 
   context "with holiday events" do
     let(:calendar_post) { create_post(raw: "[calendar]\n[/calendar]") }
 
diff --git a/spec/requests/events_controller_spec.rb b/spec/requests/events_controller_spec.rb
index e0f1066..e560a61 100644
--- a/spec/requests/events_controller_spec.rb
+++ b/spec/requests/events_controller_spec.rb
@@ -8,17 +8,17 @@ module DiscoursePostEvent
       SiteSetting.calendar_enabled = true
       SiteSetting.discourse_post_event_enabled = true
       SiteSetting.displayed_invitees_limit = 3
     end
 
-    let(:user) { Fabricate(:user, admin: true) }
-    let(:topic) { Fabricate(:topic, user: user) }
-    let(:post1) { Fabricate(:post, user: user, topic: topic) }
-    let(:invitee1) { Fabricate(:user) }
-    let(:invitee2) { Fabricate(:user) }
-
     context 'with an existing post' do
+      let(:user) { Fabricate(:user, admin: true) }
+      let(:topic) { Fabricate(:topic, user: user) }
+      let(:post1) { Fabricate(:post, user: user, topic: topic) }
+      let(:invitee1) { Fabricate(:user) }
+      let(:invitee2) { Fabricate(:user) }
+
       context 'with an existing event' do
         let(:event_1) { Fabricate(:event, post: post1) }
 
         before do
           sign_in(user)
@@ -222,7 +222,102 @@ module DiscoursePostEvent
             expect(events[0]["id"]).to eq(event_2.id)
           end
         end
       end
     end
+
+    context 'with a private event' do
+      let(:moderator) { Fabricate(:user, moderator: true) }
+      let(:topic) { Fabricate(:topic, user: moderator) }
+      let(:first_post) { Fabricate(:post, user: moderator, topic: topic) }
+      let(:private_event) { Fabricate(:event, post: first_post, status: Event.statuses[:private]) }
+
+      before do
+        sign_in(moderator)
+      end
+
+      context 'when bulk inviting via CSV file' do
+        def csv_file(content)
+          file = Tempfile.new("invites.csv")
+          file.write(content)
+          file.rewind
+          file
+        end
+
+        it "doesn't invite a private group" do
+          private_group = Fabricate(:group, visibility_level: Group.visibility_levels[:owners])
+
+          file = csv_file("#{private_group.name},going\n")
+          params = { file: fixture_file_upload(file) }
+          post "/discourse-post-event/events/#{private_event.id}/csv-bulk-invite.json", { params: params }
+
+          expect(response.status).to eq(200)
+          private_event.reload
+          expect(private_event.raw_invitees).to be_nil
+        end
+
+        it "returns 200 when inviting a non-existent group" do
+          file = csv_file("non-existent group name,going\n")
+          params = { file: fixture_file_upload(file) }
+          post "/discourse-post-event/events/#{private_event.id}/csv-bulk-invite.json", { params: params }
+
+          expect(response.status).to eq(200)
+        end
+
+        it "doesn't invite a public group with private members" do
+          public_group_with_private_members = Fabricate(
+            :group,
+            visibility_level: Group.visibility_levels[:public],
+            members_visibility_level: Group.visibility_levels[:owners])
+
+          file = csv_file("#{public_group_with_private_members.name},going\n")
+          params = { file: fixture_file_upload(file) }
+          post "/discourse-post-event/events/#{private_event.id}/csv-bulk-invite.json", { params: params }
+
+          expect(response.status).to eq(200)
+          private_event.reload
+          expect(private_event.raw_invitees).to be_nil
+        end
+      end
+
+      context 'when doing bulk inviting via UI' do
+        it "doesn't invite a private group" do
+          private_group = Fabricate(:group, visibility_level: Group.visibility_levels[:owners])
+
+          params = { invitees: [
+            { 'identifier' => private_group.name, 'attendance' => 'going' }
+          ] }
+          post "/discourse-post-event/events/#{private_event.id}/bulk-invite.json", { params: params }
+
+          expect(response.status).to eq(200)
+          private_event.reload
+          expect(private_event.raw_invitees).to be_nil
+        end
+
+        it "returns 200 when inviting a non-existent group" do
+          params = { invitees: [
+            { 'identifier' => 'non-existent group name', 'attendance' => 'going' }
+          ] }
+          post "/discourse-post-event/events/#{private_event.id}/bulk-invite.json", { params: params }
+
+          expect(response.status).to eq(200)
+        end
+
+        it "doesn't invite a public group with private members" do
+          public_group_with_private_members = Fabricate(
+            :group,
+            visibility_level: Group.visibility_levels[:public],
+            members_visibility_level: Group.visibility_levels[:owners])
+
+          params = { invitees: [
+            { 'identifier' => public_group_with_private_members.name, 'attendance' => 'going' }
+          ] }
+          post "/discourse-post-event/events/#{private_event.id}/bulk-invite.json", { params: params }
+
+          expect(response.status).to eq(200)
+          private_event.reload
+          expect(private_event.raw_invitees).to be_nil
+        end
+      end
+    end
   end
 end

Vulnerable code:     let(:user) { Fabricate(:user, admin: true) }
    let(:topic) { Fabricate(:topic, user: user) }
    let(:post1) { Fabricate(:post, user: user, topic: topic) }
    let(:invitee1) { Fabricate(:user) }
    let(:invitee2) { Fabricate(:user) }

Fixed code:           invalid_allowed_groups: "Invalid allowed groups."
          acting_user_not_allowed_to_invite_these_groups: "Current user is not allowed to invite these groups."
      invitees = filter_out_unavailable_groups(invitees)


    def invitee_groups(invitees)
      Group.where(name: invitees.map { |i| i[:identifier] })
    end

    def filter_out_unavailable_groups(invitees)
      groups = invitee_groups(invitees)
      invitees.filter do |i|
        group = groups.find { |g| g.name === i[:identifier] }

        !group || (@guardian.can_see_group?(group) && @guardian.can_see_group_members?(group))
      end
    end
      return false unless can_invite_groups?(extracted_event)


    private

    def can_invite_groups?(event)
      guardian = Guardian.new(@post.acting_user)
      return true unless event[:"allowed-groups"]

      event[:"allowed-groups"].split(',').each do |group_name|
        group = Group.find_by(name: group_name)

        if !group || !guardian.can_see_group?(group)
          @post.errors.add(:base, I18n.t("discourse_post_event.errors.models.event.invalid_allowed_groups"))
          return false
        end

        if !guardian.can_see_group_members?(group)
          @post.errors.add(:base, I18n.t("discourse_post_event.errors.models.event.acting_user_not_allowed_to_invite_these_groups"))
          return false
        end
      end

      true
    end
            Fabricate(:group, name: "euro")
            Fabricate(:group, name: "america")


    it "rejects private groups in allowedGroups" do
      moderator = Fabricate(:user, moderator: true)
      private_group = Fabricate(
        :group,
        visibility_level: Group.visibility_levels[:owners])

      expect {
        create_post_with_event(moderator, "allowedGroups='#{private_group.name}'")
      }.to raise_error(ActiveRecord::RecordNotSaved)
    end

    it "rejects non-existent groups in allowedGroups" do
      moderator = Fabricate(:user, moderator: true)

      expect {
        create_post_with_event(moderator, "allowedGroups='non-existent_group_name'")
      }.to raise_error(ActiveRecord::RecordNotSaved)
    end

    it "rejects public groups with private members in allowedGroups" do
      moderator = Fabricate(:user, moderator: true)
      public_group_with_private_members = Fabricate(
        :group,
        visibility_level: Group.visibility_levels[:public],
        members_visibility_level: Group.visibility_levels[:owners])

      expect {
        create_post_with_event(moderator, "allowedGroups='#{public_group_with_private_members.name}'")
      }.to raise_error(ActiveRecord::RecordNotSaved)
    end
      let(:user) { Fabricate(:user, admin: true) }
      let(:topic) { Fabricate(:topic, user: user) }
      let(:post1) { Fabricate(:post, user: user, topic: topic) }
      let(:invitee1) { Fabricate(:user) }
      let(:invitee2) { Fabricate(:user) }


    context 'with a private event' do
      let(:moderator) { Fabricate(:user, moderator: true) }
      let(:topic) { Fabricate(:topic, user: moderator) }
      let(:first_post) { Fabricate(:post, user: moderator, topic: topic) }
      let(:private_event) { Fabricate(:event, post: first_post, status: Event.statuses[:private]) }

      before do
        sign_in(moderator)
      end

      context 'when bulk inviting via CSV file' do
        def csv_file(content)
          file = Tempfile.new("invites.csv")
          file.write(content)
          file.rewind
          file
        end

        it "doesn't invite a private group" do
          private_group = Fabricate(:group, visibility_level: Group.visibility_levels[:owners])

          file = csv_file("#{private_group.name},going\n")
          params = { file: fixture_file_upload(file) }
          post "/discourse-post-event/events/#{private_event.id}/csv-bulk-invite.json", { params: params }

          expect(response.status).to eq(200)
          private_event.reload
          expect(private_event.raw_invitees).to be_nil
        end

        it "returns 200 when inviting a non-existent group" do
          file = csv_file("non-existent group name,going\n")
          params = { file: fixture_file_upload(file) }
          post "/discourse-post-event/events/#{private_event.id}/csv-bulk-invite.json", { params: params }

          expect(response.status).to eq(200)
        end

        it "doesn't invite a public group with private members" do
          public_group_with_private_members = Fabricate(
            :group,
            visibility_level: Group.visibility_levels[:public],
            members_visibility_level: Group.visibility_levels[:owners])

          file = csv_file("#{public_group_with_private_members.name},going\n")
          params = { file: fixture_file_upload(file) }
          post "/discourse-post-event/events/#{private_event.id}/csv-bulk-invite.json", { params: params }

          expect(response.status).to eq(200)
          private_event.reload
          expect(private_event.raw_invitees).to be_nil
        end
      end

      context 'when doing bulk inviting via UI' do
        it "doesn't invite a private group" do
          private_group = Fabricate(:group, visibility_level: Group.visibility_levels[:owners])

          params = { invitees: [
            { 'identifier' => private_group.name, 'attendance' => 'going' }
          ] }
          post "/discourse-post-event/events/#{private_event.id}/bulk-invite.json", { params: params }

          expect(response.status).to eq(200)
          private_event.reload
          expect(private_event.raw_invitees).to be_nil
        end

        it "returns 200 when inviting a non-existent group" do
          params = { invitees: [
            { 'identifier' => 'non-existent group name', 'attendance' => 'going' }
          ] }
          post "/discourse-post-event/events/#{private_event.id}/bulk-invite.json", { params: params }

          expect(response.status).to eq(200)
        end

        it "doesn't invite a public group with private members" do
          public_group_with_private_members = Fabricate(
            :group,
            visibility_level: Group.visibility_levels[:public],
            members_visibility_level: Group.visibility_levels[:owners])

          params = { invitees: [
            { 'identifier' => public_group_with_private_members.name, 'attendance' => 'going' }
          ] }
          post "/discourse-post-event/events/#{private_event.id}/bulk-invite.json", { params: params }

          expect(response.status).to eq(200)
          private_event.reload
          expect(private_event.raw_invitees).to be_nil
        end
      end
    end