VYPR
Medium severity5.3NVD Advisory· Published Jun 12, 2026

CVE-2026-45085

CVE-2026-45085

Description

Discourse chat plugin had four authorization/disclosure flaws; patched in versions 2026.1.4, 2026.3.1, 2026.4.1, and 2026.5.0-latest.1.

AI Insight

LLM-synthesized narrative grounded in this CVE's description and references.

Discourse chat plugin had four authorization/disclosure flaws; patched in versions 2026.1.4, 2026.3.1, 2026.4.1, and 2026.5.0-latest.1.

Vulnerability

Discourse versions 2026.1.0-latest to before 2026.1.4, 2026.3.0-latest to before 2026.3.1, and 2026.4.0-latest to before 2026.4.1 contain four authorization and information disclosure issues in the chat plugin (one also involving discourse-calendar). Read-only category users could create chat threads; self-deleted chat messages could be restored by their author after channel access was revoked; moderators reviewing a flagged chat message were shown the channel's current last_message (often unrelated DM content); and calendar event payloads exposed the attached chat channel and its last message to viewers without chat access, including anonymous users. These affect sites with the chat plugin enabled; the calendar issue additionally requires discourse-calendar. [1]

Exploitation

For the thread creation issue, an attacker needs only read-only access to a category. For message restoration, the attacker must be the original author of a self-deleted message and have had their channel access revoked after deletion. The moderator disclosure occurs automatically when a moderator reviews a flagged chat message, revealing the channel's current last_message (which may be from a different conversation). The calendar leak requires the attacker to view a calendar event payload; no authentication is needed for anonymous users. [1]

Impact

Successful exploitation allows read-only users to create chat threads (unauthorized action), authors to restore self-deleted messages after losing access (data integrity bypass), moderators to see unrelated private messages (information disclosure), and viewers without chat access (including anonymous users) to see the attached chat channel and its last message (confidentiality breach). The calendar leak exposes potentially sensitive chat content to unauthorized parties. [1]

Mitigation

Patched versions are 2026.1.4, 2026.3.1, 2026.4.1, and 2026.5.0-latest.1. No full workaround exists other than upgrading. Disabling the chat plugin removes all exposure; detaching chat channels from public calendar events mitigates the calendar leak only. [1]

AI Insight generated on Jun 12, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.

Affected products

2
  • >=2026.1.0-latest,<2026.1.4 || >=2026.3.0-latest,<2026.3.1 || >=2026.4.0-latest,<2026.4.1+ 1 more
    • (no CPE)range: >=2026.1.0-latest,<2026.1.4 || >=2026.3.0-latest,<2026.3.1 || >=2026.4.0-latest,<2026.4.1
    • (no CPE)range: >=2026.1.0, <2026.1.4 || >=2026.3.0, <2026.3.1 || >=2026.4.0, <2026.4.1

Patches

4
0b9d2d7ed717

SECURITY: Chat authorization and disclosure fixes [backport 2026.4]

https://github.com/discourse/discourseNatMay 18, 2026Fixed in 2026.4.1via llm-release-walk
12 files changed · +293 3
  • plugins/chat/app/controllers/chat/api/channel_threads_controller.rb+1 0 modified
    @@ -85,6 +85,7 @@ def create
           end
           on_model_not_found(:channel) { raise Discourse::NotFound }
           on_failed_policy(:can_view_channel) { raise Discourse::InvalidAccess }
    +      on_failed_policy(:can_create_thread_in_channel) { raise Discourse::InvalidAccess }
           on_failed_policy(:threading_enabled_for_channel) { raise Discourse::NotFound }
           on_model_errors(:thread) do |model|
             render json: failed_json.merge(errors: [model.errors.full_messages.join(", ")]),
    
  • plugins/chat/app/serializers/chat/channel_serializer.rb+4 0 modified
    @@ -114,6 +114,10 @@ def include_current_user_membership?
           @current_user_membership.present?
         end
     
    +    def include_last_message?
    +      scope.can_preview_chat_channel?(object)
    +    end
    +
         def current_user_membership
           @current_user_membership.chat_channel = object
     
    
  • plugins/chat/app/services/chat/create_thread.rb+5 1 modified
    @@ -51,7 +51,11 @@ def can_view_channel(guardian:, channel:)
         end
     
         def can_create_thread_in_channel(guardian:, channel:)
    -      guardian.can_create_channel_message?(channel)
    +      if channel.direct_message_channel?
    +        return Chat::Channel::Policy::MessageCreation.new(context).call
    +      end
    +
    +      guardian.can_join_chat_channel?(channel) && guardian.can_create_channel_message?(channel)
         end
     
         def threading_enabled_for_channel(channel:)
    
  • plugins/chat/lib/chat/guardian_extensions.rb+2 0 modified
    @@ -244,6 +244,8 @@ def can_restore_chat?(message, chatable)
           return false if !can_modify_channel_message?(message.chat_channel)
     
           if message.user_id == current_user.id
    +        return false if !can_preview_chat_channel?(message.chat_channel)
    +
             case chatable
             when Category
               return message.deleted_by_id == current_user.id || can_moderate_chat?(chatable)
    
  • plugins/chat/spec/lib/chat/guardian_extensions_spec.rb+32 0 modified
    @@ -605,6 +605,38 @@
                 expect(guardian.can_restore_chat?(message, chatable)).to eq(false)
               end
             end
    +
    +        context "when the owner has lost access to a private category channel" do
    +          fab!(:revoke_group, :group)
    +          fab!(:revoked_category) { Fabricate(:private_category, group: revoke_group) }
    +          fab!(:revoked_channel) { Fabricate(:chat_channel, chatable: revoked_category) }
    +          fab!(:message) { Fabricate(:chat_message, chat_channel: revoked_channel, user: user) }
    +
    +          before do
    +            revoke_group.add(user)
    +            message.trash!(guardian.user)
    +            GroupUser.where(group: revoke_group, user: user).destroy_all
    +          end
    +
    +          it "disallows owner to restore" do
    +            expect(guardian.can_restore_chat?(message, revoked_category)).to eq(false)
    +          end
    +        end
    +
    +        context "when the owner is no longer in a direct message channel" do
    +          fab!(:other_user, :user)
    +          fab!(:dm_channel) { Fabricate(:direct_message_channel, users: [user, other_user]) }
    +          fab!(:message) { Fabricate(:chat_message, chat_channel: dm_channel, user: user) }
    +
    +          before do
    +            message.trash!(guardian.user)
    +            dm_channel.chatable.direct_message_users.find_by!(user: user).destroy!
    +          end
    +
    +          it "disallows owner to restore" do
    +            expect(guardian.can_restore_chat?(message, dm_channel.chatable)).to eq(false)
    +          end
    +        end
           end
         end
     
    
  • plugins/chat/spec/requests/chat/api/channel_messages_controller_spec.rb+44 0 modified
    @@ -255,4 +255,48 @@
           end
         end
       end
    +
    +  describe "#restore" do
    +    context "when the user no longer has access to a private category channel" do
    +      fab!(:group)
    +      fab!(:private_category) { Fabricate(:private_category, group:) }
    +      fab!(:private_channel) { Fabricate(:chat_channel, chatable: private_category) }
    +      fab!(:message) { Fabricate(:chat_message, chat_channel: private_channel, user: current_user) }
    +
    +      before do
    +        group.add(current_user)
    +        private_channel.add(current_user)
    +        message.trash!(current_user)
    +        GroupUser.where(group: group, user: current_user).destroy_all
    +      end
    +
    +      it "does not restore their own deleted message" do
    +        put "/chat/api/channels/#{private_channel.id}/messages/#{message.id}/restore"
    +
    +        expect(response.status).to eq(403)
    +        expect(message.reload).to be_trashed
    +      end
    +    end
    +
    +    context "when the user is no longer a member of a direct message channel" do
    +      fab!(:other_user, :user)
    +      fab!(:third_user, :user)
    +      fab!(:dm_channel) do
    +        Fabricate(:direct_message_channel, users: [current_user, other_user, third_user])
    +      end
    +      fab!(:message) { Fabricate(:chat_message, chat_channel: dm_channel, user: current_user) }
    +
    +      before do
    +        message.trash!(current_user)
    +        dm_channel.chatable.direct_message_users.find_by!(user: current_user).destroy!
    +      end
    +
    +      it "does not restore their own deleted message" do
    +        put "/chat/api/channels/#{dm_channel.id}/messages/#{message.id}/restore"
    +
    +        expect(response.status).to eq(403)
    +        expect(message.reload).to be_trashed
    +      end
    +    end
    +  end
     end
    
  • plugins/chat/spec/requests/chat/api/channel_threads_controller_spec.rb+21 0 modified
    @@ -328,6 +328,27 @@
             end
           end
     
    +      context "when user can only see a readonly category channel" do
    +        fab!(:group) { Fabricate(:group, users: [current_user]) }
    +        fab!(:category) do
    +          Fabricate(
    +            :private_category,
    +            group: group,
    +            permission_type: CategoryGroup.permission_types[:readonly],
    +          )
    +        end
    +        fab!(:channel_1) do
    +          Fabricate(:category_channel, chatable: category, threading_enabled: true)
    +        end
    +        fab!(:message_1) { Fabricate(:chat_message, chat_channel: channel_1) }
    +
    +        it "returns 403" do
    +          post "/chat/api/channels/#{channel_id}/threads", params: params
    +
    +          expect(response.status).to eq(403)
    +        end
    +      end
    +
           context "when the title is too long" do
             let(:title) { "x" * Chat::Thread::MAX_TITLE_LENGTH + "x" }
     
    
  • plugins/chat/spec/requests/reviewables_controller_spec.rb+54 0 added
    @@ -0,0 +1,54 @@
    +# frozen_string_literal: true
    +
    +RSpec.describe ReviewablesController do
    +  fab!(:moderator)
    +  fab!(:message_author) { Fabricate(:user, refresh_auto_groups: true) }
    +  fab!(:flagger) { Fabricate(:user, refresh_auto_groups: true) }
    +  fab!(:direct_message_channel) do
    +    Fabricate(:direct_message_channel, users: [message_author, flagger])
    +  end
    +
    +  before do
    +    SiteSetting.chat_enabled = true
    +    SiteSetting.chat_allowed_groups = Group::AUTO_GROUPS[:everyone]
    +    SiteSetting.chat_message_flag_allowed_groups = Group::AUTO_GROUPS[:everyone]
    +  end
    +
    +  describe "#index" do
    +    it "does not expose unrelated direct-message last message content to moderators" do
    +      flagged_message_text = "flagged direct message content"
    +      unrelated_last_message_text = "unrelated later direct message secret"
    +      flagged_message =
    +        Fabricate(
    +          :chat_message,
    +          chat_channel: direct_message_channel,
    +          user: message_author,
    +          message: flagged_message_text,
    +        )
    +
    +      result =
    +        Chat::ReviewQueue.new.flag_message(
    +          flagged_message,
    +          Guardian.new(flagger),
    +          ReviewableScore.types[:spam],
    +        )
    +      expect(result[:success]).to eq(true)
    +
    +      unrelated_last_message =
    +        Fabricate(
    +          :chat_message,
    +          chat_channel: direct_message_channel,
    +          user: flagger,
    +          message: unrelated_last_message_text,
    +        )
    +      direct_message_channel.update!(last_message: unrelated_last_message)
    +
    +      sign_in(moderator)
    +      get "/review.json", params: { type: "Chat::ReviewableMessage" }
    +
    +      expect(response.status).to eq(200)
    +      expect(response.body).to include(flagged_message_text)
    +      expect(response.body).not_to include(unrelated_last_message_text)
    +    end
    +  end
    +end
    
  • plugins/chat/spec/serializer/chat/channel_serializer_spec.rb+28 0 modified
    @@ -122,4 +122,32 @@
     
         expect(serializer.as_json[:unicode_title]).to eq("🐈 Cats")
       end
    +
    +  describe "#last_message" do
    +    fab!(:last_message) { Fabricate(:chat_message, chat_channel: chat_channel) }
    +
    +    before { chat_channel.update!(last_message: last_message) }
    +
    +    context "when the user can preview the channel" do
    +      let(:guardian_user) { admin }
    +
    +      it "includes the last_message" do
    +        expect(serializer.as_json[:last_message]).to be_present
    +        expect(serializer.as_json[:last_message][:id]).to eq(last_message.id)
    +      end
    +    end
    +
    +    context "when the user cannot preview the channel" do
    +      fab!(:private_channel, :private_category_channel)
    +      fab!(:last_private_message) { Fabricate(:chat_message, chat_channel: private_channel) }
    +
    +      let(:chat_channel) { private_channel }
    +
    +      before { private_channel.update!(last_message: last_private_message) }
    +
    +      it "omits the last_message" do
    +        expect(serializer.as_json[:last_message]).to be_nil
    +      end
    +    end
    +  end
     end
    
  • plugins/chat/spec/services/chat/create_thread_spec.rb+21 0 modified
    @@ -28,6 +28,12 @@
         let(:params) { { original_message_id: message_1.id, channel_id: channel_1.id, title: } }
         let(:dependencies) { { guardian: } }
     
    +    before do
    +      SiteSetting.chat_enabled = true
    +      SiteSetting.chat_allowed_groups = Group::AUTO_GROUPS[:everyone]
    +      SiteSetting.direct_message_enabled_groups = Group::AUTO_GROUPS[:everyone]
    +    end
    +
         context "when all steps pass" do
           it { is_expected.to run_successfully }
     
    @@ -106,6 +112,21 @@
           it { is_expected.to fail_a_policy(:can_view_channel) }
         end
     
    +    context "when user can only see a readonly category channel" do
    +      fab!(:group) { Fabricate(:group, users: [current_user]) }
    +      fab!(:category) do
    +        Fabricate(
    +          :private_category,
    +          group: group,
    +          permission_type: CategoryGroup.permission_types[:readonly],
    +        )
    +      end
    +      fab!(:channel_1) { Fabricate(:category_channel, chatable: category, threading_enabled: true) }
    +      fab!(:message_1) { Fabricate(:chat_message, chat_channel: channel_1) }
    +
    +      it { is_expected.to fail_a_policy(:can_create_thread_in_channel) }
    +    end
    +
         context "when channel is not open" do
           context "when channel is read_only" do
             before { channel_1.update!(status: :read_only) }
    
  • plugins/discourse-calendar/app/serializers/discourse_post_event/event_serializer.rb+8 2 modified
    @@ -37,11 +37,17 @@ class EventSerializer < BasicEventSerializer
         has_one :image_upload, embed: :object, serializer: UploadSerializer
     
         def channel
    -      ::Chat::ChannelSerializer.new(object.chat_channel, root: false, scope:)
    +      ::Chat::ChannelSerializer.new(
    +        object.chat_channel,
    +        root: false,
    +        scope:,
    +        membership: object.chat_channel.membership_for(scope.current_user),
    +      )
         end
     
         def include_channel?
    -      object.chat_enabled && defined?(::Chat::ChannelSerializer) && object.chat_channel.present?
    +      object.chat_enabled && defined?(::Chat::ChannelSerializer) && object.chat_channel.present? &&
    +        scope.can_chat? && scope.can_preview_chat_channel?(object.chat_channel)
         end
     
         def at_capacity
    
  • plugins/discourse-calendar/spec/requests/events_controller_spec.rb+73 0 modified
    @@ -726,6 +726,79 @@ def csv_file(content)
         end
       end
     
    +  describe "#show" do
    +    before do
    +      SiteSetting.calendar_enabled = true
    +      SiteSetting.discourse_post_event_enabled = true
    +    end
    +
    +    fab!(:admin_user) { Fabricate(:user, admin: true) }
    +    fab!(:category)
    +    fab!(:topic) { Fabricate(:topic, user: admin_user, category: category) }
    +    fab!(:post_1) { Fabricate(:post, user: admin_user, topic: topic) }
    +    fab!(:chat_channel) { Fabricate(:chat_channel, chatable: category) }
    +    fab!(:event) { Fabricate(:event, post: post_1, chat_enabled: true, chat_channel: chat_channel) }
    +    fab!(:chat_message) do
    +      Fabricate(
    +        :chat_message,
    +        chat_channel: chat_channel,
    +        user: admin_user,
    +        message: "private chat message body",
    +      )
    +    end
    +
    +    before { chat_channel.update!(last_message: chat_message) }
    +
    +    context "when the viewer is anonymous" do
    +      before do
    +        SiteSetting.chat_enabled = true
    +        SiteSetting.chat_allowed_groups = Group::AUTO_GROUPS[:everyone]
    +      end
    +
    +      it "does not include the chat channel block or last message body" do
    +        get "/discourse-post-event/events/#{event.id}.json"
    +
    +        expect(response.status).to eq(200)
    +        expect(response.parsed_body["event"]).not_to have_key("channel")
    +        expect(response.body).not_to include("private chat message body")
    +      end
    +    end
    +
    +    context "when the viewer cannot join the chat channel" do
    +      fab!(:viewer, :user)
    +
    +      before do
    +        SiteSetting.chat_enabled = true
    +        SiteSetting.chat_allowed_groups = Group::AUTO_GROUPS[:staff]
    +        sign_in(viewer)
    +      end
    +
    +      it "does not include the chat channel block or last message body" do
    +        get "/discourse-post-event/events/#{event.id}.json"
    +
    +        expect(response.status).to eq(200)
    +        expect(response.parsed_body["event"]).not_to have_key("channel")
    +        expect(response.body).not_to include("private chat message body")
    +      end
    +    end
    +
    +    context "when the viewer can join the chat channel" do
    +      before do
    +        SiteSetting.chat_enabled = true
    +        SiteSetting.chat_allowed_groups = Group::AUTO_GROUPS[:everyone]
    +        sign_in(admin_user)
    +      end
    +
    +      it "includes the chat channel block with the last message body" do
    +        get "/discourse-post-event/events/#{event.id}.json"
    +
    +        expect(response.status).to eq(200)
    +        expect(response.parsed_body["event"]["channel"]).to be_present
    +        expect(response.body).to include("private chat message body")
    +      end
    +    end
    +  end
    +
       describe "anonymous access to EventsController" do
         before do
           SiteSetting.calendar_enabled = true
    
7fa5f5db3f09

SECURITY: Chat authorization and disclosure fixes [backport 2026.3]

https://github.com/discourse/discourseNatMay 18, 2026Fixed in 2026.3.1via llm-release-walk
12 files changed · +293 3
  • plugins/chat/app/controllers/chat/api/channel_threads_controller.rb+1 0 modified
    @@ -85,6 +85,7 @@ def create
           end
           on_model_not_found(:channel) { raise Discourse::NotFound }
           on_failed_policy(:can_view_channel) { raise Discourse::InvalidAccess }
    +      on_failed_policy(:can_create_thread_in_channel) { raise Discourse::InvalidAccess }
           on_failed_policy(:threading_enabled_for_channel) { raise Discourse::NotFound }
           on_model_errors(:thread) do |model|
             render json: failed_json.merge(errors: [model.errors.full_messages.join(", ")]),
    
  • plugins/chat/app/serializers/chat/channel_serializer.rb+4 0 modified
    @@ -114,6 +114,10 @@ def include_current_user_membership?
           @current_user_membership.present?
         end
     
    +    def include_last_message?
    +      scope.can_preview_chat_channel?(object)
    +    end
    +
         def current_user_membership
           @current_user_membership.chat_channel = object
     
    
  • plugins/chat/app/services/chat/create_thread.rb+5 1 modified
    @@ -51,7 +51,11 @@ def can_view_channel(guardian:, channel:)
         end
     
         def can_create_thread_in_channel(guardian:, channel:)
    -      guardian.can_create_channel_message?(channel)
    +      if channel.direct_message_channel?
    +        return Chat::Channel::Policy::MessageCreation.new(context).call
    +      end
    +
    +      guardian.can_join_chat_channel?(channel) && guardian.can_create_channel_message?(channel)
         end
     
         def threading_enabled_for_channel(channel:)
    
  • plugins/chat/lib/chat/guardian_extensions.rb+2 0 modified
    @@ -244,6 +244,8 @@ def can_restore_chat?(message, chatable)
           return false if !can_modify_channel_message?(message.chat_channel)
     
           if message.user_id == current_user.id
    +        return false if !can_preview_chat_channel?(message.chat_channel)
    +
             case chatable
             when Category
               return message.deleted_by_id == current_user.id || can_moderate_chat?(chatable)
    
  • plugins/chat/spec/lib/chat/guardian_extensions_spec.rb+32 0 modified
    @@ -605,6 +605,38 @@
                 expect(guardian.can_restore_chat?(message, chatable)).to eq(false)
               end
             end
    +
    +        context "when the owner has lost access to a private category channel" do
    +          fab!(:revoke_group, :group)
    +          fab!(:revoked_category) { Fabricate(:private_category, group: revoke_group) }
    +          fab!(:revoked_channel) { Fabricate(:chat_channel, chatable: revoked_category) }
    +          fab!(:message) { Fabricate(:chat_message, chat_channel: revoked_channel, user: user) }
    +
    +          before do
    +            revoke_group.add(user)
    +            message.trash!(guardian.user)
    +            GroupUser.where(group: revoke_group, user: user).destroy_all
    +          end
    +
    +          it "disallows owner to restore" do
    +            expect(guardian.can_restore_chat?(message, revoked_category)).to eq(false)
    +          end
    +        end
    +
    +        context "when the owner is no longer in a direct message channel" do
    +          fab!(:other_user, :user)
    +          fab!(:dm_channel) { Fabricate(:direct_message_channel, users: [user, other_user]) }
    +          fab!(:message) { Fabricate(:chat_message, chat_channel: dm_channel, user: user) }
    +
    +          before do
    +            message.trash!(guardian.user)
    +            dm_channel.chatable.direct_message_users.find_by!(user: user).destroy!
    +          end
    +
    +          it "disallows owner to restore" do
    +            expect(guardian.can_restore_chat?(message, dm_channel.chatable)).to eq(false)
    +          end
    +        end
           end
         end
     
    
  • plugins/chat/spec/requests/chat/api/channel_messages_controller_spec.rb+44 0 modified
    @@ -242,4 +242,48 @@
           end
         end
       end
    +
    +  describe "#restore" do
    +    context "when the user no longer has access to a private category channel" do
    +      fab!(:group)
    +      fab!(:private_category) { Fabricate(:private_category, group:) }
    +      fab!(:private_channel) { Fabricate(:chat_channel, chatable: private_category) }
    +      fab!(:message) { Fabricate(:chat_message, chat_channel: private_channel, user: current_user) }
    +
    +      before do
    +        group.add(current_user)
    +        private_channel.add(current_user)
    +        message.trash!(current_user)
    +        GroupUser.where(group: group, user: current_user).destroy_all
    +      end
    +
    +      it "does not restore their own deleted message" do
    +        put "/chat/api/channels/#{private_channel.id}/messages/#{message.id}/restore"
    +
    +        expect(response.status).to eq(403)
    +        expect(message.reload).to be_trashed
    +      end
    +    end
    +
    +    context "when the user is no longer a member of a direct message channel" do
    +      fab!(:other_user, :user)
    +      fab!(:third_user, :user)
    +      fab!(:dm_channel) do
    +        Fabricate(:direct_message_channel, users: [current_user, other_user, third_user])
    +      end
    +      fab!(:message) { Fabricate(:chat_message, chat_channel: dm_channel, user: current_user) }
    +
    +      before do
    +        message.trash!(current_user)
    +        dm_channel.chatable.direct_message_users.find_by!(user: current_user).destroy!
    +      end
    +
    +      it "does not restore their own deleted message" do
    +        put "/chat/api/channels/#{dm_channel.id}/messages/#{message.id}/restore"
    +
    +        expect(response.status).to eq(403)
    +        expect(message.reload).to be_trashed
    +      end
    +    end
    +  end
     end
    
  • plugins/chat/spec/requests/chat/api/channel_threads_controller_spec.rb+21 0 modified
    @@ -328,6 +328,27 @@
             end
           end
     
    +      context "when user can only see a readonly category channel" do
    +        fab!(:group) { Fabricate(:group, users: [current_user]) }
    +        fab!(:category) do
    +          Fabricate(
    +            :private_category,
    +            group: group,
    +            permission_type: CategoryGroup.permission_types[:readonly],
    +          )
    +        end
    +        fab!(:channel_1) do
    +          Fabricate(:category_channel, chatable: category, threading_enabled: true)
    +        end
    +        fab!(:message_1) { Fabricate(:chat_message, chat_channel: channel_1) }
    +
    +        it "returns 403" do
    +          post "/chat/api/channels/#{channel_id}/threads", params: params
    +
    +          expect(response.status).to eq(403)
    +        end
    +      end
    +
           context "when the title is too long" do
             let(:title) { "x" * Chat::Thread::MAX_TITLE_LENGTH + "x" }
     
    
  • plugins/chat/spec/requests/reviewables_controller_spec.rb+54 0 added
    @@ -0,0 +1,54 @@
    +# frozen_string_literal: true
    +
    +RSpec.describe ReviewablesController do
    +  fab!(:moderator)
    +  fab!(:message_author) { Fabricate(:user, refresh_auto_groups: true) }
    +  fab!(:flagger) { Fabricate(:user, refresh_auto_groups: true) }
    +  fab!(:direct_message_channel) do
    +    Fabricate(:direct_message_channel, users: [message_author, flagger])
    +  end
    +
    +  before do
    +    SiteSetting.chat_enabled = true
    +    SiteSetting.chat_allowed_groups = Group::AUTO_GROUPS[:everyone]
    +    SiteSetting.chat_message_flag_allowed_groups = Group::AUTO_GROUPS[:everyone]
    +  end
    +
    +  describe "#index" do
    +    it "does not expose unrelated direct-message last message content to moderators" do
    +      flagged_message_text = "flagged direct message content"
    +      unrelated_last_message_text = "unrelated later direct message secret"
    +      flagged_message =
    +        Fabricate(
    +          :chat_message,
    +          chat_channel: direct_message_channel,
    +          user: message_author,
    +          message: flagged_message_text,
    +        )
    +
    +      result =
    +        Chat::ReviewQueue.new.flag_message(
    +          flagged_message,
    +          Guardian.new(flagger),
    +          ReviewableScore.types[:spam],
    +        )
    +      expect(result[:success]).to eq(true)
    +
    +      unrelated_last_message =
    +        Fabricate(
    +          :chat_message,
    +          chat_channel: direct_message_channel,
    +          user: flagger,
    +          message: unrelated_last_message_text,
    +        )
    +      direct_message_channel.update!(last_message: unrelated_last_message)
    +
    +      sign_in(moderator)
    +      get "/review.json", params: { type: "Chat::ReviewableMessage" }
    +
    +      expect(response.status).to eq(200)
    +      expect(response.body).to include(flagged_message_text)
    +      expect(response.body).not_to include(unrelated_last_message_text)
    +    end
    +  end
    +end
    
  • plugins/chat/spec/serializer/chat/channel_serializer_spec.rb+28 0 modified
    @@ -122,4 +122,32 @@
     
         expect(serializer.as_json[:unicode_title]).to eq("🐈 Cats")
       end
    +
    +  describe "#last_message" do
    +    fab!(:last_message) { Fabricate(:chat_message, chat_channel: chat_channel) }
    +
    +    before { chat_channel.update!(last_message: last_message) }
    +
    +    context "when the user can preview the channel" do
    +      let(:guardian_user) { admin }
    +
    +      it "includes the last_message" do
    +        expect(serializer.as_json[:last_message]).to be_present
    +        expect(serializer.as_json[:last_message][:id]).to eq(last_message.id)
    +      end
    +    end
    +
    +    context "when the user cannot preview the channel" do
    +      fab!(:private_channel, :private_category_channel)
    +      fab!(:last_private_message) { Fabricate(:chat_message, chat_channel: private_channel) }
    +
    +      let(:chat_channel) { private_channel }
    +
    +      before { private_channel.update!(last_message: last_private_message) }
    +
    +      it "omits the last_message" do
    +        expect(serializer.as_json[:last_message]).to be_nil
    +      end
    +    end
    +  end
     end
    
  • plugins/chat/spec/services/chat/create_thread_spec.rb+21 0 modified
    @@ -28,6 +28,12 @@
         let(:params) { { original_message_id: message_1.id, channel_id: channel_1.id, title: } }
         let(:dependencies) { { guardian: } }
     
    +    before do
    +      SiteSetting.chat_enabled = true
    +      SiteSetting.chat_allowed_groups = Group::AUTO_GROUPS[:everyone]
    +      SiteSetting.direct_message_enabled_groups = Group::AUTO_GROUPS[:everyone]
    +    end
    +
         context "when all steps pass" do
           it { is_expected.to run_successfully }
     
    @@ -106,6 +112,21 @@
           it { is_expected.to fail_a_policy(:can_view_channel) }
         end
     
    +    context "when user can only see a readonly category channel" do
    +      fab!(:group) { Fabricate(:group, users: [current_user]) }
    +      fab!(:category) do
    +        Fabricate(
    +          :private_category,
    +          group: group,
    +          permission_type: CategoryGroup.permission_types[:readonly],
    +        )
    +      end
    +      fab!(:channel_1) { Fabricate(:category_channel, chatable: category, threading_enabled: true) }
    +      fab!(:message_1) { Fabricate(:chat_message, chat_channel: channel_1) }
    +
    +      it { is_expected.to fail_a_policy(:can_create_thread_in_channel) }
    +    end
    +
         context "when channel is not open" do
           context "when channel is read_only" do
             before { channel_1.update!(status: :read_only) }
    
  • plugins/discourse-calendar/app/serializers/discourse_post_event/event_serializer.rb+8 2 modified
    @@ -34,11 +34,17 @@ class EventSerializer < BasicEventSerializer
         attributes :at_capacity
     
         def channel
    -      ::Chat::ChannelSerializer.new(object.chat_channel, root: false, scope:)
    +      ::Chat::ChannelSerializer.new(
    +        object.chat_channel,
    +        root: false,
    +        scope:,
    +        membership: object.chat_channel.membership_for(scope.current_user),
    +      )
         end
     
         def include_channel?
    -      object.chat_enabled && defined?(::Chat::ChannelSerializer) && object.chat_channel.present?
    +      object.chat_enabled && defined?(::Chat::ChannelSerializer) && object.chat_channel.present? &&
    +        scope.can_chat? && scope.can_preview_chat_channel?(object.chat_channel)
         end
     
         def at_capacity
    
  • plugins/discourse-calendar/spec/requests/events_controller_spec.rb+73 0 modified
    @@ -689,6 +689,79 @@ def csv_file(content)
         end
       end
     
    +  describe "#show" do
    +    before do
    +      SiteSetting.calendar_enabled = true
    +      SiteSetting.discourse_post_event_enabled = true
    +    end
    +
    +    fab!(:admin_user) { Fabricate(:user, admin: true) }
    +    fab!(:category)
    +    fab!(:topic) { Fabricate(:topic, user: admin_user, category: category) }
    +    fab!(:post_1) { Fabricate(:post, user: admin_user, topic: topic) }
    +    fab!(:chat_channel) { Fabricate(:chat_channel, chatable: category) }
    +    fab!(:event) { Fabricate(:event, post: post_1, chat_enabled: true, chat_channel: chat_channel) }
    +    fab!(:chat_message) do
    +      Fabricate(
    +        :chat_message,
    +        chat_channel: chat_channel,
    +        user: admin_user,
    +        message: "private chat message body",
    +      )
    +    end
    +
    +    before { chat_channel.update!(last_message: chat_message) }
    +
    +    context "when the viewer is anonymous" do
    +      before do
    +        SiteSetting.chat_enabled = true
    +        SiteSetting.chat_allowed_groups = Group::AUTO_GROUPS[:everyone]
    +      end
    +
    +      it "does not include the chat channel block or last message body" do
    +        get "/discourse-post-event/events/#{event.id}.json"
    +
    +        expect(response.status).to eq(200)
    +        expect(response.parsed_body["event"]).not_to have_key("channel")
    +        expect(response.body).not_to include("private chat message body")
    +      end
    +    end
    +
    +    context "when the viewer cannot join the chat channel" do
    +      fab!(:viewer, :user)
    +
    +      before do
    +        SiteSetting.chat_enabled = true
    +        SiteSetting.chat_allowed_groups = Group::AUTO_GROUPS[:staff]
    +        sign_in(viewer)
    +      end
    +
    +      it "does not include the chat channel block or last message body" do
    +        get "/discourse-post-event/events/#{event.id}.json"
    +
    +        expect(response.status).to eq(200)
    +        expect(response.parsed_body["event"]).not_to have_key("channel")
    +        expect(response.body).not_to include("private chat message body")
    +      end
    +    end
    +
    +    context "when the viewer can join the chat channel" do
    +      before do
    +        SiteSetting.chat_enabled = true
    +        SiteSetting.chat_allowed_groups = Group::AUTO_GROUPS[:everyone]
    +        sign_in(admin_user)
    +      end
    +
    +      it "includes the chat channel block with the last message body" do
    +        get "/discourse-post-event/events/#{event.id}.json"
    +
    +        expect(response.status).to eq(200)
    +        expect(response.parsed_body["event"]["channel"]).to be_present
    +        expect(response.body).to include("private chat message body")
    +      end
    +    end
    +  end
    +
       describe "anonymous access to EventsController" do
         before do
           SiteSetting.calendar_enabled = true
    
c9c347de1554

SECURITY: Chat authorization and disclosure fixes [backport 2026.1]

https://github.com/discourse/discourseNatMay 18, 2026Fixed in 2026.1.4via llm-release-walk
12 files changed · +358 2
  • plugins/chat/app/controllers/chat/api/channel_threads_controller.rb+1 0 modified
    @@ -84,6 +84,7 @@ def create
           end
           on_model_not_found(:channel) { raise Discourse::NotFound }
           on_failed_policy(:can_view_channel) { raise Discourse::InvalidAccess }
    +      on_failed_policy(:can_create_thread_in_channel) { raise Discourse::InvalidAccess }
           on_failed_policy(:threading_enabled_for_channel) { raise Discourse::NotFound }
           on_model_errors(:thread) do |model|
             render json: failed_json.merge(errors: [model.errors.full_messages.join(", ")]),
    
  • plugins/chat/app/serializers/chat/channel_serializer.rb+4 0 modified
    @@ -105,6 +105,10 @@ def include_current_user_membership?
           @current_user_membership.present?
         end
     
    +    def include_last_message?
    +      scope.can_preview_chat_channel?(object)
    +    end
    +
         def current_user_membership
           @current_user_membership.chat_channel = object
     
    
  • plugins/chat/app/services/chat/create_thread.rb+9 0 modified
    @@ -28,6 +28,7 @@ class CreateThread
     
         model :channel
         policy :can_view_channel
    +    policy :can_create_thread_in_channel
         policy :threading_enabled_for_channel
         model :original_message
     
    @@ -49,6 +50,14 @@ def can_view_channel(guardian:, channel:)
           guardian.can_preview_chat_channel?(channel)
         end
     
    +    def can_create_thread_in_channel(guardian:, channel:)
    +      if channel.direct_message_channel?
    +        return Chat::Channel::Policy::MessageCreation.new(context).call
    +      end
    +
    +      guardian.can_join_chat_channel?(channel) && guardian.can_create_channel_message?(channel)
    +    end
    +
         def threading_enabled_for_channel(channel:)
           channel.threading_enabled?
         end
    
  • plugins/chat/lib/chat/guardian_extensions.rb+2 0 modified
    @@ -240,6 +240,8 @@ def can_restore_chat?(message, chatable)
           return false if !can_modify_channel_message?(message.chat_channel)
     
           if message.user_id == current_user.id
    +        return false if !can_preview_chat_channel?(message.chat_channel)
    +
             case chatable
             when Category
               return message.deleted_by_id == current_user.id || can_see_category?(chatable)
    
  • plugins/chat/spec/lib/chat/guardian_extensions_spec.rb+32 0 modified
    @@ -599,6 +599,38 @@
                 expect(guardian.can_restore_chat?(message, chatable)).to eq(false)
               end
             end
    +
    +        context "when the owner has lost access to a private category channel" do
    +          fab!(:revoke_group, :group)
    +          fab!(:revoked_category) { Fabricate(:private_category, group: revoke_group) }
    +          fab!(:revoked_channel) { Fabricate(:chat_channel, chatable: revoked_category) }
    +          fab!(:message) { Fabricate(:chat_message, chat_channel: revoked_channel, user: user) }
    +
    +          before do
    +            revoke_group.add(user)
    +            message.trash!(guardian.user)
    +            GroupUser.where(group: revoke_group, user: user).destroy_all
    +          end
    +
    +          it "disallows owner to restore" do
    +            expect(guardian.can_restore_chat?(message, revoked_category)).to eq(false)
    +          end
    +        end
    +
    +        context "when the owner is no longer in a direct message channel" do
    +          fab!(:other_user, :user)
    +          fab!(:dm_channel) { Fabricate(:direct_message_channel, users: [user, other_user]) }
    +          fab!(:message) { Fabricate(:chat_message, chat_channel: dm_channel, user: user) }
    +
    +          before do
    +            message.trash!(guardian.user)
    +            dm_channel.chatable.direct_message_users.find_by!(user: user).destroy!
    +          end
    +
    +          it "disallows owner to restore" do
    +            expect(guardian.can_restore_chat?(message, dm_channel.chatable)).to eq(false)
    +          end
    +        end
           end
         end
     
    
  • plugins/chat/spec/requests/chat/api/channel_messages_controller_spec.rb+44 0 modified
    @@ -203,4 +203,48 @@
           end
         end
       end
    +
    +  describe "#restore" do
    +    context "when the user no longer has access to a private category channel" do
    +      fab!(:group)
    +      fab!(:private_category) { Fabricate(:private_category, group:) }
    +      fab!(:private_channel) { Fabricate(:chat_channel, chatable: private_category) }
    +      fab!(:message) { Fabricate(:chat_message, chat_channel: private_channel, user: current_user) }
    +
    +      before do
    +        group.add(current_user)
    +        private_channel.add(current_user)
    +        message.trash!(current_user)
    +        GroupUser.where(group: group, user: current_user).destroy_all
    +      end
    +
    +      it "does not restore their own deleted message" do
    +        put "/chat/api/channels/#{private_channel.id}/messages/#{message.id}/restore"
    +
    +        expect(response.status).to eq(403)
    +        expect(message.reload).to be_trashed
    +      end
    +    end
    +
    +    context "when the user is no longer a member of a direct message channel" do
    +      fab!(:other_user, :user)
    +      fab!(:third_user, :user)
    +      fab!(:dm_channel) do
    +        Fabricate(:direct_message_channel, users: [current_user, other_user, third_user])
    +      end
    +      fab!(:message) { Fabricate(:chat_message, chat_channel: dm_channel, user: current_user) }
    +
    +      before do
    +        message.trash!(current_user)
    +        dm_channel.chatable.direct_message_users.find_by!(user: current_user).destroy!
    +      end
    +
    +      it "does not restore their own deleted message" do
    +        put "/chat/api/channels/#{dm_channel.id}/messages/#{message.id}/restore"
    +
    +        expect(response.status).to eq(403)
    +        expect(message.reload).to be_trashed
    +      end
    +    end
    +  end
     end
    
  • plugins/chat/spec/requests/chat/api/channel_threads_controller_spec.rb+21 0 modified
    @@ -284,6 +284,27 @@
             end
           end
     
    +      context "when user can only see a readonly category channel" do
    +        fab!(:group) { Fabricate(:group, users: [current_user]) }
    +        fab!(:category) do
    +          Fabricate(
    +            :private_category,
    +            group: group,
    +            permission_type: CategoryGroup.permission_types[:readonly],
    +          )
    +        end
    +        fab!(:channel_1) do
    +          Fabricate(:category_channel, chatable: category, threading_enabled: true)
    +        end
    +        fab!(:message_1) { Fabricate(:chat_message, chat_channel: channel_1) }
    +
    +        it "returns 403" do
    +          post "/chat/api/channels/#{channel_id}/threads", params: params
    +
    +          expect(response.status).to eq(403)
    +        end
    +      end
    +
           context "when the title is too long" do
             let(:title) { "x" * Chat::Thread::MAX_TITLE_LENGTH + "x" }
     
    
  • plugins/chat/spec/requests/reviewables_controller_spec.rb+54 0 added
    @@ -0,0 +1,54 @@
    +# frozen_string_literal: true
    +
    +RSpec.describe ReviewablesController do
    +  fab!(:moderator)
    +  fab!(:message_author) { Fabricate(:user, refresh_auto_groups: true) }
    +  fab!(:flagger) { Fabricate(:user, refresh_auto_groups: true) }
    +  fab!(:direct_message_channel) do
    +    Fabricate(:direct_message_channel, users: [message_author, flagger])
    +  end
    +
    +  before do
    +    SiteSetting.chat_enabled = true
    +    SiteSetting.chat_allowed_groups = Group::AUTO_GROUPS[:everyone]
    +    SiteSetting.chat_message_flag_allowed_groups = Group::AUTO_GROUPS[:everyone]
    +  end
    +
    +  describe "#index" do
    +    it "does not expose unrelated direct-message last message content to moderators" do
    +      flagged_message_text = "flagged direct message content"
    +      unrelated_last_message_text = "unrelated later direct message secret"
    +      flagged_message =
    +        Fabricate(
    +          :chat_message,
    +          chat_channel: direct_message_channel,
    +          user: message_author,
    +          message: flagged_message_text,
    +        )
    +
    +      result =
    +        Chat::ReviewQueue.new.flag_message(
    +          flagged_message,
    +          Guardian.new(flagger),
    +          ReviewableScore.types[:spam],
    +        )
    +      expect(result[:success]).to eq(true)
    +
    +      unrelated_last_message =
    +        Fabricate(
    +          :chat_message,
    +          chat_channel: direct_message_channel,
    +          user: flagger,
    +          message: unrelated_last_message_text,
    +        )
    +      direct_message_channel.update!(last_message: unrelated_last_message)
    +
    +      sign_in(moderator)
    +      get "/review.json", params: { type: "Chat::ReviewableMessage" }
    +
    +      expect(response.status).to eq(200)
    +      expect(response.body).to include(flagged_message_text)
    +      expect(response.body).not_to include(unrelated_last_message_text)
    +    end
    +  end
    +end
    
  • plugins/chat/spec/serializer/chat/channel_serializer_spec.rb+28 0 modified
    @@ -122,4 +122,32 @@
     
         expect(serializer.as_json[:unicode_title]).to eq("🐈 Cats")
       end
    +
    +  describe "#last_message" do
    +    fab!(:last_message) { Fabricate(:chat_message, chat_channel: chat_channel) }
    +
    +    before { chat_channel.update!(last_message: last_message) }
    +
    +    context "when the user can preview the channel" do
    +      let(:guardian_user) { admin }
    +
    +      it "includes the last_message" do
    +        expect(serializer.as_json[:last_message]).to be_present
    +        expect(serializer.as_json[:last_message][:id]).to eq(last_message.id)
    +      end
    +    end
    +
    +    context "when the user cannot preview the channel" do
    +      fab!(:private_channel, :private_category_channel)
    +      fab!(:last_private_message) { Fabricate(:chat_message, chat_channel: private_channel) }
    +
    +      let(:chat_channel) { private_channel }
    +
    +      before { private_channel.update!(last_message: last_private_message) }
    +
    +      it "omits the last_message" do
    +        expect(serializer.as_json[:last_message]).to be_nil
    +      end
    +    end
    +  end
     end
    
  • plugins/chat/spec/services/chat/create_thread_spec.rb+47 0 modified
    @@ -28,6 +28,12 @@
         let(:params) { { original_message_id: message_1.id, channel_id: channel_1.id, title: } }
         let(:dependencies) { { guardian: } }
     
    +    before do
    +      SiteSetting.chat_enabled = true
    +      SiteSetting.chat_allowed_groups = Group::AUTO_GROUPS[:everyone]
    +      SiteSetting.direct_message_enabled_groups = Group::AUTO_GROUPS[:everyone]
    +    end
    +
         context "when all steps pass" do
           it { is_expected.to run_successfully }
     
    @@ -106,6 +112,47 @@
           it { is_expected.to fail_a_policy(:can_view_channel) }
         end
     
    +    context "when user can only see a readonly category channel" do
    +      fab!(:group) { Fabricate(:group, users: [current_user]) }
    +      fab!(:category) do
    +        Fabricate(
    +          :private_category,
    +          group: group,
    +          permission_type: CategoryGroup.permission_types[:readonly],
    +        )
    +      end
    +      fab!(:channel_1) { Fabricate(:category_channel, chatable: category, threading_enabled: true) }
    +      fab!(:message_1) { Fabricate(:chat_message, chat_channel: channel_1) }
    +
    +      it { is_expected.to fail_a_policy(:can_create_thread_in_channel) }
    +    end
    +
    +    context "when channel is not open" do
    +      context "when channel is read_only" do
    +        before { channel_1.update!(status: :read_only) }
    +
    +        it { is_expected.to fail_a_policy(:can_create_thread_in_channel) }
    +      end
    +
    +      context "when channel is closed" do
    +        before { channel_1.update!(status: :closed) }
    +
    +        it { is_expected.to fail_a_policy(:can_create_thread_in_channel) }
    +
    +        context "when user is staff" do
    +          let(:guardian) { Guardian.new(Fabricate(:admin)) }
    +
    +          it { is_expected.to run_successfully }
    +        end
    +      end
    +
    +      context "when channel is archived" do
    +        before { channel_1.update!(status: :archived) }
    +
    +        it { is_expected.to fail_a_policy(:can_create_thread_in_channel) }
    +      end
    +    end
    +
         context "when threading is not enabled for the channel" do
           before { channel_1.update!(threading_enabled: false) }
     
    
  • plugins/discourse-calendar/app/serializers/discourse_post_event/event_serializer.rb+8 2 modified
    @@ -34,11 +34,17 @@ class EventSerializer < BasicEventSerializer
         attributes :at_capacity
     
         def channel
    -      ::Chat::ChannelSerializer.new(object.chat_channel, root: false, scope:)
    +      ::Chat::ChannelSerializer.new(
    +        object.chat_channel,
    +        root: false,
    +        scope:,
    +        membership: object.chat_channel.membership_for(scope.current_user),
    +      )
         end
     
         def include_channel?
    -      object.chat_enabled && defined?(::Chat::ChannelSerializer) && object.chat_channel.present?
    +      object.chat_enabled && defined?(::Chat::ChannelSerializer) && object.chat_channel.present? &&
    +        scope.can_chat? && scope.can_preview_chat_channel?(object.chat_channel)
         end
     
         def at_capacity
    
  • plugins/discourse-calendar/spec/requests/events_controller_spec.rb+108 0 modified
    @@ -541,4 +541,112 @@ def csv_file(content)
           expect(event.invitees.with_status(:going).count).to be <= 1
         end
       end
    +
    +  describe "#show" do
    +    before do
    +      SiteSetting.calendar_enabled = true
    +      SiteSetting.discourse_post_event_enabled = true
    +    end
    +
    +    fab!(:admin_user) { Fabricate(:user, admin: true) }
    +    fab!(:category)
    +    fab!(:topic) { Fabricate(:topic, user: admin_user, category: category) }
    +    fab!(:post_1) { Fabricate(:post, user: admin_user, topic: topic) }
    +    fab!(:chat_channel) { Fabricate(:chat_channel, chatable: category) }
    +    fab!(:event) { Fabricate(:event, post: post_1, chat_enabled: true, chat_channel: chat_channel) }
    +    fab!(:chat_message) do
    +      Fabricate(
    +        :chat_message,
    +        chat_channel: chat_channel,
    +        user: admin_user,
    +        message: "private chat message body",
    +      )
    +    end
    +
    +    before { chat_channel.update!(last_message: chat_message) }
    +
    +    context "when the viewer is anonymous" do
    +      before do
    +        SiteSetting.chat_enabled = true
    +        SiteSetting.chat_allowed_groups = Group::AUTO_GROUPS[:everyone]
    +      end
    +
    +      it "does not include the chat channel block or last message body" do
    +        get "/discourse-post-event/events/#{event.id}.json"
    +
    +        expect(response.status).to eq(200)
    +        expect(response.parsed_body["event"]).not_to have_key("channel")
    +        expect(response.body).not_to include("private chat message body")
    +      end
    +    end
    +
    +    context "when the viewer cannot join the chat channel" do
    +      fab!(:viewer, :user)
    +
    +      before do
    +        SiteSetting.chat_enabled = true
    +        SiteSetting.chat_allowed_groups = Group::AUTO_GROUPS[:staff]
    +        sign_in(viewer)
    +      end
    +
    +      it "does not include the chat channel block or last message body" do
    +        get "/discourse-post-event/events/#{event.id}.json"
    +
    +        expect(response.status).to eq(200)
    +        expect(response.parsed_body["event"]).not_to have_key("channel")
    +        expect(response.body).not_to include("private chat message body")
    +      end
    +    end
    +
    +    context "when the viewer can join the chat channel" do
    +      before do
    +        SiteSetting.chat_enabled = true
    +        SiteSetting.chat_allowed_groups = Group::AUTO_GROUPS[:everyone]
    +        sign_in(admin_user)
    +      end
    +
    +      it "includes the chat channel block with the last message body" do
    +        get "/discourse-post-event/events/#{event.id}.json"
    +
    +        expect(response.status).to eq(200)
    +        expect(response.parsed_body["event"]["channel"]).to be_present
    +        expect(response.body).to include("private chat message body")
    +      end
    +    end
    +  end
    +
    +  describe "anonymous access to EventsController" do
    +    before do
    +      SiteSetting.calendar_enabled = true
    +      SiteSetting.discourse_post_event_enabled = true
    +    end
    +
    +    fab!(:admin_user) { Fabricate(:user, admin: true) }
    +    fab!(:topic) { Fabricate(:topic, user: admin_user) }
    +    fab!(:post_1) { Fabricate(:post, user: admin_user, topic: topic) }
    +    fab!(:event) { Fabricate(:event, post: post_1) }
    +
    +    it "requires login for invite" do
    +      post "/discourse-post-event/events/#{event.id}/invite.json"
    +      expect(response.status).to eq(403)
    +    end
    +
    +    it "requires login for destroy" do
    +      delete "/discourse-post-event/events/#{event.id}.json"
    +      expect(response.status).to eq(403)
    +    end
    +
    +    it "requires login for bulk_invite" do
    +      post "/discourse-post-event/events/#{event.id}/bulk-invite.json",
    +           params: {
    +             invitees: [{ "identifier" => "bob", "attendance" => "going" }],
    +           }
    +      expect(response.status).to eq(403)
    +    end
    +
    +    it "requires login for csv_bulk_invite" do
    +      post "/discourse-post-event/events/#{event.id}/csv-bulk-invite.json"
    +      expect(response.status).to eq(403)
    +    end
    +  end
     end
    
9e9dd0c608d6

SECURITY: Restrict public chat MessageBus broadcasts to chat-eligible users [backport 2026.3]

https://github.com/discourse/discourseNatMay 18, 2026Fixed in 2026.3.1via llm-release-walk
3 files changed · +69 13
  • plugins/chat/app/services/chat/publisher.rb+15 4 modified
    @@ -501,10 +501,21 @@ def self.publish_notice(user_id:, channel_id:, text_content: nil, type: nil, dat
         private
     
         def self.permissions(channel)
    -      {
    -        user_ids: channel.allowed_user_ids.presence,
    -        group_ids: channel.allowed_group_ids.presence,
    -      }.compact
    +      group_ids = channel.allowed_group_ids.presence
    +      if group_ids.blank? && channel.category_channel? && !channel.read_restricted?
    +        group_ids = chat_allowed_group_ids
    +      end
    +
    +      { user_ids: channel.allowed_user_ids.presence, group_ids: group_ids }.compact
    +    end
    +
    +    def self.chat_allowed_group_ids
    +      Chat
    +        .allowed_group_ids
    +        .map do |group_id|
    +          group_id == Group::AUTO_GROUPS[:everyone] ? Group::AUTO_GROUPS[:trust_level_0] : group_id
    +        end
    +        .uniq
         end
     
         def self.anonymous_guardian
    
  • plugins/chat/spec/plugin_helper.rb+1 0 modified
    @@ -23,6 +23,7 @@ def chat_system_bootstrap(user = Fabricate(:admin), channels_for_membership = []
       def chat_system_user_bootstrap(user:, channel:)
         user.activate
         user.user_option.update!(chat_enabled: true)
    +    Group.refresh_automatic_group!(:trust_level_0)
         Group.refresh_automatic_group!("trust_level_#{user.trust_level}".to_sym)
         channel.add(user)
       end
    
  • plugins/chat/spec/services/chat/publisher_spec.rb+53 9 modified
    @@ -239,10 +239,48 @@
           end
     
           it "calls MessageBus with the correct permissions" do
    -        MessageBus.stubs(:publish)
    -        MessageBus.expects(:publish).with("/chat/#{channel.id}", anything, {})
    +        messages =
    +          MessageBus.track_publish("/chat/#{channel.id}") do
    +            described_class.publish_new!(channel, message_1, staged_id)
    +          end
     
    -        described_class.publish_new!(channel, message_1, staged_id)
    +        expect(messages.first.group_ids).to contain_exactly(*Chat.allowed_group_ids)
    +        expect(messages.first.user_ids).to eq(nil)
    +      end
    +
    +      it "publishes to trust level 0 when chat is allowed for everyone" do
    +        SiteSetting.chat_allowed_groups = Group::AUTO_GROUPS[:everyone]
    +
    +        messages =
    +          MessageBus.track_publish("/chat/#{channel.id}") do
    +            described_class.publish_new!(channel, message_1, staged_id)
    +          end
    +
    +        expect(messages.first.group_ids).to contain_exactly(
    +          Group::AUTO_GROUPS[:admins],
    +          Group::AUTO_GROUPS[:moderators],
    +          Group::AUTO_GROUPS[:trust_level_0],
    +        )
    +        expect(messages.first.user_ids).to eq(nil)
    +        expect(
    +          MessageBus::Client.new(client_id: "anonymous", message_bus: MessageBus).allowed?(
    +            messages.first,
    +          ),
    +        ).to eq(false)
    +        expect(
    +          MessageBus::Client.new(
    +            client_id: "allowed",
    +            group_ids: [Group::AUTO_GROUPS[:trust_level_0]],
    +            message_bus: MessageBus,
    +          ).allowed?(messages.first),
    +        ).to eq(true)
    +        expect(
    +          MessageBus::Client.new(
    +            client_id: "disallowed",
    +            user_id: 1,
    +            message_bus: MessageBus,
    +          ).allowed?(messages.first),
    +        ).to eq(false)
           end
         end
     
    @@ -269,10 +307,13 @@
             end
     
             it "calls MessageBus with the correct permissions" do
    -          MessageBus.stubs(:publish)
    -          MessageBus.expects(:publish).with("/chat/#{channel.id}", anything, {})
    +          messages =
    +            MessageBus.track_publish("/chat/#{channel.id}") do
    +              described_class.publish_new!(channel, message_1, staged_id)
    +            end
     
    -          described_class.publish_new!(channel, message_1, staged_id)
    +          expect(messages.first.group_ids).to contain_exactly(*Chat.allowed_group_ids)
    +          expect(messages.first.user_ids).to eq(nil)
             end
           end
     
    @@ -300,10 +341,13 @@
             end
     
             it "calls MessageBus with the correct permissions" do
    -          MessageBus.stubs(:publish)
    -          MessageBus.expects(:publish).with("/chat/#{channel.id}", anything, {})
    +          messages =
    +            MessageBus.track_publish("/chat/#{channel.id}") do
    +              described_class.publish_new!(channel, message_1, staged_id)
    +            end
     
    -          described_class.publish_new!(channel, message_1, staged_id)
    +          expect(messages.first.group_ids).to contain_exactly(*Chat.allowed_group_ids)
    +          expect(messages.first.user_ids).to eq(nil)
             end
           end
         end
    

Vulnerability mechanics

Root cause

"Missing authorization checks in chat thread creation, message restoration, channel serialization, calendar event serialization, and MessageBus broadcast permissions allow unauthorized users to create threads, restore messages, or view private chat content."

Attack vector

An attacker who is a read-only category member could POST to `Chat::CreateThread` to create threads despite lacking write permission [patch_id=5750837][patch_id=5750838][patch_id=5750840]. Separately, a user whose group membership or DM membership was revoked could PUT to restore their own previously self-deleted chat message [patch_id=5750837][patch_id=5750838][patch_id=5750840]. Additionally, a moderator reviewing a flagged DM message would see the channel's `last_message` (potentially unrelated DM content) because the serializer previously always included the last message [patch_id=5750837][patch_id=5750838][patch_id=5750840]. Finally, anonymous visitors and users without chat access could retrieve calendar event API responses that included the chat channel block and its last message body [patch_id=5750837][patch_id=5750838][patch_id=5750840]. The publisher also lacked MessageBus permissions, allowing anonymous subscribers to receive chat payloads [patch_id=5750839].

Affected code

The bundle addresses four distinct authorization/disclosure issues in the chat plugin (and discourse-calendar). The patches modify `Chat::CreateThread` (services/create_thread.rb), `Chat::GuardianExtensions` (guardian_extensions.rb), `Chat::ChannelSerializer` (channel_serializer.rb), `DiscoursePostEvent::EventSerializer` (event_serializer.rb), and `Chat::Publisher` (publisher.rb) — plus their corresponding specs. The `include_last_message?` predicate on `Chat::ChannelSerializer` [patch_id=5750837][patch_id=5750838][patch_id=5750840] is gated by `can_preview_chat_channel?`; the `include_channel?` check on the calendar event serializer [patch_id=5750837][patch_id=5750838][patch_id=5750840] adds `scope.can_chat? && scope.can_preview_chat_channel?(object.chat_channel)`.

What the fix does

The patches add missing authorization checks. In `Chat::CreateThread`, `can_create_thread_in_channel` now demands `can_join_chat_channel?` for category channels and the full `MessageCreation` policy for DMs, rejecting read-only users [patch_id=5750837][patch_id=5750838][patch_id=5750840]. In `GuardianExtensions`, a `can_preview_chat_channel?` short-circuit prevents message restoration after channel access is revoked [patch_id=5750837][patch_id=5750838][patch_id=5750840]. `Chat::ChannelSerializer#include_last_message?` hides the last message when the user cannot preview the channel [patch_id=5750837][patch_id=5750838][patch_id=5750840]. `DiscoursePostEvent::EventSerializer#include_channel?` now additionally checks `scope.can_chat?` and `scope.can_preview_chat_channel?`, so non-members and anonymous viewers do not receive the channel payload [patch_id=5750837][patch_id=5750838][patch_id=5750840]. The publisher `permissions` method maps the 'everyone' group to `trust_level_0` and scopes public broadcasts to the configured chat-allowed groups, preventing anonymous MessageBus subscribers from receiving chat events [patch_id=5750839].

Preconditions

  • configThe chat plugin must be enabled.
  • configFor the calendar issue, discourse-calendar must also be enabled.
  • networkThe attacker must have network access to the Discourse application.
  • authNo authentication is needed for the anonymous disclosure in the calendar issue; thread creation or message restoration require a logged-in user with read-only or prior channel access.

Generated on Jun 12, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

1

News mentions

0

No linked articles in our index yet.