Persistent notification timing attack causing server denial of service
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 archive the channel before removing persistent notifications which allows authenticated user to crash the server via timing the creation of persistent notification message between the server deleting existing persistent notifications and archiving the channel.. Mattermost Advisory ID: MMSA-2026-00637
Affected products
1- Range: <=11.6.0, <=11.5.3, <=11.4.4, <=10.11.14
Patches
63b21498788a7Added 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)
0840149b3708f6760151c4a7Support Elasticsearch v9 (for v10.11) (#35925)
8 files changed · +182 −11
.github/workflows/server-ci-template.yml+24 −0 modified@@ -264,6 +264,30 @@ jobs: datasource: mmuser:mostest@tcp(mysql:3306)/mattermost_test?charset=utf8mb4&multiStatements=true&maxAllowedPacket=4194304 drivername: mysql logsartifact: mysql-server-test-logs + test-elasticsearch-v8: + name: Elasticsearch v8 Compatibility + needs: check-mattermost-vet + uses: ./.github/workflows/server-test-template.yml + secrets: inherit + with: + name: Elasticsearch v8 Compatibility + datasource: postgres://mmuser:mostest@postgres:5432/mattermost_test?sslmode=disable&connect_timeout=10 + drivername: postgres + logsartifact: elasticsearch-v8-server-test-logs + elasticsearch-version: "8.9.0" + test-target: "test-server-elasticsearch" + test-elasticsearch-v7: + name: Elasticsearch v7 Compatibility + needs: check-mattermost-vet + uses: ./.github/workflows/server-test-template.yml + secrets: inherit + with: + name: Elasticsearch v7 Compatibility + datasource: postgres://mmuser:mostest@postgres:5432/mattermost_test?sslmode=disable&connect_timeout=10 + drivername: postgres + logsartifact: elasticsearch-v7-server-test-logs + elasticsearch-version: "7.17.29" + test-target: "test-server-elasticsearch" test-coverage: # Skip coverage generation for cherry-pick PRs into release branches. if: ${{ github.event_name != 'pull_request' || !startsWith(github.event.pull_request.base.ref, 'release-') }}
.github/workflows/server-test-template.yml+15 −2 modified@@ -22,6 +22,14 @@ on: required: false type: boolean default: false + elasticsearch-version: + required: false + type: string + default: "9.0.0" + test-target: + required: false + type: string + default: "test-server" # -- Test sharding inputs (leave defaults for non-sharded callers) -- shard-index: required: false @@ -75,6 +83,8 @@ jobs: echo "${{ inputs.name }}" > server/test-name echo "${{ github.event.pull_request.number }}" > server/pr-number - name: Run docker compose + env: + ELASTICSEARCH_VERSION: ${{ inputs.elasticsearch-version }} run: | cd server/build docker compose --ansi never run --rm start_dependencies @@ -143,11 +153,11 @@ jobs: env: BUILD_IMAGE: mattermost/mattermost-build-server:${{ steps.go.outputs.GO_VERSION }} run: | - if [[ ${{ github.ref_name }} == 'master' && ${{ inputs.fullyparallel }} != true ]]; then + if [[ ${{ github.ref_name }} == 'master' && ${{ inputs.fullyparallel }} != true && "${{ inputs.test-target }}" == "test-server" ]]; then export RACE_MODE="-race" fi - TEST_TARGET="test-server${RACE_MODE}" + TEST_TARGET="${{ inputs.test-target }}${RACE_MODE}" BUILD_NUMBER="${GITHUB_HEAD_REF}-${GITHUB_RUN_ID}" DOCKER_CMD="make ${TEST_TARGET}" @@ -186,8 +196,10 @@ jobs: disable_search: true files: server/cover.out - name: Stop docker compose + if: ${{ always() }} run: | cd server/build + docker compose --ansi never logs --no-color > ../../docker-compose.log 2>&1 docker compose --ansi never stop - name: Archive logs if: ${{ always() }} @@ -200,4 +212,5 @@ jobs: server/cover.out server/test-name server/pr-number + docker-compose.log
server/build/docker-compose.common.yml+5 −1 modified@@ -87,7 +87,11 @@ services: LDAP_DOMAIN: "mm.test.com" LDAP_ADMIN_PASSWORD: "mostest" elasticsearch: - image: "mattermostdevelopment/mattermost-elasticsearch:8.9.0" + build: + context: . + dockerfile: ./Dockerfile.elasticsearch + args: + ELASTICSEARCH_VERSION: ${ELASTICSEARCH_VERSION:-9.0.0} networks: - mm-test environment:
server/build/Dockerfile.elasticsearch+4 −0 added@@ -0,0 +1,4 @@ +ARG ELASTICSEARCH_VERSION=9.0.0 +FROM docker.elastic.co/elasticsearch/elasticsearch:${ELASTICSEARCH_VERSION} + +RUN /usr/share/elasticsearch/bin/elasticsearch-plugin install --batch analysis-icu analysis-nori analysis-kuromoji analysis-smartcn
server/enterprise/elasticsearch/elasticsearch/check_version_test.go+109 −0 added@@ -0,0 +1,109 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.enterprise for license information. + +package elasticsearch + +import ( + "fmt" + "net/http" + "net/http/httptest" + "testing" + + elastic "github.com/elastic/go-elasticsearch/v8" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func newTestClient(t *testing.T, handler http.Handler) *elastic.TypedClient { + t.Helper() + ts := httptest.NewServer(handler) + t.Cleanup(ts.Close) + + client, err := elastic.NewTypedClient(elastic.Config{ + Addresses: []string{ts.URL}, + }) + require.NoError(t, err) + return client +} + +func infoHandler(version string) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Header().Set("X-Elastic-Product", "Elasticsearch") + fmt.Fprintf(w, `{"cluster_name":"test","version":{"number":%q,"build_flavor":"default","build_hash":"abc","build_date":"2024-01-01","build_snapshot":false,"build_type":"docker","lucene_version":"9.0.0","minimum_wire_compatibility_version":"7.0.0","minimum_index_compatibility_version":"7.0.0"}}`, version) + } +} + +func TestCheckVersion(t *testing.T) { + tests := []struct { + name string + version string + wantVersion string + wantMajor int + wantErrID string + }{ + { + name: "ES 8 is supported", + version: "8.9.0", + wantVersion: "8.9.0", + wantMajor: 8, + }, + { + name: "ES 9 is supported", + version: "9.0.0", + wantVersion: "9.0.0", + wantMajor: 9, + }, + { + name: "ES 7 is supported", + version: "7.17.0", + wantVersion: "7.17.0", + wantMajor: 7, + }, + { + name: "ES 6 is too old", + version: "6.8.0", + wantErrID: "ent.elasticsearch.min_version.app_error", + }, + { + name: "ES 10 is too new", + version: "10.0.0", + wantErrID: "ent.elasticsearch.max_version.app_error", + }, + { + name: "invalid version string", + version: "invalid", + wantErrID: "ent.elasticsearch.start.parse_server_version.app_error", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + client := newTestClient(t, infoHandler(tc.version)) + version, major, appErr := checkVersion(client, nil) + if tc.wantErrID != "" { + require.NotNil(t, appErr) + assert.Equal(t, tc.wantErrID, appErr.Id) + } else { + require.Nil(t, appErr) + assert.Equal(t, tc.wantVersion, version) + assert.Equal(t, tc.wantMajor, major) + } + }) + } +} + +func TestCheckVersionConnectionError(t *testing.T) { + ts := httptest.NewServer(http.NotFoundHandler()) + ts.Close() // close immediately to force connection error + + client, err := elastic.NewTypedClient(elastic.Config{ + Addresses: []string{ts.URL}, + MaxRetries: 0, + }) + require.NoError(t, err) + + _, _, appErr := checkVersion(client, nil) + require.NotNil(t, appErr) + assert.Equal(t, "ent.elasticsearch.start.get_server_version.app_error", appErr.Id) +}
server/enterprise/elasticsearch/elasticsearch/elasticsearch.go+11 −7 modified@@ -28,7 +28,8 @@ import ( "github.com/elastic/go-elasticsearch/v8/typedapi/types/enums/sortorder" ) -const elasticsearchMaxVersion = 8 +const elasticsearchMinVersion = 7 +const elasticsearchMaxVersion = 9 var ( purgeIndexListAllowedIndexes = []string{common.IndexBaseChannels} @@ -106,7 +107,7 @@ func (es *ElasticsearchInterfaceImpl) Start() *model.AppError { return appErr } - version, major, appErr := checkMaxVersion(es.client, es.Platform.Config()) + version, major, appErr := checkVersion(es.client, es.Platform.Config()) if appErr != nil { return appErr } @@ -1245,7 +1246,7 @@ func (es *ElasticsearchInterfaceImpl) TestConfig(rctx request.CTX, cfg *model.Co return appErr } - _, _, appErr = checkMaxVersion(client, cfg) + _, _, appErr = checkVersion(client, cfg) if appErr != nil { return appErr } @@ -1830,19 +1831,22 @@ func (es *ElasticsearchInterfaceImpl) DeleteFilesBatch(rctx request.CTX, endTime return nil } -func checkMaxVersion(client *elastic.TypedClient, cfg *model.Config) (string, int, *model.AppError) { +func checkVersion(client *elastic.TypedClient, cfg *model.Config) (string, int, *model.AppError) { resp, err := client.API.Core.Info().Do(context.Background()) if err != nil { - return "", 0, model.NewAppError("Elasticsearch.checkMaxVersion", "ent.elasticsearch.start.get_server_version.app_error", map[string]any{"Backend": model.ElasticsearchSettingsESBackend}, "", http.StatusInternalServerError).Wrap(err) + return "", 0, model.NewAppError("Elasticsearch.checkVersion", "ent.elasticsearch.start.get_server_version.app_error", map[string]any{"Backend": model.ElasticsearchSettingsESBackend}, "", http.StatusInternalServerError).Wrap(err) } major, _, _, esErr := common.GetVersionComponents(resp.Version.Int) if esErr != nil { - return "", 0, model.NewAppError("Elasticsearch.checkMaxVersion", "ent.elasticsearch.start.parse_server_version.app_error", map[string]any{"Backend": model.ElasticsearchSettingsESBackend}, "", http.StatusInternalServerError).Wrap(err) + return "", 0, model.NewAppError("Elasticsearch.checkVersion", "ent.elasticsearch.start.parse_server_version.app_error", map[string]any{"Backend": model.ElasticsearchSettingsESBackend}, "", http.StatusInternalServerError).Wrap(esErr) } + if major < elasticsearchMinVersion { + return "", 0, model.NewAppError("Elasticsearch.checkVersion", "ent.elasticsearch.min_version.app_error", map[string]any{"Version": major, "MinVersion": elasticsearchMinVersion, "Backend": model.ElasticsearchSettingsESBackend}, "", http.StatusBadRequest) + } if major > elasticsearchMaxVersion { - return "", 0, model.NewAppError("Elasticsearch.checkMaxVersion", "ent.elasticsearch.max_version.app_error", map[string]any{"Version": major, "MaxVersion": elasticsearchMaxVersion, "Backend": model.ElasticsearchSettingsESBackend}, "", http.StatusBadRequest) + return "", 0, model.NewAppError("Elasticsearch.checkVersion", "ent.elasticsearch.max_version.app_error", map[string]any{"Version": major, "MaxVersion": elasticsearchMaxVersion, "Backend": model.ElasticsearchSettingsESBackend}, "", http.StatusBadRequest) } return resp.Version.Int, major, nil }
server/i18n/en.json+4 −0 modified@@ -8228,6 +8228,10 @@ "id": "ent.elasticsearch.max_version.app_error", "translation": "{{.Backend}} version {{.Version}} is higher than max supported version of {{.MaxVersion}}" }, + { + "id": "ent.elasticsearch.min_version.app_error", + "translation": "{{.Backend}} version {{.Version}} is lower than min supported version of {{.MinVersion}}" + }, { "id": "ent.elasticsearch.not_started.error", "translation": "{{.Backend}} is not started"
server/Makefile+10 −1 modified@@ -1,4 +1,4 @@ -.PHONY: build package run stop run-client run-server run-node run-haserver stop-haserver stop-client stop-server restart restart-server restart-client restart-haserver start-docker update-docker clean-dist clean nuke check-style check-client-style check-server-style check-unit-tests test dist run-client-tests setup-run-client-tests cleanup-run-client-tests test-client build-linux build-osx build-windows package-prep package-linux package-osx package-windows internal-test-web-client vet run-server-for-web-client-tests diff-config prepackaged-plugins prepackaged-binaries test-server test-server-ee test-server-quick test-server-race test-mmctl-unit test-mmctl-e2e test-mmctl test-mmctl-coverage mmctl-build mmctl-docs new-migration migrations-extract test-public mocks-public +.PHONY: build package run stop run-client run-server run-node run-haserver stop-haserver stop-client stop-server restart restart-server restart-client restart-haserver start-docker update-docker clean-dist clean nuke check-style check-client-style check-server-style check-unit-tests test dist run-client-tests setup-run-client-tests cleanup-run-client-tests test-client build-linux build-osx build-windows package-prep package-linux package-osx package-windows internal-test-web-client vet run-server-for-web-client-tests diff-config prepackaged-plugins prepackaged-binaries test-server test-server-ee test-server-elasticsearch test-server-quick test-server-race test-mmctl-unit test-mmctl-e2e test-mmctl test-mmctl-coverage mmctl-build mmctl-docs new-migration migrations-extract test-public mocks-public ROOT := $(dir $(abspath $(lastword $(MAKEFILE_LIST)))) @@ -476,6 +476,15 @@ test-server-ee: check-prereqs-enterprise start-docker gotestsum ## Runs EE tests @echo Running only EE tests $(GOBIN)/gotestsum --packages="$(EE_PACKAGES)" -- $(GOFLAGS) -timeout=20m +ES_PACKAGES=$(shell $(GO) list ./enterprise/elasticsearch/...) + +test-server-elasticsearch: export GOTESTSUM_FORMAT := $(GOTESTSUM_FORMAT) +test-server-elasticsearch: export GOTESTSUM_JUNITFILE := $(GOTESTSUM_JUNITFILE) +test-server-elasticsearch: export GOTESTSUM_JSONFILE := $(GOTESTSUM_JSONFILE) +test-server-elasticsearch: check-prereqs-enterprise start-docker gotestsum ## Runs Elasticsearch tests. + @echo Running only Elasticsearch tests + $(GOBIN)/gotestsum --rerun-fails=3 --packages="$(ES_PACKAGES)" -- $(GOFLAGS) -timeout=20m + test-server-quick: export GOTESTSUM_FORMAT := $(GOTESTSUM_FORMAT) test-server-quick: export GOTESTSUM_JUNITFILE := $(GOTESTSUM_JUNITFILE) test-server-quick: export GOTESTSUM_JSONFILE := $(GOTESTSUM_JSONFILE)
ffee10a61081Bump 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)/...)
292d4b7ea15bBump Boards FIPS version to v9.2.4 (#36165) (#36170)
1 file changed · +1 −1
server/Makefile+1 −1 modified@@ -174,7 +174,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
"Race condition: the channel is archived after persistent notifications are deleted, allowing a new persistent notification to be created referencing a channel that is about to be deleted, leading to a nil pointer dereference when the server later iterates over notifications."
Attack vector
An authenticated user who can post persistent notifications in a channel can trigger a server crash by racing the channel archive operation. The attacker creates a persistent notification message at the precise moment between the server deleting existing persistent notifications for the channel and the server archiving (deleting) the channel itself. Because the channel is not yet archived when the notification is created, the notification record references a channel that is about to be deleted. When the server later iterates over persistent notifications (e.g., during a background job), it encounters a nil channel pointer and panics, crashing the server. No special network position is required beyond normal API access.
Affected code
The vulnerability is in the channel deletion flow in `server/channels/app/channel.go` (the `DeleteChannel` function) and the persistent notification iteration logic in `server/channels/app/post_persistent_notification.go` (the `forEachPersistentNotificationPost` function). The patch moves the `Channel().Delete()` call to occur **before** the call that deletes persistent notifications by channel, and adds nil checks for missing channels and teams in `forEachPersistentNotificationPost` to safely skip or clean up orphaned notification records.
What the fix does
The patch in `server/channels/app/channel.go` reorders the `Channel().Delete()` call to happen **before** the call that deletes persistent notifications by channel (`DeletePersistentNotificationsForChannel`). This ensures the channel is archived first, so any subsequent persistent notification creation attempt will fail because the channel no longer exists. Additionally, the patch in `server/channels/app/post_persistent_notification.go` adds nil checks for `channel` and `team` pointers in `forEachPersistentNotificationPost`; when a channel or team is missing (nil), the code now skips the callback and cleans up the orphaned persistent notification record instead of dereferencing a nil pointer and crashing. The new unit tests in `post_persistent_notification_test.go` verify these nil-safety behaviors for missing channels, missing teams, and DM/GM channels that naturally have no team.
Preconditions
- authThe attacker must be an authenticated user with permission to post persistent notifications in the target channel.
- configThe server must have persistent notifications enabled (PostPriority and AllowPersistentNotifications config settings).
- inputThe attacker must be able to time the creation of a persistent notification message to occur between the deletion of existing persistent notifications and the archiving of the channel.
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.