CVE-2026-44786
Description
Discourse chat events for public category channels leak message payloads to any MessageBus subscriber, fixed in 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 events for public category channels leak message payloads to any MessageBus subscriber, fixed in 2026.1.4, 2026.3.1, 2026.4.1, and 2026.5.0-latest.1.
Vulnerability
Discourse, an open-source discussion platform, publishes chat events for public category channels to MessageBus without permission scoping. This affects versions from 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 [1]. Any MessageBus subscriber who does not have chat enabled could receive chat message payloads in real time [1].
Exploitation
An attacker needs only to be a MessageBus subscriber; no authentication or chat feature activation is required. By subscribing to the relevant channels, the attacker can passively receive real-time chat message payloads for public category channels without having chat enabled themselves [1].
Impact
Successful exploitation results in unauthorized disclosure of chat message payloads in real time, leading to information disclosure of potentially sensitive communications in public discourse channels (confidentiality impact). The attacker does not need any specialized privileges beyond MessageBus access [1].
Mitigation
The vulnerability is patched in Discourse versions 2026.1.4, 2026.3.1, 2026.4.1, and 2026.5.0-latest.1 [1]. As a workaround, administrators can restrict chat_allowed_groups to logged-in groups (remove the everyone group) or temporarily disable the chat plugin until upgraded [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
1- Range: >=2026.1.0 <2026.1.4, >=2026.3.0 <2026.3.1, >=2026.4.0 <2026.4.1
Patches
5d32786547605SECURITY: Restrict public chat MessageBus broadcasts to chat-eligible users [backport 2026.4]
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
9e9dd0c608d6SECURITY: Restrict public chat MessageBus broadcasts to chat-eligible users [backport 2026.3]
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
7fa5f5db3f09SECURITY: Chat authorization and disclosure fixes [backport 2026.3]
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
5360fd345ae0SECURITY: Restrict public chat MessageBus broadcasts to chat-eligible users [backport 2026.1]
3 files changed · +69 −13
plugins/chat/app/services/chat/publisher.rb+15 −4 modified@@ -482,10 +482,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
c9c347de1554SECURITY: Chat authorization and disclosure fixes [backport 2026.1]
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
Vulnerability mechanics
Root cause
"Missing MessageBus permission scoping on chat broadcasts for public category channels allows any subscriber to receive real-time chat payloads."
Attack vector
An anonymous or chat-disabled MessageBus subscriber can receive real-time chat message payloads for public category channels because the broadcasts were published without permission scoping [patch_id=5750846]. An attacker only needs network access to the MessageBus endpoint and does not require any authentication or chat feature activation. The CVSS vector confirms the attack vector is network-based (AV:N) with no privileges required (PR:N) and no user interaction (UI:N).
Affected code
The primary vulnerability is in the `Chat::Publisher.permissions` method within `plugins/chat/app/services/chat/publisher.rb` (files patched by patch_id=5750846, patch_id=5750845, patch_id=5750842). The secondary fixes touch `plugins/chat/app/services/chat/create_thread.rb`, `plugins/chat/app/serializers/chat/channel_serializer.rb`, `plugins/chat/lib/chat/guardian_extensions.rb`, and `plugins/discourse-calendar/app/serializers/discourse_post_event/event_serializer.rb` (files patched by patch_id=5750844 and patch_id=5750843).
What the fix does
The patch modifies the private `permissions` method in `Chat::Publisher` to detect public (non-read-restricted) category channels that have no explicit `allowed_group_ids`, and substitutes the configured `chat_allowed_group_ids` instead [patch_id=5750846]. The `chat_allowed_group_ids` helper further maps the `everyone` auto-group to `trust_level_0`, ensuring anonymous clients are excluded while logged-in users still receive updates. This closes the gap that previously left the permissions hash empty (or absent) for public channel broadcasts.
Preconditions
- configThe Discourse instance must have chat enabled and public category channels configured.
- networkThe attacker must have network access to the MessageBus pub/sub endpoint (typically same HTTP origin).
- authNo authentication or chat feature activation is required on the attacker's part.
- inputThe attacker subscribes to the MessageBus channel for a public chat channel (e.g., /chat/{channel_id}).
Generated on Jun 12, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
1News mentions
0No linked articles in our index yet.