Unauthenticated WebSocket binary frame causes denial of service in Mattermost Server
Description
Mattermost versions 11.6.x <= 11.6.0, 11.5.x <= 11.5.3, 11.4.x <= 11.4.4, 10.11.x <= 10.11.14 fail to properly validate msgpack-encoded WebSocket frames before memory allocation which allows an unauthenticated remote attacker to crash the server process and cause a full service outage for all users via a crafted binary WebSocket message sent to the public WebSocket endpoint.. Mattermost Advisory ID: MMSA-2026-00647
Affected products
1- Range: (11.4.x <= 11.4.4) || (11.5.x <= 11.5.3) || (11.6.x <= 11.6.0) || (10.11.x <= 10.11.14)
Patches
1029dd6d0cfdb7MM-68369: add missing TearDown in TestWebConnRejectBinaryFrameUnauthenticated (#36172)
1 file changed · +1 −0
server/channels/app/platform/web_conn_test.go+1 −0 modified@@ -245,6 +245,7 @@ func TestWebConnDrainDeadQueue(t *testing.T) { func TestWebConnRejectBinaryFrameUnauthenticated(t *testing.T) { th := Setup(t) + defer th.TearDown() readPumpDone := make(chan struct{}) upgradeErrCh := make(chan error, 1)
3b21498788a7Added nil checks (#35755) (#36133)
3 files changed · +250 −7
server/channels/app/channel.go+6 −6 modified@@ -1605,6 +1605,12 @@ func (a *App) DeleteChannel(rctx request.CTX, channel *model.Channel, userID str return err } + deleteAt := model.GetMillis() + + if err := a.Srv().Store().Channel().Delete(channel.Id, deleteAt); err != nil { + return model.NewAppError("DeleteChannel", "app.channel.delete.app_error", nil, "", http.StatusInternalServerError).Wrap(err) + } + if user != nil { T := i18n.GetUserTranslations(user.Locale) @@ -1660,12 +1666,6 @@ func (a *App) DeleteChannel(rctx request.CTX, channel *model.Channel, userID str return model.NewAppError("DeleteChannel", "app.post_persistent_notification.delete_by_channel.app_error", nil, "", http.StatusInternalServerError).Wrap(err) } - deleteAt := model.GetMillis() - - if err := a.Srv().Store().Channel().Delete(channel.Id, deleteAt); err != nil { - return model.NewAppError("DeleteChannel", "app.channel.delete.app_error", nil, "", http.StatusInternalServerError).Wrap(err) - } - a.Srv().Platform().InvalidateCacheForChannel(channel) var message *model.WebSocketEvent
server/channels/app/post_persistent_notification.go+26 −1 modified@@ -163,13 +163,25 @@ func (a *App) forEachPersistentNotificationPost(posts []*model.Post, fn func(pos return err } + var postsForPersistentNotificationCleanup []*model.Post + for _, post := range posts { channel := channelsMap[post.ChannelId] + if channel == nil { + postsForPersistentNotificationCleanup = append(postsForPersistentNotificationCleanup, post) + continue + } + team := teamsMap[channel.TeamId] // GMs and DMs don't belong to any team if channel.IsGroupOrDirect() { team = &model.Team{} + } else if team == nil { + // cleanup persistent notification for posts with missing teams when they are not DM or GM + postsForPersistentNotificationCleanup = append(postsForPersistentNotificationCleanup, post) + continue } + profileMap := channelProfileMap[channel.Id] // Ensure the sender is always in the profile map: for example, system admins can post @@ -210,6 +222,14 @@ func (a *App) forEachPersistentNotificationPost(posts []*model.Post, fn func(pos } } + if len(postsForPersistentNotificationCleanup) > 0 { + for _, post := range postsForPersistentNotificationCleanup { + if appErr := a.DeletePersistentNotification(request.EmptyContext(a.Log()), post); appErr != nil { + a.Log().Warn("Failed to delete persistent notification for post", mlog.String("post_id", post.Id), mlog.String("channel_id", post.ChannelId), mlog.Err(appErr)) + } + } + } + return nil } @@ -219,9 +239,14 @@ func (a *App) persistentNotificationsAuxiliaryData(channelsMap map[string]*model channelKeywords := make(map[string]MentionKeywords, len(channelsMap)) channelNotifyProps := make(map[string]map[string]model.StringMap, len(channelsMap)) for _, c := range channelsMap { + team := teamsMap[c.TeamId] + if team == nil && !c.IsGroupOrDirect() { + continue + } + // In DM, notifications can't be send to any 3rd person. if c.Type != model.ChannelTypeDirect { - groups, err := a.getGroupsAllowedForReferenceInChannel(c, teamsMap[c.TeamId]) + groups, err := a.getGroupsAllowedForReferenceInChannel(c, team) if err != nil { return nil, nil, nil, nil, errors.Wrapf(err, "failed to get profiles for channel %s", c.Id) }
server/channels/app/post_persistent_notification_test.go+218 −0 modified@@ -192,6 +192,224 @@ func TestDeletePersistentNotification(t *testing.T) { }) } +func TestForEachPersistentNotificationPost(t *testing.T) { + mainHelper.Parallel(t) + + t.Run("should cleanup posts whose channel no longer exists", func(t *testing.T) { + th := SetupWithStoreMock(t) + + user1 := &model.User{Id: "uid1", Username: "user-1"} + profileMap := map[string]*model.User{user1.Id: user1} + team := &model.Team{Id: "tid"} + channel := &model.Channel{Id: "chid", TeamId: team.Id, Type: model.ChannelTypeOpen} + + // post1 belongs to an existing channel; post2 belongs to a deleted/missing channel + post1 := &model.Post{Id: "pid1", ChannelId: channel.Id, Message: "hello @user-1", UserId: user1.Id} + post2 := &model.Post{Id: "pid2", ChannelId: "deleted-channel-id", Message: "hello", UserId: user1.Id} + + mockStore := th.App.Srv().Store().(*storemocks.Store) + + mockChannel := storemocks.ChannelStore{} + mockStore.On("Channel").Return(&mockChannel) + // Only return channel for post1; post2's channel is missing + mockChannel.On("GetChannelsByIds", mock.Anything, mock.Anything).Return([]*model.Channel{channel}, nil) + mockChannel.On("GetAllChannelMembersNotifyPropsForChannel", mock.Anything, mock.Anything).Return(map[string]model.StringMap{}, nil) + + mockTeam := storemocks.TeamStore{} + mockStore.On("Team").Return(&mockTeam) + mockTeam.On("GetMany", mock.Anything).Return([]*model.Team{team}, nil) + + mockUser := storemocks.UserStore{} + mockStore.On("User").Return(&mockUser) + mockUser.On("GetAllProfilesInChannel", mock.Anything, mock.Anything, mock.Anything).Return(profileMap, nil) + + mockGroup := storemocks.GroupStore{} + mockStore.On("Group").Return(&mockGroup) + mockGroup.On("GetGroups", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return([]*model.Group{}, nil) + + // DeletePersistentNotification mocks - the cleanup path calls GetSingle then Delete + mockPostPersistentNotification := storemocks.PostPersistentNotificationStore{} + mockStore.On("PostPersistentNotification").Return(&mockPostPersistentNotification) + mockPostPersistentNotification.On("GetSingle", post2.Id).Return(&model.PostPersistentNotifications{PostId: post2.Id}, nil) + mockPostPersistentNotification.On("Delete", []string{post2.Id}).Return(nil) + + th.App.Srv().SetLicense(getLicWithSkuShortName(model.LicenseShortSkuProfessional)) + cfg := th.App.Config() + *cfg.ServiceSettings.PostPriority = true + *cfg.ServiceSettings.AllowPersistentNotifications = true + + fnCalled := []string{} + err := th.App.forEachPersistentNotificationPost([]*model.Post{post1, post2}, func(post *model.Post, _ *model.Channel, _ *model.Team, _ *MentionResults, _ model.UserMap, _ map[string]map[string]model.StringMap) error { + fnCalled = append(fnCalled, post.Id) + return nil + }) + require.NoError(t, err) + + // The callback should only be called for post1 (valid channel) + assert.Equal(t, []string{"pid1"}, fnCalled) + // post2 persistent notification should have been cleaned up + mockPostPersistentNotification.AssertCalled(t, "Delete", []string{post2.Id}) + }) + + t.Run("should cleanup posts whose team no longer exists", func(t *testing.T) { + th := SetupWithStoreMock(t) + + user1 := &model.User{Id: "uid1", Username: "user-1"} + user2 := &model.User{Id: "uid2", Username: "user-2"} + profileMap := map[string]*model.User{user1.Id: user1, user2.Id: user2} + team := &model.Team{Id: "tid"} + channel := &model.Channel{Id: "chid", TeamId: team.Id, Type: model.ChannelTypeOpen} + // channelWithMissingTeam has a TeamId that won't be in teamsMap + channelWithMissingTeam := &model.Channel{Id: "chid2", TeamId: "deleted-team-id", Type: model.ChannelTypeOpen} + + post1 := &model.Post{Id: "pid1", ChannelId: channel.Id, Message: "hello @user-1", UserId: user2.Id} + post2 := &model.Post{Id: "pid2", ChannelId: channelWithMissingTeam.Id, Message: "hello @user-1", UserId: user2.Id} + + mockStore := th.App.Srv().Store().(*storemocks.Store) + + mockChannel := storemocks.ChannelStore{} + mockStore.On("Channel").Return(&mockChannel) + // Both channels exist, but only one team exists + mockChannel.On("GetChannelsByIds", mock.Anything, mock.Anything).Return([]*model.Channel{channel, channelWithMissingTeam}, nil) + mockChannel.On("GetAllChannelMembersNotifyPropsForChannel", mock.Anything, mock.Anything).Return(map[string]model.StringMap{}, nil) + + mockTeam := storemocks.TeamStore{} + mockStore.On("Team").Return(&mockTeam) + // Only return the team for channel, not for channelWithMissingTeam + mockTeam.On("GetMany", mock.Anything).Return([]*model.Team{team}, nil) + + mockUser := storemocks.UserStore{} + mockStore.On("User").Return(&mockUser) + mockUser.On("GetAllProfilesInChannel", mock.Anything, mock.Anything, mock.Anything).Return(profileMap, nil) + + mockGroup := storemocks.GroupStore{} + mockStore.On("Group").Return(&mockGroup) + mockGroup.On("GetGroups", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return([]*model.Group{}, nil) + + mockPostPersistentNotification := storemocks.PostPersistentNotificationStore{} + mockStore.On("PostPersistentNotification").Return(&mockPostPersistentNotification) + mockPostPersistentNotification.On("GetSingle", post2.Id).Return(&model.PostPersistentNotifications{PostId: post2.Id}, nil) + mockPostPersistentNotification.On("Delete", []string{post2.Id}).Return(nil) + + th.App.Srv().SetLicense(getLicWithSkuShortName(model.LicenseShortSkuProfessional)) + cfg := th.App.Config() + *cfg.ServiceSettings.PostPriority = true + *cfg.ServiceSettings.AllowPersistentNotifications = true + + fnCalled := []string{} + err := th.App.forEachPersistentNotificationPost([]*model.Post{post1, post2}, func(post *model.Post, _ *model.Channel, _ *model.Team, _ *MentionResults, _ model.UserMap, _ map[string]map[string]model.StringMap) error { + fnCalled = append(fnCalled, post.Id) + return nil + }) + require.NoError(t, err) + + // The callback should only be called for post1 (valid team) + assert.Equal(t, []string{"pid1"}, fnCalled) + // post2 persistent notification should have been cleaned up due to missing team + mockPostPersistentNotification.AssertCalled(t, "Delete", []string{post2.Id}) + }) + + t.Run("should not cleanup DM posts that have no team", func(t *testing.T) { + th := SetupWithStoreMock(t) + + user1 := &model.User{Id: "uid1", Username: "user-1"} + user2 := &model.User{Id: "uid2", Username: "user-2"} + profileMap := map[string]*model.User{user1.Id: user1, user2.Id: user2} + dmChannel := &model.Channel{Id: "dm-chid", TeamId: "", Type: model.ChannelTypeDirect, Name: model.GetDMNameFromIds(user1.Id, user2.Id)} + + post1 := &model.Post{Id: "pid1", ChannelId: dmChannel.Id, Message: "hello", UserId: user1.Id} + + mockStore := th.App.Srv().Store().(*storemocks.Store) + + mockChannel := storemocks.ChannelStore{} + mockStore.On("Channel").Return(&mockChannel) + mockChannel.On("GetChannelsByIds", mock.Anything, mock.Anything).Return([]*model.Channel{dmChannel}, nil) + + mockTeam := storemocks.TeamStore{} + mockStore.On("Team").Return(&mockTeam) + mockTeam.On("GetMany", mock.Anything).Return([]*model.Team{}, nil) + + mockUser := storemocks.UserStore{} + mockStore.On("User").Return(&mockUser) + mockUser.On("GetAllProfilesInChannel", mock.Anything, mock.Anything, mock.Anything).Return(profileMap, nil) + + mockGroup := storemocks.GroupStore{} + mockStore.On("Group").Return(&mockGroup) + mockGroup.On("GetGroups", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return([]*model.Group{}, nil) + + mockPostPersistentNotification := storemocks.PostPersistentNotificationStore{} + mockStore.On("PostPersistentNotification").Return(&mockPostPersistentNotification) + + th.App.Srv().SetLicense(getLicWithSkuShortName(model.LicenseShortSkuProfessional)) + cfg := th.App.Config() + *cfg.ServiceSettings.PostPriority = true + *cfg.ServiceSettings.AllowPersistentNotifications = true + + fnCalled := []string{} + err := th.App.forEachPersistentNotificationPost([]*model.Post{post1}, func(post *model.Post, _ *model.Channel, _ *model.Team, _ *MentionResults, _ model.UserMap, _ map[string]map[string]model.StringMap) error { + fnCalled = append(fnCalled, post.Id) + return nil + }) + require.NoError(t, err) + + // The callback should be called for the DM post even though there's no team + assert.Equal(t, []string{"pid1"}, fnCalled) + // Delete should NOT have been called — DMs don't need a team + mockPostPersistentNotification.AssertNotCalled(t, "Delete", mock.Anything) + }) + + t.Run("should not cleanup GM posts that have no team", func(t *testing.T) { + th := SetupWithStoreMock(t) + + user1 := &model.User{Id: "uid1", Username: "user-1"} + user2 := &model.User{Id: "uid2", Username: "user-2"} + user3 := &model.User{Id: "uid3", Username: "user-3"} + profileMap := map[string]*model.User{user1.Id: user1, user2.Id: user2, user3.Id: user3} + gmChannel := &model.Channel{Id: "gm-chid", TeamId: "", Type: model.ChannelTypeGroup} + + post1 := &model.Post{Id: "pid1", ChannelId: gmChannel.Id, Message: "hello @user-2", UserId: user1.Id} + + mockStore := th.App.Srv().Store().(*storemocks.Store) + + mockChannel := storemocks.ChannelStore{} + mockStore.On("Channel").Return(&mockChannel) + mockChannel.On("GetChannelsByIds", mock.Anything, mock.Anything).Return([]*model.Channel{gmChannel}, nil) + mockChannel.On("GetAllChannelMembersNotifyPropsForChannel", mock.Anything, mock.Anything).Return(map[string]model.StringMap{}, nil) + + mockTeam := storemocks.TeamStore{} + mockStore.On("Team").Return(&mockTeam) + mockTeam.On("GetMany", mock.Anything).Return([]*model.Team{}, nil) + + mockUser := storemocks.UserStore{} + mockStore.On("User").Return(&mockUser) + mockUser.On("GetAllProfilesInChannel", mock.Anything, mock.Anything, mock.Anything).Return(profileMap, nil) + + mockGroup := storemocks.GroupStore{} + mockStore.On("Group").Return(&mockGroup) + mockGroup.On("GetGroups", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return([]*model.Group{}, nil) + + mockPostPersistentNotification := storemocks.PostPersistentNotificationStore{} + mockStore.On("PostPersistentNotification").Return(&mockPostPersistentNotification) + + th.App.Srv().SetLicense(getLicWithSkuShortName(model.LicenseShortSkuProfessional)) + cfg := th.App.Config() + *cfg.ServiceSettings.PostPriority = true + *cfg.ServiceSettings.AllowPersistentNotifications = true + + fnCalled := []string{} + err := th.App.forEachPersistentNotificationPost([]*model.Post{post1}, func(post *model.Post, _ *model.Channel, _ *model.Team, _ *MentionResults, _ model.UserMap, _ map[string]map[string]model.StringMap) error { + fnCalled = append(fnCalled, post.Id) + return nil + }) + require.NoError(t, err) + + // The callback should be called for the GM post even though there's no team + assert.Equal(t, []string{"pid1"}, fnCalled) + // Delete should NOT have been called — GMs don't need a team + mockPostPersistentNotification.AssertNotCalled(t, "Delete", mock.Anything) + }) +} + func TestSendPersistentNotifications(t *testing.T) { mainHelper.Parallel(t) th := Setup(t).InitBasic(t)
2f2dabe523f8Added nil checks (#35755) (#36132)
3 files changed · +249 −7
server/channels/app/channel.go+5 −6 modified@@ -1605,6 +1605,11 @@ func (a *App) DeleteChannel(rctx request.CTX, channel *model.Channel, userID str return err } + deleteAt := model.GetMillis() + + if err := a.Srv().Store().Channel().Delete(channel.Id, deleteAt); err != nil { + return model.NewAppError("DeleteChannel", "app.channel.delete.app_error", nil, "", http.StatusInternalServerError).Wrap(err) + } if user != nil { T := i18n.GetUserTranslations(user.Locale) @@ -1660,12 +1665,6 @@ func (a *App) DeleteChannel(rctx request.CTX, channel *model.Channel, userID str return model.NewAppError("DeleteChannel", "app.post_persistent_notification.delete_by_channel.app_error", nil, "", http.StatusInternalServerError).Wrap(err) } - deleteAt := model.GetMillis() - - if err := a.Srv().Store().Channel().Delete(channel.Id, deleteAt); err != nil { - return model.NewAppError("DeleteChannel", "app.channel.delete.app_error", nil, "", http.StatusInternalServerError).Wrap(err) - } - a.Srv().Platform().InvalidateCacheForChannel(channel) var message *model.WebSocketEvent
server/channels/app/post_persistent_notification.go+26 −1 modified@@ -163,13 +163,25 @@ func (a *App) forEachPersistentNotificationPost(posts []*model.Post, fn func(pos return err } + var postsForPersistentNotificationCleanup []*model.Post + for _, post := range posts { channel := channelsMap[post.ChannelId] + if channel == nil { + postsForPersistentNotificationCleanup = append(postsForPersistentNotificationCleanup, post) + continue + } + team := teamsMap[channel.TeamId] // GMs and DMs don't belong to any team if channel.IsGroupOrDirect() { team = &model.Team{} + } else if team == nil { + // cleanup persistent notification for posts with missing teams when they are not DM or GM + postsForPersistentNotificationCleanup = append(postsForPersistentNotificationCleanup, post) + continue } + profileMap := channelProfileMap[channel.Id] // Ensure the sender is always in the profile map: for example, system admins can post @@ -210,6 +222,14 @@ func (a *App) forEachPersistentNotificationPost(posts []*model.Post, fn func(pos } } + if len(postsForPersistentNotificationCleanup) > 0 { + for _, post := range postsForPersistentNotificationCleanup { + if appErr := a.DeletePersistentNotification(request.EmptyContext(a.Log()), post); appErr != nil { + a.Log().Warn("Failed to delete persistent notification for post", mlog.String("post_id", post.Id), mlog.String("channel_id", post.ChannelId), mlog.Err(appErr)) + } + } + } + return nil } @@ -219,9 +239,14 @@ func (a *App) persistentNotificationsAuxiliaryData(channelsMap map[string]*model channelKeywords := make(map[string]MentionKeywords, len(channelsMap)) channelNotifyProps := make(map[string]map[string]model.StringMap, len(channelsMap)) for _, c := range channelsMap { + team := teamsMap[c.TeamId] + if team == nil && !c.IsGroupOrDirect() { + continue + } + // In DM, notifications can't be send to any 3rd person. if c.Type != model.ChannelTypeDirect { - groups, err := a.getGroupsAllowedForReferenceInChannel(c, teamsMap[c.TeamId]) + groups, err := a.getGroupsAllowedForReferenceInChannel(c, team) if err != nil { return nil, nil, nil, nil, errors.Wrapf(err, "failed to get profiles for channel %s", c.Id) }
server/channels/app/post_persistent_notification_test.go+218 −0 modified@@ -192,6 +192,224 @@ func TestDeletePersistentNotification(t *testing.T) { }) } +func TestForEachPersistentNotificationPost(t *testing.T) { + mainHelper.Parallel(t) + + t.Run("should cleanup posts whose channel no longer exists", func(t *testing.T) { + th := SetupWithStoreMock(t) + + user1 := &model.User{Id: "uid1", Username: "user-1"} + profileMap := map[string]*model.User{user1.Id: user1} + team := &model.Team{Id: "tid"} + channel := &model.Channel{Id: "chid", TeamId: team.Id, Type: model.ChannelTypeOpen} + + // post1 belongs to an existing channel; post2 belongs to a deleted/missing channel + post1 := &model.Post{Id: "pid1", ChannelId: channel.Id, Message: "hello @user-1", UserId: user1.Id} + post2 := &model.Post{Id: "pid2", ChannelId: "deleted-channel-id", Message: "hello", UserId: user1.Id} + + mockStore := th.App.Srv().Store().(*storemocks.Store) + + mockChannel := storemocks.ChannelStore{} + mockStore.On("Channel").Return(&mockChannel) + // Only return channel for post1; post2's channel is missing + mockChannel.On("GetChannelsByIds", mock.Anything, mock.Anything).Return([]*model.Channel{channel}, nil) + mockChannel.On("GetAllChannelMembersNotifyPropsForChannel", mock.Anything, mock.Anything).Return(map[string]model.StringMap{}, nil) + + mockTeam := storemocks.TeamStore{} + mockStore.On("Team").Return(&mockTeam) + mockTeam.On("GetMany", mock.Anything).Return([]*model.Team{team}, nil) + + mockUser := storemocks.UserStore{} + mockStore.On("User").Return(&mockUser) + mockUser.On("GetAllProfilesInChannel", mock.Anything, mock.Anything, mock.Anything).Return(profileMap, nil) + + mockGroup := storemocks.GroupStore{} + mockStore.On("Group").Return(&mockGroup) + mockGroup.On("GetGroups", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return([]*model.Group{}, nil) + + // DeletePersistentNotification mocks - the cleanup path calls GetSingle then Delete + mockPostPersistentNotification := storemocks.PostPersistentNotificationStore{} + mockStore.On("PostPersistentNotification").Return(&mockPostPersistentNotification) + mockPostPersistentNotification.On("GetSingle", post2.Id).Return(&model.PostPersistentNotifications{PostId: post2.Id}, nil) + mockPostPersistentNotification.On("Delete", []string{post2.Id}).Return(nil) + + th.App.Srv().SetLicense(getLicWithSkuShortName(model.LicenseShortSkuProfessional)) + cfg := th.App.Config() + *cfg.ServiceSettings.PostPriority = true + *cfg.ServiceSettings.AllowPersistentNotifications = true + + fnCalled := []string{} + err := th.App.forEachPersistentNotificationPost([]*model.Post{post1, post2}, func(post *model.Post, _ *model.Channel, _ *model.Team, _ *MentionResults, _ model.UserMap, _ map[string]map[string]model.StringMap) error { + fnCalled = append(fnCalled, post.Id) + return nil + }) + require.NoError(t, err) + + // The callback should only be called for post1 (valid channel) + assert.Equal(t, []string{"pid1"}, fnCalled) + // post2 persistent notification should have been cleaned up + mockPostPersistentNotification.AssertCalled(t, "Delete", []string{post2.Id}) + }) + + t.Run("should cleanup posts whose team no longer exists", func(t *testing.T) { + th := SetupWithStoreMock(t) + + user1 := &model.User{Id: "uid1", Username: "user-1"} + user2 := &model.User{Id: "uid2", Username: "user-2"} + profileMap := map[string]*model.User{user1.Id: user1, user2.Id: user2} + team := &model.Team{Id: "tid"} + channel := &model.Channel{Id: "chid", TeamId: team.Id, Type: model.ChannelTypeOpen} + // channelWithMissingTeam has a TeamId that won't be in teamsMap + channelWithMissingTeam := &model.Channel{Id: "chid2", TeamId: "deleted-team-id", Type: model.ChannelTypeOpen} + + post1 := &model.Post{Id: "pid1", ChannelId: channel.Id, Message: "hello @user-1", UserId: user2.Id} + post2 := &model.Post{Id: "pid2", ChannelId: channelWithMissingTeam.Id, Message: "hello @user-1", UserId: user2.Id} + + mockStore := th.App.Srv().Store().(*storemocks.Store) + + mockChannel := storemocks.ChannelStore{} + mockStore.On("Channel").Return(&mockChannel) + // Both channels exist, but only one team exists + mockChannel.On("GetChannelsByIds", mock.Anything, mock.Anything).Return([]*model.Channel{channel, channelWithMissingTeam}, nil) + mockChannel.On("GetAllChannelMembersNotifyPropsForChannel", mock.Anything, mock.Anything).Return(map[string]model.StringMap{}, nil) + + mockTeam := storemocks.TeamStore{} + mockStore.On("Team").Return(&mockTeam) + // Only return the team for channel, not for channelWithMissingTeam + mockTeam.On("GetMany", mock.Anything).Return([]*model.Team{team}, nil) + + mockUser := storemocks.UserStore{} + mockStore.On("User").Return(&mockUser) + mockUser.On("GetAllProfilesInChannel", mock.Anything, mock.Anything, mock.Anything).Return(profileMap, nil) + + mockGroup := storemocks.GroupStore{} + mockStore.On("Group").Return(&mockGroup) + mockGroup.On("GetGroups", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return([]*model.Group{}, nil) + + mockPostPersistentNotification := storemocks.PostPersistentNotificationStore{} + mockStore.On("PostPersistentNotification").Return(&mockPostPersistentNotification) + mockPostPersistentNotification.On("GetSingle", post2.Id).Return(&model.PostPersistentNotifications{PostId: post2.Id}, nil) + mockPostPersistentNotification.On("Delete", []string{post2.Id}).Return(nil) + + th.App.Srv().SetLicense(getLicWithSkuShortName(model.LicenseShortSkuProfessional)) + cfg := th.App.Config() + *cfg.ServiceSettings.PostPriority = true + *cfg.ServiceSettings.AllowPersistentNotifications = true + + fnCalled := []string{} + err := th.App.forEachPersistentNotificationPost([]*model.Post{post1, post2}, func(post *model.Post, _ *model.Channel, _ *model.Team, _ *MentionResults, _ model.UserMap, _ map[string]map[string]model.StringMap) error { + fnCalled = append(fnCalled, post.Id) + return nil + }) + require.NoError(t, err) + + // The callback should only be called for post1 (valid team) + assert.Equal(t, []string{"pid1"}, fnCalled) + // post2 persistent notification should have been cleaned up due to missing team + mockPostPersistentNotification.AssertCalled(t, "Delete", []string{post2.Id}) + }) + + t.Run("should not cleanup DM posts that have no team", func(t *testing.T) { + th := SetupWithStoreMock(t) + + user1 := &model.User{Id: "uid1", Username: "user-1"} + user2 := &model.User{Id: "uid2", Username: "user-2"} + profileMap := map[string]*model.User{user1.Id: user1, user2.Id: user2} + dmChannel := &model.Channel{Id: "dm-chid", TeamId: "", Type: model.ChannelTypeDirect, Name: model.GetDMNameFromIds(user1.Id, user2.Id)} + + post1 := &model.Post{Id: "pid1", ChannelId: dmChannel.Id, Message: "hello", UserId: user1.Id} + + mockStore := th.App.Srv().Store().(*storemocks.Store) + + mockChannel := storemocks.ChannelStore{} + mockStore.On("Channel").Return(&mockChannel) + mockChannel.On("GetChannelsByIds", mock.Anything, mock.Anything).Return([]*model.Channel{dmChannel}, nil) + + mockTeam := storemocks.TeamStore{} + mockStore.On("Team").Return(&mockTeam) + mockTeam.On("GetMany", mock.Anything).Return([]*model.Team{}, nil) + + mockUser := storemocks.UserStore{} + mockStore.On("User").Return(&mockUser) + mockUser.On("GetAllProfilesInChannel", mock.Anything, mock.Anything, mock.Anything).Return(profileMap, nil) + + mockGroup := storemocks.GroupStore{} + mockStore.On("Group").Return(&mockGroup) + mockGroup.On("GetGroups", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return([]*model.Group{}, nil) + + mockPostPersistentNotification := storemocks.PostPersistentNotificationStore{} + mockStore.On("PostPersistentNotification").Return(&mockPostPersistentNotification) + + th.App.Srv().SetLicense(getLicWithSkuShortName(model.LicenseShortSkuProfessional)) + cfg := th.App.Config() + *cfg.ServiceSettings.PostPriority = true + *cfg.ServiceSettings.AllowPersistentNotifications = true + + fnCalled := []string{} + err := th.App.forEachPersistentNotificationPost([]*model.Post{post1}, func(post *model.Post, _ *model.Channel, _ *model.Team, _ *MentionResults, _ model.UserMap, _ map[string]map[string]model.StringMap) error { + fnCalled = append(fnCalled, post.Id) + return nil + }) + require.NoError(t, err) + + // The callback should be called for the DM post even though there's no team + assert.Equal(t, []string{"pid1"}, fnCalled) + // Delete should NOT have been called — DMs don't need a team + mockPostPersistentNotification.AssertNotCalled(t, "Delete", mock.Anything) + }) + + t.Run("should not cleanup GM posts that have no team", func(t *testing.T) { + th := SetupWithStoreMock(t) + + user1 := &model.User{Id: "uid1", Username: "user-1"} + user2 := &model.User{Id: "uid2", Username: "user-2"} + user3 := &model.User{Id: "uid3", Username: "user-3"} + profileMap := map[string]*model.User{user1.Id: user1, user2.Id: user2, user3.Id: user3} + gmChannel := &model.Channel{Id: "gm-chid", TeamId: "", Type: model.ChannelTypeGroup} + + post1 := &model.Post{Id: "pid1", ChannelId: gmChannel.Id, Message: "hello @user-2", UserId: user1.Id} + + mockStore := th.App.Srv().Store().(*storemocks.Store) + + mockChannel := storemocks.ChannelStore{} + mockStore.On("Channel").Return(&mockChannel) + mockChannel.On("GetChannelsByIds", mock.Anything, mock.Anything).Return([]*model.Channel{gmChannel}, nil) + mockChannel.On("GetAllChannelMembersNotifyPropsForChannel", mock.Anything, mock.Anything).Return(map[string]model.StringMap{}, nil) + + mockTeam := storemocks.TeamStore{} + mockStore.On("Team").Return(&mockTeam) + mockTeam.On("GetMany", mock.Anything).Return([]*model.Team{}, nil) + + mockUser := storemocks.UserStore{} + mockStore.On("User").Return(&mockUser) + mockUser.On("GetAllProfilesInChannel", mock.Anything, mock.Anything, mock.Anything).Return(profileMap, nil) + + mockGroup := storemocks.GroupStore{} + mockStore.On("Group").Return(&mockGroup) + mockGroup.On("GetGroups", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return([]*model.Group{}, nil) + + mockPostPersistentNotification := storemocks.PostPersistentNotificationStore{} + mockStore.On("PostPersistentNotification").Return(&mockPostPersistentNotification) + + th.App.Srv().SetLicense(getLicWithSkuShortName(model.LicenseShortSkuProfessional)) + cfg := th.App.Config() + *cfg.ServiceSettings.PostPriority = true + *cfg.ServiceSettings.AllowPersistentNotifications = true + + fnCalled := []string{} + err := th.App.forEachPersistentNotificationPost([]*model.Post{post1}, func(post *model.Post, _ *model.Channel, _ *model.Team, _ *MentionResults, _ model.UserMap, _ map[string]map[string]model.StringMap) error { + fnCalled = append(fnCalled, post.Id) + return nil + }) + require.NoError(t, err) + + // The callback should be called for the GM post even though there's no team + assert.Equal(t, []string{"pid1"}, fnCalled) + // Delete should NOT have been called — GMs don't need a team + mockPostPersistentNotification.AssertNotCalled(t, "Delete", mock.Anything) + }) +} + func TestSendPersistentNotifications(t *testing.T) { mainHelper.Parallel(t) th := Setup(t).InitBasic(t)
c7aff1fcd9c3Update msgpack fork dependency (#35988) (#36042)
4 files changed · +56 −4
server/channels/app/platform/web_conn.go+6 −0 modified@@ -469,6 +469,12 @@ func (wc *WebConn) readPump() { return } + // Reject binary frames from unauthenticated connections. See MM-68222. + if msgType != websocket.TextMessage && !wc.IsAuthenticated() { + wc.logSocketErr("websocket.UnauthBinary", errors.New("binary frames require authentication")) + return + } + var decoder interface { Decode(v any) error }
server/channels/app/platform/web_conn_test.go+46 −0 modified@@ -10,6 +10,7 @@ import ( "net/http" "net/http/httptest" "testing" + "time" "github.com/gorilla/websocket" "github.com/stretchr/testify/assert" @@ -237,3 +238,48 @@ func TestWebConnDrainDeadQueue(t *testing.T) { t.Run("Overwritten First", func(t *testing.T) { run(int64(128), deadQueueSize+10) }) }) } + +func TestWebConnRejectBinaryFrameUnauthenticated(t *testing.T) { + th := Setup(t) + + readPumpDone := make(chan struct{}) + upgradeErrCh := make(chan error, 1) + + s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + upgrader := &websocket.Upgrader{} + conn, err := upgrader.Upgrade(w, r, nil) + if err != nil { + upgradeErrCh <- err + return + } + upgradeErrCh <- nil + + wc := th.Service.NewWebConn(&WebConnConfig{ + WebSocket: conn, + }, th.Suite, &hookRunner{}) + + require.False(t, wc.IsAuthenticated()) + + go func() { + wc.readPump() + close(readPumpDone) + }() + })) + defer s.Close() + + d := websocket.Dialer{} + clientConn, _, err := d.Dial("ws://"+s.Listener.Addr().String()+"/ws", nil) + require.NoError(t, err) + defer clientConn.Close() + + require.NoError(t, <-upgradeErrCh) + + err = clientConn.WriteMessage(websocket.BinaryMessage, []byte{0x01, 0x02, 0x03}) + require.NoError(t, err) + + select { + case <-readPumpDone: + case <-time.After(5 * time.Second): + require.Fail(t, "readPump did not exit after receiving binary frame") + } +}
server/go.mod+2 −2 modified@@ -230,5 +230,5 @@ require ( // https://github.com/advancedlogic/GoOse/pull/77 replace github.com/olekukonko/tablewriter => github.com/olekukonko/tablewriter v0.0.5 -// See MM-66167 for more details. -replace github.com/vmihailenco/msgpack/v5 => github.com/mattermost/msgpack/v5 v5.0.0-20260120151306-2f9c67d7e57f +// See MM-66167, MM-68222 for more details. +replace github.com/vmihailenco/msgpack/v5 => github.com/mattermost/msgpack/v5 v5.0.0-20260408165622-cadfad56a815
server/go.sum+2 −2 modified@@ -411,8 +411,8 @@ github.com/mattermost/mattermost/server/public v0.1.20 h1:N39ZOyYvCVZXABetpb0tVr github.com/mattermost/mattermost/server/public v0.1.20/go.mod h1:ZSdLCHIFYDTmX5FNCJ7LoI2CLZ1rQqZcfJ3ZF81QadY= github.com/mattermost/morph v1.1.0 h1:Q9vrJbeM3s2jfweGheq12EFIzdNp9a/6IovcbvOQ6Cw= github.com/mattermost/morph v1.1.0/go.mod h1:gD+EaqX2UMyyuzmF4PFh4r33XneQ8Nzi+0E8nXjMa3A= -github.com/mattermost/msgpack/v5 v5.0.0-20260120151306-2f9c67d7e57f h1:tAXeRJSWo6EK7wDq1TcxMHIxHRyjrE62ihvsigdg4Q0= -github.com/mattermost/msgpack/v5 v5.0.0-20260120151306-2f9c67d7e57f/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok= +github.com/mattermost/msgpack/v5 v5.0.0-20260408165622-cadfad56a815 h1:uOi89NvrFmDngqMKjlLDxi+MNzJQLA3TqcU2p8czv34= +github.com/mattermost/msgpack/v5 v5.0.0-20260408165622-cadfad56a815/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok= github.com/mattermost/rsc v0.0.0-20160330161541-bbaefb05eaa0 h1:G9tL6JXRBMzjuD1kkBtcnd42kUiT6QDwxfFYu7adM6o= github.com/mattermost/rsc v0.0.0-20160330161541-bbaefb05eaa0/go.mod h1:nV5bfVpT//+B1RPD2JvRnxbkLmJEYXmRaaVl15fsXjs= github.com/mattermost/squirrel v0.5.0 h1:81QPS0aA+inQbpA7Pzmv6O9sWwB6VaBh/VYw3oJf8ZY=
384635216f46Update msgpack fork dependency (#35988) (#36043)
4 files changed · +56 −4
server/channels/app/platform/web_conn.go+6 −0 modified@@ -469,6 +469,12 @@ func (wc *WebConn) readPump() { return } + // Reject binary frames from unauthenticated connections. See MM-68222. + if msgType != websocket.TextMessage && !wc.IsAuthenticated() { + wc.logSocketErr("websocket.UnauthBinary", errors.New("binary frames require authentication")) + return + } + var decoder interface { Decode(v any) error }
server/channels/app/platform/web_conn_test.go+46 −0 modified@@ -10,6 +10,7 @@ import ( "net/http" "net/http/httptest" "testing" + "time" "github.com/gorilla/websocket" "github.com/stretchr/testify/assert" @@ -241,3 +242,48 @@ func TestWebConnDrainDeadQueue(t *testing.T) { t.Run("Overwritten First", func(t *testing.T) { run(int64(128), deadQueueSize+10) }) }) } + +func TestWebConnRejectBinaryFrameUnauthenticated(t *testing.T) { + th := Setup(t) + + readPumpDone := make(chan struct{}) + upgradeErrCh := make(chan error, 1) + + s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + upgrader := &websocket.Upgrader{} + conn, err := upgrader.Upgrade(w, r, nil) + if err != nil { + upgradeErrCh <- err + return + } + upgradeErrCh <- nil + + wc := th.Service.NewWebConn(&WebConnConfig{ + WebSocket: conn, + }, th.Suite, &hookRunner{}) + + require.False(t, wc.IsAuthenticated()) + + go func() { + wc.readPump() + close(readPumpDone) + }() + })) + defer s.Close() + + d := websocket.Dialer{} + clientConn, _, err := d.Dial("ws://"+s.Listener.Addr().String()+"/ws", nil) + require.NoError(t, err) + defer clientConn.Close() + + require.NoError(t, <-upgradeErrCh) + + err = clientConn.WriteMessage(websocket.BinaryMessage, []byte{0x01, 0x02, 0x03}) + require.NoError(t, err) + + select { + case <-readPumpDone: + case <-time.After(5 * time.Second): + require.Fail(t, "readPump did not exit after receiving binary frame") + } +}
server/go.mod+2 −2 modified@@ -257,5 +257,5 @@ replace github.com/ledongthuc/pdf => github.com/ledongthuc/pdf v0.0.0-2024020113 // version always. Tablewriter has made breaking changes to its latest release. replace github.com/olekukonko/tablewriter => github.com/olekukonko/tablewriter v0.0.5 -// See MM-66167 for more details. -replace github.com/vmihailenco/msgpack/v5 => github.com/mattermost/msgpack/v5 v5.0.0-20260120151306-2f9c67d7e57f +// See MM-66167, MM-68222 for more details. +replace github.com/vmihailenco/msgpack/v5 => github.com/mattermost/msgpack/v5 v5.0.0-20260408165622-cadfad56a815
server/go.sum+2 −2 modified@@ -423,8 +423,8 @@ github.com/mattermost/mattermost/server/public v0.1.12 h1:qlIU/llY0FWdHWQPtvncdd github.com/mattermost/mattermost/server/public v0.1.12/go.mod h1:3RJZfl7sMedX6ihX+JMFOIAzCHhd0WQnuez+UFQS80k= github.com/mattermost/morph v1.1.0 h1:Q9vrJbeM3s2jfweGheq12EFIzdNp9a/6IovcbvOQ6Cw= github.com/mattermost/morph v1.1.0/go.mod h1:gD+EaqX2UMyyuzmF4PFh4r33XneQ8Nzi+0E8nXjMa3A= -github.com/mattermost/msgpack/v5 v5.0.0-20260120151306-2f9c67d7e57f h1:tAXeRJSWo6EK7wDq1TcxMHIxHRyjrE62ihvsigdg4Q0= -github.com/mattermost/msgpack/v5 v5.0.0-20260120151306-2f9c67d7e57f/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok= +github.com/mattermost/msgpack/v5 v5.0.0-20260408165622-cadfad56a815 h1:uOi89NvrFmDngqMKjlLDxi+MNzJQLA3TqcU2p8czv34= +github.com/mattermost/msgpack/v5 v5.0.0-20260408165622-cadfad56a815/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok= github.com/mattermost/rsc v0.0.0-20160330161541-bbaefb05eaa0 h1:G9tL6JXRBMzjuD1kkBtcnd42kUiT6QDwxfFYu7adM6o= github.com/mattermost/rsc v0.0.0-20160330161541-bbaefb05eaa0/go.mod h1:nV5bfVpT//+B1RPD2JvRnxbkLmJEYXmRaaVl15fsXjs= github.com/mattermost/squirrel v0.5.0 h1:81QPS0aA+inQbpA7Pzmv6O9sWwB6VaBh/VYw3oJf8ZY=
c4960b893bf0Update msgpack fork dependency (#35988) (#36040)
4 files changed · +56 −4
server/channels/app/platform/web_conn.go+6 −0 modified@@ -469,6 +469,12 @@ func (wc *WebConn) readPump() { return } + // Reject binary frames from unauthenticated connections. See MM-68222. + if msgType != websocket.TextMessage && !wc.IsAuthenticated() { + wc.logSocketErr("websocket.UnauthBinary", errors.New("binary frames require authentication")) + return + } + var decoder interface { Decode(v any) error }
server/channels/app/platform/web_conn_test.go+46 −0 modified@@ -10,6 +10,7 @@ import ( "net/http" "net/http/httptest" "testing" + "time" "github.com/gorilla/websocket" "github.com/stretchr/testify/assert" @@ -237,3 +238,48 @@ func TestWebConnDrainDeadQueue(t *testing.T) { t.Run("Overwritten First", func(t *testing.T) { run(int64(128), deadQueueSize+10) }) }) } + +func TestWebConnRejectBinaryFrameUnauthenticated(t *testing.T) { + th := Setup(t) + + readPumpDone := make(chan struct{}) + upgradeErrCh := make(chan error, 1) + + s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + upgrader := &websocket.Upgrader{} + conn, err := upgrader.Upgrade(w, r, nil) + if err != nil { + upgradeErrCh <- err + return + } + upgradeErrCh <- nil + + wc := th.Service.NewWebConn(&WebConnConfig{ + WebSocket: conn, + }, th.Suite, &hookRunner{}) + + require.False(t, wc.IsAuthenticated()) + + go func() { + wc.readPump() + close(readPumpDone) + }() + })) + defer s.Close() + + d := websocket.Dialer{} + clientConn, _, err := d.Dial("ws://"+s.Listener.Addr().String()+"/ws", nil) + require.NoError(t, err) + defer clientConn.Close() + + require.NoError(t, <-upgradeErrCh) + + err = clientConn.WriteMessage(websocket.BinaryMessage, []byte{0x01, 0x02, 0x03}) + require.NoError(t, err) + + select { + case <-readPumpDone: + case <-time.After(5 * time.Second): + require.Fail(t, "readPump did not exit after receiving binary frame") + } +}
server/go.mod+2 −2 modified@@ -231,5 +231,5 @@ require ( // https://github.com/advancedlogic/GoOse/pull/77 replace github.com/olekukonko/tablewriter => github.com/olekukonko/tablewriter v0.0.5 -// See MM-66167 for more details. -replace github.com/vmihailenco/msgpack/v5 => github.com/mattermost/msgpack/v5 v5.0.0-20260120151306-2f9c67d7e57f +// See MM-66167, MM-68222 for more details. +replace github.com/vmihailenco/msgpack/v5 => github.com/mattermost/msgpack/v5 v5.0.0-20260408165622-cadfad56a815
server/go.sum+2 −2 modified@@ -415,8 +415,8 @@ github.com/mattermost/mattermost/server/public v0.1.22-0.20251105210629-8bf4a007 github.com/mattermost/mattermost/server/public v0.1.22-0.20251105210629-8bf4a00724e2/go.mod h1:X0RG3lk0XK0SFSH67JS/xporlz3TxItHEPlFIrsQIa8= github.com/mattermost/morph v1.1.0 h1:Q9vrJbeM3s2jfweGheq12EFIzdNp9a/6IovcbvOQ6Cw= github.com/mattermost/morph v1.1.0/go.mod h1:gD+EaqX2UMyyuzmF4PFh4r33XneQ8Nzi+0E8nXjMa3A= -github.com/mattermost/msgpack/v5 v5.0.0-20260120151306-2f9c67d7e57f h1:tAXeRJSWo6EK7wDq1TcxMHIxHRyjrE62ihvsigdg4Q0= -github.com/mattermost/msgpack/v5 v5.0.0-20260120151306-2f9c67d7e57f/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok= +github.com/mattermost/msgpack/v5 v5.0.0-20260408165622-cadfad56a815 h1:uOi89NvrFmDngqMKjlLDxi+MNzJQLA3TqcU2p8czv34= +github.com/mattermost/msgpack/v5 v5.0.0-20260408165622-cadfad56a815/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok= github.com/mattermost/rsc v0.0.0-20160330161541-bbaefb05eaa0 h1:G9tL6JXRBMzjuD1kkBtcnd42kUiT6QDwxfFYu7adM6o= github.com/mattermost/rsc v0.0.0-20160330161541-bbaefb05eaa0/go.mod h1:nV5bfVpT//+B1RPD2JvRnxbkLmJEYXmRaaVl15fsXjs= github.com/mattermost/squirrel v0.5.0 h1:81QPS0aA+inQbpA7Pzmv6O9sWwB6VaBh/VYw3oJf8ZY=
a07e4b05ff82Added nil checks (#35755) (#36134)
3 files changed · +250 −7
server/channels/app/channel.go+6 −6 modified@@ -1580,6 +1580,12 @@ func (a *App) DeleteChannel(rctx request.CTX, channel *model.Channel, userID str return err } + deleteAt := model.GetMillis() + + if err := a.Srv().Store().Channel().Delete(channel.Id, deleteAt); err != nil { + return model.NewAppError("DeleteChannel", "app.channel.delete.app_error", nil, "", http.StatusInternalServerError).Wrap(err) + } + if user != nil { T := i18n.GetUserTranslations(user.Locale) @@ -1635,12 +1641,6 @@ func (a *App) DeleteChannel(rctx request.CTX, channel *model.Channel, userID str return model.NewAppError("DeleteChannel", "app.post_persistent_notification.delete_by_channel.app_error", nil, "", http.StatusInternalServerError).Wrap(err) } - deleteAt := model.GetMillis() - - if err := a.Srv().Store().Channel().Delete(channel.Id, deleteAt); err != nil { - return model.NewAppError("DeleteChannel", "app.channel.delete.app_error", nil, "", http.StatusInternalServerError).Wrap(err) - } - a.Srv().Platform().InvalidateCacheForChannel(channel) var message *model.WebSocketEvent
server/channels/app/post_persistent_notification.go+26 −1 modified@@ -163,13 +163,25 @@ func (a *App) forEachPersistentNotificationPost(posts []*model.Post, fn func(pos return err } + var postsForPersistentNotificationCleanup []*model.Post + for _, post := range posts { channel := channelsMap[post.ChannelId] + if channel == nil { + postsForPersistentNotificationCleanup = append(postsForPersistentNotificationCleanup, post) + continue + } + team := teamsMap[channel.TeamId] // GMs and DMs don't belong to any team if channel.IsGroupOrDirect() { team = &model.Team{} + } else if team == nil { + // cleanup persistent notification for posts with missing teams when they are not DM or GM + postsForPersistentNotificationCleanup = append(postsForPersistentNotificationCleanup, post) + continue } + profileMap := channelProfileMap[channel.Id] // Ensure the sender is always in the profile map: for example, system admins can post @@ -210,6 +222,14 @@ func (a *App) forEachPersistentNotificationPost(posts []*model.Post, fn func(pos } } + if len(postsForPersistentNotificationCleanup) > 0 { + for _, post := range postsForPersistentNotificationCleanup { + if appErr := a.DeletePersistentNotification(request.EmptyContext(a.Log()), post); appErr != nil { + a.Log().Warn("Failed to delete persistent notification for post", mlog.String("post_id", post.Id), mlog.String("channel_id", post.ChannelId), mlog.Err(appErr)) + } + } + } + return nil } @@ -219,9 +239,14 @@ func (a *App) persistentNotificationsAuxiliaryData(channelsMap map[string]*model channelKeywords := make(map[string]MentionKeywords, len(channelsMap)) channelNotifyProps := make(map[string]map[string]model.StringMap, len(channelsMap)) for _, c := range channelsMap { + team := teamsMap[c.TeamId] + if team == nil && !c.IsGroupOrDirect() { + continue + } + // In DM, notifications can't be send to any 3rd person. if c.Type != model.ChannelTypeDirect { - groups, err := a.getGroupsAllowedForReferenceInChannel(c, teamsMap[c.TeamId]) + groups, err := a.getGroupsAllowedForReferenceInChannel(c, team) if err != nil { return nil, nil, nil, nil, errors.Wrapf(err, "failed to get profiles for channel %s", c.Id) }
server/channels/app/post_persistent_notification_test.go+218 −0 modified@@ -192,6 +192,224 @@ func TestDeletePersistentNotification(t *testing.T) { }) } +func TestForEachPersistentNotificationPost(t *testing.T) { + mainHelper.Parallel(t) + + t.Run("should cleanup posts whose channel no longer exists", func(t *testing.T) { + th := SetupWithStoreMock(t) + + user1 := &model.User{Id: "uid1", Username: "user-1"} + profileMap := map[string]*model.User{user1.Id: user1} + team := &model.Team{Id: "tid"} + channel := &model.Channel{Id: "chid", TeamId: team.Id, Type: model.ChannelTypeOpen} + + // post1 belongs to an existing channel; post2 belongs to a deleted/missing channel + post1 := &model.Post{Id: "pid1", ChannelId: channel.Id, Message: "hello @user-1", UserId: user1.Id} + post2 := &model.Post{Id: "pid2", ChannelId: "deleted-channel-id", Message: "hello", UserId: user1.Id} + + mockStore := th.App.Srv().Store().(*storemocks.Store) + + mockChannel := storemocks.ChannelStore{} + mockStore.On("Channel").Return(&mockChannel) + // Only return channel for post1; post2's channel is missing + mockChannel.On("GetChannelsByIds", mock.Anything, mock.Anything).Return([]*model.Channel{channel}, nil) + mockChannel.On("GetAllChannelMembersNotifyPropsForChannel", mock.Anything, mock.Anything).Return(map[string]model.StringMap{}, nil) + + mockTeam := storemocks.TeamStore{} + mockStore.On("Team").Return(&mockTeam) + mockTeam.On("GetMany", mock.Anything).Return([]*model.Team{team}, nil) + + mockUser := storemocks.UserStore{} + mockStore.On("User").Return(&mockUser) + mockUser.On("GetAllProfilesInChannel", mock.Anything, mock.Anything, mock.Anything).Return(profileMap, nil) + + mockGroup := storemocks.GroupStore{} + mockStore.On("Group").Return(&mockGroup) + mockGroup.On("GetGroups", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return([]*model.Group{}, nil) + + // DeletePersistentNotification mocks - the cleanup path calls GetSingle then Delete + mockPostPersistentNotification := storemocks.PostPersistentNotificationStore{} + mockStore.On("PostPersistentNotification").Return(&mockPostPersistentNotification) + mockPostPersistentNotification.On("GetSingle", post2.Id).Return(&model.PostPersistentNotifications{PostId: post2.Id}, nil) + mockPostPersistentNotification.On("Delete", []string{post2.Id}).Return(nil) + + th.App.Srv().SetLicense(getLicWithSkuShortName(model.LicenseShortSkuProfessional)) + cfg := th.App.Config() + *cfg.ServiceSettings.PostPriority = true + *cfg.ServiceSettings.AllowPersistentNotifications = true + + fnCalled := []string{} + err := th.App.forEachPersistentNotificationPost([]*model.Post{post1, post2}, func(post *model.Post, _ *model.Channel, _ *model.Team, _ *MentionResults, _ model.UserMap, _ map[string]map[string]model.StringMap) error { + fnCalled = append(fnCalled, post.Id) + return nil + }) + require.NoError(t, err) + + // The callback should only be called for post1 (valid channel) + assert.Equal(t, []string{"pid1"}, fnCalled) + // post2 persistent notification should have been cleaned up + mockPostPersistentNotification.AssertCalled(t, "Delete", []string{post2.Id}) + }) + + t.Run("should cleanup posts whose team no longer exists", func(t *testing.T) { + th := SetupWithStoreMock(t) + + user1 := &model.User{Id: "uid1", Username: "user-1"} + user2 := &model.User{Id: "uid2", Username: "user-2"} + profileMap := map[string]*model.User{user1.Id: user1, user2.Id: user2} + team := &model.Team{Id: "tid"} + channel := &model.Channel{Id: "chid", TeamId: team.Id, Type: model.ChannelTypeOpen} + // channelWithMissingTeam has a TeamId that won't be in teamsMap + channelWithMissingTeam := &model.Channel{Id: "chid2", TeamId: "deleted-team-id", Type: model.ChannelTypeOpen} + + post1 := &model.Post{Id: "pid1", ChannelId: channel.Id, Message: "hello @user-1", UserId: user2.Id} + post2 := &model.Post{Id: "pid2", ChannelId: channelWithMissingTeam.Id, Message: "hello @user-1", UserId: user2.Id} + + mockStore := th.App.Srv().Store().(*storemocks.Store) + + mockChannel := storemocks.ChannelStore{} + mockStore.On("Channel").Return(&mockChannel) + // Both channels exist, but only one team exists + mockChannel.On("GetChannelsByIds", mock.Anything, mock.Anything).Return([]*model.Channel{channel, channelWithMissingTeam}, nil) + mockChannel.On("GetAllChannelMembersNotifyPropsForChannel", mock.Anything, mock.Anything).Return(map[string]model.StringMap{}, nil) + + mockTeam := storemocks.TeamStore{} + mockStore.On("Team").Return(&mockTeam) + // Only return the team for channel, not for channelWithMissingTeam + mockTeam.On("GetMany", mock.Anything).Return([]*model.Team{team}, nil) + + mockUser := storemocks.UserStore{} + mockStore.On("User").Return(&mockUser) + mockUser.On("GetAllProfilesInChannel", mock.Anything, mock.Anything, mock.Anything).Return(profileMap, nil) + + mockGroup := storemocks.GroupStore{} + mockStore.On("Group").Return(&mockGroup) + mockGroup.On("GetGroups", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return([]*model.Group{}, nil) + + mockPostPersistentNotification := storemocks.PostPersistentNotificationStore{} + mockStore.On("PostPersistentNotification").Return(&mockPostPersistentNotification) + mockPostPersistentNotification.On("GetSingle", post2.Id).Return(&model.PostPersistentNotifications{PostId: post2.Id}, nil) + mockPostPersistentNotification.On("Delete", []string{post2.Id}).Return(nil) + + th.App.Srv().SetLicense(getLicWithSkuShortName(model.LicenseShortSkuProfessional)) + cfg := th.App.Config() + *cfg.ServiceSettings.PostPriority = true + *cfg.ServiceSettings.AllowPersistentNotifications = true + + fnCalled := []string{} + err := th.App.forEachPersistentNotificationPost([]*model.Post{post1, post2}, func(post *model.Post, _ *model.Channel, _ *model.Team, _ *MentionResults, _ model.UserMap, _ map[string]map[string]model.StringMap) error { + fnCalled = append(fnCalled, post.Id) + return nil + }) + require.NoError(t, err) + + // The callback should only be called for post1 (valid team) + assert.Equal(t, []string{"pid1"}, fnCalled) + // post2 persistent notification should have been cleaned up due to missing team + mockPostPersistentNotification.AssertCalled(t, "Delete", []string{post2.Id}) + }) + + t.Run("should not cleanup DM posts that have no team", func(t *testing.T) { + th := SetupWithStoreMock(t) + + user1 := &model.User{Id: "uid1", Username: "user-1"} + user2 := &model.User{Id: "uid2", Username: "user-2"} + profileMap := map[string]*model.User{user1.Id: user1, user2.Id: user2} + dmChannel := &model.Channel{Id: "dm-chid", TeamId: "", Type: model.ChannelTypeDirect, Name: model.GetDMNameFromIds(user1.Id, user2.Id)} + + post1 := &model.Post{Id: "pid1", ChannelId: dmChannel.Id, Message: "hello", UserId: user1.Id} + + mockStore := th.App.Srv().Store().(*storemocks.Store) + + mockChannel := storemocks.ChannelStore{} + mockStore.On("Channel").Return(&mockChannel) + mockChannel.On("GetChannelsByIds", mock.Anything, mock.Anything).Return([]*model.Channel{dmChannel}, nil) + + mockTeam := storemocks.TeamStore{} + mockStore.On("Team").Return(&mockTeam) + mockTeam.On("GetMany", mock.Anything).Return([]*model.Team{}, nil) + + mockUser := storemocks.UserStore{} + mockStore.On("User").Return(&mockUser) + mockUser.On("GetAllProfilesInChannel", mock.Anything, mock.Anything, mock.Anything).Return(profileMap, nil) + + mockGroup := storemocks.GroupStore{} + mockStore.On("Group").Return(&mockGroup) + mockGroup.On("GetGroups", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return([]*model.Group{}, nil) + + mockPostPersistentNotification := storemocks.PostPersistentNotificationStore{} + mockStore.On("PostPersistentNotification").Return(&mockPostPersistentNotification) + + th.App.Srv().SetLicense(getLicWithSkuShortName(model.LicenseShortSkuProfessional)) + cfg := th.App.Config() + *cfg.ServiceSettings.PostPriority = true + *cfg.ServiceSettings.AllowPersistentNotifications = true + + fnCalled := []string{} + err := th.App.forEachPersistentNotificationPost([]*model.Post{post1}, func(post *model.Post, _ *model.Channel, _ *model.Team, _ *MentionResults, _ model.UserMap, _ map[string]map[string]model.StringMap) error { + fnCalled = append(fnCalled, post.Id) + return nil + }) + require.NoError(t, err) + + // The callback should be called for the DM post even though there's no team + assert.Equal(t, []string{"pid1"}, fnCalled) + // Delete should NOT have been called — DMs don't need a team + mockPostPersistentNotification.AssertNotCalled(t, "Delete", mock.Anything) + }) + + t.Run("should not cleanup GM posts that have no team", func(t *testing.T) { + th := SetupWithStoreMock(t) + + user1 := &model.User{Id: "uid1", Username: "user-1"} + user2 := &model.User{Id: "uid2", Username: "user-2"} + user3 := &model.User{Id: "uid3", Username: "user-3"} + profileMap := map[string]*model.User{user1.Id: user1, user2.Id: user2, user3.Id: user3} + gmChannel := &model.Channel{Id: "gm-chid", TeamId: "", Type: model.ChannelTypeGroup} + + post1 := &model.Post{Id: "pid1", ChannelId: gmChannel.Id, Message: "hello @user-2", UserId: user1.Id} + + mockStore := th.App.Srv().Store().(*storemocks.Store) + + mockChannel := storemocks.ChannelStore{} + mockStore.On("Channel").Return(&mockChannel) + mockChannel.On("GetChannelsByIds", mock.Anything, mock.Anything).Return([]*model.Channel{gmChannel}, nil) + mockChannel.On("GetAllChannelMembersNotifyPropsForChannel", mock.Anything, mock.Anything).Return(map[string]model.StringMap{}, nil) + + mockTeam := storemocks.TeamStore{} + mockStore.On("Team").Return(&mockTeam) + mockTeam.On("GetMany", mock.Anything).Return([]*model.Team{}, nil) + + mockUser := storemocks.UserStore{} + mockStore.On("User").Return(&mockUser) + mockUser.On("GetAllProfilesInChannel", mock.Anything, mock.Anything, mock.Anything).Return(profileMap, nil) + + mockGroup := storemocks.GroupStore{} + mockStore.On("Group").Return(&mockGroup) + mockGroup.On("GetGroups", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return([]*model.Group{}, nil) + + mockPostPersistentNotification := storemocks.PostPersistentNotificationStore{} + mockStore.On("PostPersistentNotification").Return(&mockPostPersistentNotification) + + th.App.Srv().SetLicense(getLicWithSkuShortName(model.LicenseShortSkuProfessional)) + cfg := th.App.Config() + *cfg.ServiceSettings.PostPriority = true + *cfg.ServiceSettings.AllowPersistentNotifications = true + + fnCalled := []string{} + err := th.App.forEachPersistentNotificationPost([]*model.Post{post1}, func(post *model.Post, _ *model.Channel, _ *model.Team, _ *MentionResults, _ model.UserMap, _ map[string]map[string]model.StringMap) error { + fnCalled = append(fnCalled, post.Id) + return nil + }) + require.NoError(t, err) + + // The callback should be called for the GM post even though there's no team + assert.Equal(t, []string{"pid1"}, fnCalled) + // Delete should NOT have been called — GMs don't need a team + mockPostPersistentNotification.AssertNotCalled(t, "Delete", mock.Anything) + }) +} + func TestSendPersistentNotifications(t *testing.T) { mainHelper.Parallel(t) th := Setup(t).InitBasic(t)
a5fb12645247ffee10a61081Bump Boards FIPS version to v9.2.4 (#36165) (#36168)
1 file changed · +1 −1
server/Makefile+1 −1 modified@@ -183,7 +183,7 @@ PLUGIN_PACKAGES += mattermost-plugin-channel-export-v1.3.0 ifeq ($(FIPS_ENABLED),true) PLUGIN_PACKAGES = mattermost-plugin-playbooks-v2.8.0%2Bc4449ac-fips PLUGIN_PACKAGES += mattermost-plugin-agents-v1.7.2%2B866e2dd-fips - PLUGIN_PACKAGES += mattermost-plugin-boards-v9.2.2%2B4282c63-fips + PLUGIN_PACKAGES += mattermost-plugin-boards-v9.2.4%2B5855fe1-fips endif EE_PACKAGES=$(shell $(GO) list $(BUILD_ENTERPRISE_DIR)/...)
fff6ab3a5851Bump Boards FIPS version to v9.2.4 (#36165) (#36169)
1 file changed · +1 −1
server/Makefile+1 −1 modified@@ -176,7 +176,7 @@ PLUGIN_PACKAGES += mattermost-plugin-channel-export-v1.3.0 ifeq ($(FIPS_ENABLED),true) PLUGIN_PACKAGES = mattermost-plugin-playbooks-v2.8.0%2Bc4449ac-fips PLUGIN_PACKAGES += mattermost-plugin-agents-v1.7.2%2B866e2dd-fips - PLUGIN_PACKAGES += mattermost-plugin-boards-v9.2.2%2B4282c63-fips + PLUGIN_PACKAGES += mattermost-plugin-boards-v9.2.4%2B5855fe1-fips endif EE_PACKAGES=$(shell $(GO) list $(BUILD_ENTERPRISE_DIR)/...)
Vulnerability mechanics
Root cause
"Missing validation of msgpack-encoded binary WebSocket frames before memory allocation allows an unauthenticated attacker to trigger excessive memory consumption or a nil-pointer dereference, crashing the server."
Attack vector
An unauthenticated remote attacker opens a WebSocket connection to the public WebSocket endpoint and sends a crafted binary message (msgpack-encoded). The server's `readPump()` function in `web_conn.go` previously did not check whether the connection was authenticated before decoding binary frames. Because the msgpack decoder could be fed malformed data without size limits, the server would allocate memory based on attacker-controlled values, leading to a crash and full service outage. No authentication or prior knowledge is required beyond network access to the WebSocket endpoint.
Affected code
The primary vulnerable code is in `server/channels/app/platform/web_conn.go` in the `readPump()` method, where binary WebSocket frames were decoded without checking authentication status. The msgpack decoder dependency (`github.com/vmihailenco/msgpack/v5`, replaced by a Mattermost fork) lacked input validation that could lead to uncontrolled memory allocation. Additional nil-pointer issues exist in `server/channels/app/post_persistent_notification.go` (`forEachPersistentNotificationPost` and `persistentNotificationsAuxiliaryData`) and `server/channels/app/channel.go` (`DeleteChannel`).
What the fix does
The fix adds a guard in `readPump()` (`web_conn.go`) that rejects binary frames from unauthenticated connections: if `msgType != websocket.TextMessage && !wc.IsAuthenticated()`, the connection is closed immediately with a log message [patch_id=1693052][patch_id=1693056][patch_id=1693393]. Additionally, the msgpack fork dependency was updated to a newer version (`v5.0.0-20260408165622-cadfad56a815`) that includes hardening against malformed input [patch_id=1693052][patch_id=1693056][patch_id=1693393]. The nil-check patches in `post_persistent_notification.go` and `channel.go` address a separate but related issue where missing nil checks on channel and team lookups could cause panics [patch_id=1693392][patch_id=1693051][patch_id=1693053].
Preconditions
- networkNetwork access to the Mattermost public WebSocket endpoint (typically /ws)
- authNo authentication required — the attack is performed on an unauthenticated WebSocket connection
- inputThe attacker sends a crafted binary (msgpack-encoded) WebSocket frame
Generated on May 23, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
1- mattermost.com/security-updatesmitrevendor-advisory
News mentions
0No linked articles in our index yet.