Moderate severityNVD Advisory· Published Mar 16, 2026· Updated Mar 16, 2026
Guest user can upload files without permission across teams
CVE-2026-4265
Description
Mattermost versions 11.3.x <= 11.3.0, 11.2.x <= 11.2.2, 10.11.x <= 10.11.10 fail to validate team-specific upload_file permissions which allows a guest user to post files in channels where they lack upload_file permission via uploading files in a team where they have permission and reusing the file metadata in a POST request to a different team. Mattermost Advisory ID: MMSA-2025-00553
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
github.com/mattermost/mattermost/server/v8Go | < 8.0.0-20260107144005-c7f6efdfb035 | 8.0.0-20260107144005-c7f6efdfb035 |
github.com/mattermost/mattermost-serverGo | < 5.3.2-0.20260107144005-c7f6efdfb035 | 5.3.2-0.20260107144005-c7f6efdfb035 |
github.com/mattermost/mattermost-serverGo | >= 10.11.0-rc1, < 10.11.11 | 10.11.11 |
github.com/mattermost/mattermost-serverGo | >= 11.2.0-rc1, < 11.2.3 | 11.2.3 |
github.com/mattermost/mattermost-serverGo | >= 11.3.0-rc1, < 11.3.1 | 11.3.1 |
Affected products
1- Range: 11.3.0
Patches
1c7f6efdfb035Guest cannot add file to post without upload_file permission (#34538)
9 files changed · +286 −94
server/channels/api4/post.go+26 −0 modified@@ -68,6 +68,13 @@ func createPostChecks(where string, c *Context, post *model.Post) { return } + if len(post.FileIds) > 0 { + if !c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), post.ChannelId, model.PermissionUploadFile) { + c.SetPermissionError(model.PermissionUploadFile) + return + } + } + postHardenedModeCheckWithContext(where, c, post.GetProps()) if c.Err != nil { return @@ -923,6 +930,12 @@ func updatePost(c *Context, w http.ResponseWriter, r *http.Request) { post.FileIds = originalPost.FileIds } + // Check upload_file permission only if update is adding NEW files (not just keeping existing ones) + checkUploadFilePermissionForNewFiles(c, post.FileIds, originalPost) + if c.Err != nil { + return + } + if c.AppContext.Session().UserId != originalPost.UserId { if !c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), originalPost.ChannelId, model.PermissionEditOthersPosts) { c.SetPermissionError(model.PermissionEditOthersPosts) @@ -980,6 +993,19 @@ func patchPost(c *Context, w http.ResponseWriter, r *http.Request) { return } + originalPost, err := c.App.GetSinglePost(c.AppContext, c.Params.PostId, false) + if err != nil { + c.SetPermissionError(model.PermissionEditPost) + return + } + + if post.FileIds != nil { + checkUploadFilePermissionForNewFiles(c, *post.FileIds, originalPost) + if c.Err != nil { + return + } + } + patchedPost, err := c.App.PatchPost(c.AppContext, c.Params.PostId, c.App.PostPatchWithProxyRemovedFromImageURLs(&post), nil) if err != nil { c.Err = err
server/channels/api4/post_test.go+96 −0 modified@@ -285,6 +285,46 @@ func TestCreatePost(t *testing.T) { assert.Nil(t, rpost) }) + t.Run("should prevent creating post with files when user lacks upload_file permission in target channel", func(t *testing.T) { + fileResp, resp, err := client.UploadFile(context.Background(), []byte("test file data"), th.BasicChannel.Id, "test-file.txt") + require.NoError(t, err) + CheckCreatedStatus(t, resp) + fileId := fileResp.FileInfos[0].Id + + th.RemovePermissionFromRole(t, model.PermissionUploadFile.Id, model.ChannelUserRoleId) + defer func() { + th.AddPermissionToRole(t, model.PermissionUploadFile.Id, model.ChannelUserRoleId) + }() + + post := &model.Post{ + ChannelId: th.BasicChannel.Id, + Message: "Test post with file", + FileIds: model.StringArray{fileId}, + } + rpost, resp, err := client.CreatePost(context.Background(), post) + require.Error(t, err) + CheckForbiddenStatus(t, resp) + assert.Nil(t, rpost) + }) + + t.Run("should allow creating post with files when user has upload_file permission", func(t *testing.T) { + fileResp, resp, err := client.UploadFile(context.Background(), []byte("test file data"), th.BasicChannel.Id, "test-file.txt") + require.NoError(t, err) + CheckCreatedStatus(t, resp) + fileId := fileResp.FileInfos[0].Id + + post := &model.Post{ + ChannelId: th.BasicChannel.Id, + Message: "Test post with file", + FileIds: model.StringArray{fileId}, + } + rpost, resp, err := client.CreatePost(context.Background(), post) + require.NoError(t, err) + CheckCreatedStatus(t, resp) + require.NotNil(t, rpost) + assert.Contains(t, rpost.FileIds, fileId) + }) + t.Run("CreateAt should match the one provided in the request", func(t *testing.T) { post := basicPost() post.CreateAt = 123 @@ -1545,6 +1585,62 @@ func TestUpdatePost(t *testing.T) { CheckBadRequestStatus(t, resp) }) + t.Run("should prevent updating post with files when user lacks upload_file permission in target channel", func(t *testing.T) { + postWithoutFiles, appErr := th.App.CreatePost(th.Context, &model.Post{ + UserId: th.BasicUser.Id, + ChannelId: channel.Id, + Message: "Post without files", + }, channel, model.CreatePostFlags{SetOnline: true}) + require.Nil(t, appErr) + + fileResp, resp, err := client.UploadFile(context.Background(), []byte("test file data"), channel.Id, "test-file.txt") + require.NoError(t, err) + CheckCreatedStatus(t, resp) + fileId := fileResp.FileInfos[0].Id + + th.RemovePermissionFromRole(t, model.PermissionUploadFile.Id, model.ChannelUserRoleId) + defer func() { + th.AddPermissionToRole(t, model.PermissionUploadFile.Id, model.ChannelUserRoleId) + }() + + updatePost := &model.Post{ + Id: postWithoutFiles.Id, + ChannelId: channel.Id, + Message: "Updated post with file", + FileIds: model.StringArray{fileId}, + } + updatedPost, resp, err := client.UpdatePost(context.Background(), postWithoutFiles.Id, updatePost) + require.Error(t, err) + CheckForbiddenStatus(t, resp) + assert.Nil(t, updatedPost) + }) + + t.Run("should allow updating post with files when user has upload_file permission", func(t *testing.T) { + postWithoutFiles, appErr := th.App.CreatePost(th.Context, &model.Post{ + UserId: th.BasicUser.Id, + ChannelId: channel.Id, + Message: "Post without files", + }, channel, model.CreatePostFlags{SetOnline: true}) + require.Nil(t, appErr) + + fileResp, resp, err := client.UploadFile(context.Background(), []byte("test file data"), channel.Id, "test-file.txt") + require.NoError(t, err) + CheckCreatedStatus(t, resp) + fileId := fileResp.FileInfos[0].Id + + updatePost := &model.Post{ + Id: postWithoutFiles.Id, + ChannelId: channel.Id, + Message: "Updated post with file", + FileIds: model.StringArray{fileId}, + } + updatedPost, resp, err := client.UpdatePost(context.Background(), postWithoutFiles.Id, updatePost) + require.NoError(t, err) + CheckOKStatus(t, resp) + require.NotNil(t, updatedPost) + assert.Contains(t, updatedPost.FileIds, fileId) + }) + t.Run("logged out", func(t *testing.T) { _, err := client.Logout(context.Background()) require.NoError(t, err)
server/channels/api4/post_utils.go+28 −0 modified@@ -41,3 +41,31 @@ func postPriorityCheckWithContext(where string, c *Context, priority *model.Post c.Err = appErr } } + +// checkUploadFilePermissionForNewFiles checks upload_file permission only when +// adding new files to a post, preventing permission bypass via cross-channel file attachments. +func checkUploadFilePermissionForNewFiles(c *Context, newFileIds []string, originalPost *model.Post) { + if len(newFileIds) == 0 { + return + } + + originalFileIDsMap := make(map[string]bool, len(originalPost.FileIds)) + for _, fileID := range originalPost.FileIds { + originalFileIDsMap[fileID] = true + } + + hasNewFiles := false + for _, fileID := range newFileIds { + if !originalFileIDsMap[fileID] { + hasNewFiles = true + break + } + } + + if hasNewFiles { + if !c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), originalPost.ChannelId, model.PermissionUploadFile) { + c.SetPermissionError(model.PermissionUploadFile) + return + } + } +}
server/channels/api4/scheduled_post.go+49 −1 modified@@ -74,6 +74,13 @@ func createSchedulePost(c *Context, w http.ResponseWriter, r *http.Request) { defer c.LogAuditRecWithLevel(auditRec, app.LevelContent) model.AddEventParameterAuditableToAuditRec(auditRec, "scheduledPost", &scheduledPost) + if len(scheduledPost.FileIds) > 0 { + if !c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), scheduledPost.ChannelId, model.PermissionUploadFile) { + c.SetPermissionError(model.PermissionUploadFile) + return + } + } + scheduledPostChecks("Api4.createSchedulePost", c, &scheduledPost) if c.Err != nil { return @@ -169,12 +176,38 @@ func updateScheduledPost(c *Context, w http.ResponseWriter, r *http.Request) { defer c.LogAuditRecWithLevel(auditRec, app.LevelContent) model.AddEventParameterAuditableToAuditRec(auditRec, "scheduledPost", &scheduledPost) + userId := c.AppContext.Session().UserId + existingScheduledPost, err := c.App.Srv().Store().ScheduledPost().Get(scheduledPost.Id) + if err != nil { + c.Err = model.NewAppError("updateScheduledPost", "app.update_scheduled_post.get_scheduled_post.error", nil, "", http.StatusInternalServerError).Wrap(err) + return + } + if existingScheduledPost == nil { + c.Err = model.NewAppError("updateScheduledPost", "app.update_scheduled_post.existing_scheduled_post.not_exist", nil, "", http.StatusNotFound) + return + } + if existingScheduledPost.UserId != userId { + c.Err = model.NewAppError("updateScheduledPost", "app.update_scheduled_post.update_permission.error", nil, "", http.StatusForbidden) + return + } + + if len(scheduledPost.FileIds) > 0 { + originalPost, err := existingScheduledPost.ToPost() + if err != nil { + c.Err = model.NewAppError("updateScheduledPost", "app.update_scheduled_post.convert_to_post.error", nil, "", http.StatusInternalServerError).Wrap(err) + return + } + checkUploadFilePermissionForNewFiles(c, scheduledPost.FileIds, originalPost) + if c.Err != nil { + return + } + } + scheduledPostChecks("Api4.updateScheduledPost", c, &scheduledPost) if c.Err != nil { return } - userId := c.AppContext.Session().UserId updatedScheduledPost, appErr := c.App.UpdateScheduledPost(c.AppContext, userId, &scheduledPost, connectionID) if appErr != nil { c.Err = appErr @@ -209,6 +242,21 @@ func deleteScheduledPost(c *Context, w http.ResponseWriter, r *http.Request) { model.AddEventParameterToAuditRec(auditRec, "scheduledPostId", scheduledPostId) userId := c.AppContext.Session().UserId + + existingScheduledPost, err := c.App.Srv().Store().ScheduledPost().Get(scheduledPostId) + if err != nil { + c.Err = model.NewAppError("deleteScheduledPost", "app.delete_scheduled_post.get_scheduled_post.error", nil, "", http.StatusInternalServerError).Wrap(err) + return + } + if existingScheduledPost == nil { + c.Err = model.NewAppError("deleteScheduledPost", "app.delete_scheduled_post.existing_scheduled_post.not_exist", nil, "", http.StatusNotFound) + return + } + if existingScheduledPost.UserId != userId { + c.Err = model.NewAppError("deleteScheduledPost", "app.delete_scheduled_post.delete_permission.error", nil, "", http.StatusForbidden) + return + } + connectionID := r.Header.Get(model.ConnectionId) deletedScheduledPost, appErr := c.App.DeleteScheduledPost(c.AppContext, userId, scheduledPostId, connectionID) if appErr != nil {
server/channels/api4/scheduled_post_test.go+82 −0 modified@@ -11,6 +11,88 @@ import ( "github.com/stretchr/testify/require" ) +func TestUpdateScheduledPost(t *testing.T) { + mainHelper.Parallel(t) + th := Setup(t).InitBasic(t) + + th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuProfessional)) + + t.Run("should not allow updating a scheduled post not belonging to the user", func(t *testing.T) { + scheduledPost := &model.ScheduledPost{ + Draft: model.Draft{ + CreateAt: model.GetMillis(), + UserId: th.BasicUser.Id, + ChannelId: th.BasicChannel.Id, + Message: "this is a scheduled post", + }, + ScheduledAt: model.GetMillis() + 100000, + } + createdScheduledPost, _, err := th.Client.CreateScheduledPost(context.Background(), scheduledPost) + require.NoError(t, err) + require.NotNil(t, createdScheduledPost) + + originalMessage := createdScheduledPost.Message + originalScheduledAt := createdScheduledPost.ScheduledAt + + createdScheduledPost.ScheduledAt = model.GetMillis() + 9999999 + createdScheduledPost.Message = "Updated Message!!!" + + // Switch to BasicUser2 + th.LoginBasic2(t) + + _, resp, err := th.Client.UpdateScheduledPost(context.Background(), createdScheduledPost) + require.Error(t, err) + CheckForbiddenStatus(t, resp) + + // Switch back to original user and verify the post wasn't modified + th.LoginBasic(t) + + fetchedPost, err := th.App.Srv().Store().ScheduledPost().Get(createdScheduledPost.Id) + require.NoError(t, err) + require.NotNil(t, fetchedPost) + require.Equal(t, originalMessage, fetchedPost.Message) + require.Equal(t, originalScheduledAt, fetchedPost.ScheduledAt) + }) +} + +func TestDeleteScheduledPost(t *testing.T) { + mainHelper.Parallel(t) + th := Setup(t).InitBasic(t) + + th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuProfessional)) + + t.Run("should not allow deleting a scheduled post not belonging to the user", func(t *testing.T) { + scheduledPost := &model.ScheduledPost{ + Draft: model.Draft{ + CreateAt: model.GetMillis(), + UserId: th.BasicUser.Id, + ChannelId: th.BasicChannel.Id, + Message: "this is a scheduled post", + }, + ScheduledAt: model.GetMillis() + 100000, + } + createdScheduledPost, _, err := th.Client.CreateScheduledPost(context.Background(), scheduledPost) + require.NoError(t, err) + require.NotNil(t, createdScheduledPost) + + // Switch to BasicUser2 + th.LoginBasic2(t) + + _, resp, err := th.Client.DeleteScheduledPost(context.Background(), createdScheduledPost.Id) + require.Error(t, err) + CheckForbiddenStatus(t, resp) + + // Switch back to original user and verify the post wasn't deleted + th.LoginBasic(t) + + fetchedPost, err := th.App.Srv().Store().ScheduledPost().Get(createdScheduledPost.Id) + require.NoError(t, err) + require.NotNil(t, fetchedPost) + require.Equal(t, createdScheduledPost.Id, fetchedPost.Id) + require.Equal(t, createdScheduledPost.Message, fetchedPost.Message) + }) +} + func TestCreateScheduledPost(t *testing.T) { mainHelper.Parallel(t) th := Setup(t).InitBasic(t)
server/channels/app/channel_test.go+1 −1 modified@@ -714,7 +714,7 @@ func TestAddUserToChannelCreatesChannelMemberHistoryRecord(t *testing.T) { assert.Equal(t, channel.Id, history.ChannelId) channelMemberHistoryUserIds = append(channelMemberHistoryUserIds, history.UserId) } - assert.Equal(t, groupUserIds, channelMemberHistoryUserIds) + assert.ElementsMatch(t, groupUserIds, channelMemberHistoryUserIds) } func TestUsersAndPostsCreateActivityInChannel(t *testing.T) {
server/channels/app/scheduled_post.go+0 −9 modified@@ -73,7 +73,6 @@ func (a *App) UpdateScheduledPost(rctx request.CTX, userId string, scheduledPost return nil, validationErr } - // validate the scheduled post belongs to the said user existingScheduledPost, err := a.Srv().Store().ScheduledPost().Get(scheduledPost.Id) if err != nil { return nil, model.NewAppError("app.UpdateScheduledPost", "app.update_scheduled_post.get_scheduled_post.error", map[string]any{"user_id": userId, "scheduled_post_id": scheduledPost.Id}, "", http.StatusInternalServerError).Wrap(err) @@ -83,10 +82,6 @@ func (a *App) UpdateScheduledPost(rctx request.CTX, userId string, scheduledPost return nil, model.NewAppError("app.UpdateScheduledPost", "app.update_scheduled_post.existing_scheduled_post.not_exist", map[string]any{"user_id": userId, "scheduled_post_id": scheduledPost.Id}, "", http.StatusNotFound) } - if existingScheduledPost.UserId != userId { - return nil, model.NewAppError("app.UpdateScheduledPost", "app.update_scheduled_post.update_permission.error", map[string]any{"user_id": userId, "scheduled_post_id": scheduledPost.Id}, "", http.StatusForbidden) - } - // This step is not required for update but is useful as we want to return the // updated scheduled post. It's better to do this before calling update than after. scheduledPost.RestoreNonUpdatableFields(existingScheduledPost) @@ -110,10 +105,6 @@ func (a *App) DeleteScheduledPost(rctx request.CTX, userId, scheduledPostId, con return nil, model.NewAppError("app.DeleteScheduledPost", "app.delete_scheduled_post.existing_scheduled_post.not_exist", map[string]any{"user_id": userId, "scheduled_post_id": scheduledPostId}, "", http.StatusNotFound) } - if scheduledPost.UserId != userId { - return nil, model.NewAppError("app.DeleteScheduledPost", "app.delete_scheduled_post.delete_permission.error", map[string]any{"user_id": userId, "scheduled_post_id": scheduledPostId}, "", http.StatusForbidden) - } - if err := a.Srv().Store().ScheduledPost().PermanentlyDeleteScheduledPosts([]string{scheduledPostId}); err != nil { return nil, model.NewAppError("app.DeleteScheduledPost", "app.delete_scheduled_post.delete_error", map[string]any{"user_id": userId, "scheduled_post_id": scheduledPostId}, "", http.StatusInternalServerError).Wrap(err) }
server/channels/app/scheduled_post_test.go+0 −83 modified@@ -567,54 +567,6 @@ func TestUpdateScheduledPost(t *testing.T) { require.Equal(t, "Updated Message!!!", updatedScheduledPost.Message) }) - t.Run("should ot be allowed to updated a scheduled post not belonging to the user", func(t *testing.T) { - // first we'll create a scheduled post - userId := model.NewId() - - channel, err := th.GetSqlStore().Channel().Save(th.Context, &model.Channel{ - Name: model.NewId(), - DisplayName: "Channel", - Type: model.ChannelTypeOpen, - }, 1000) - require.NoError(t, err) - - _, err = th.GetSqlStore().Channel().SaveMember(th.Context, &model.ChannelMember{ - ChannelId: channel.Id, - UserId: userId, - NotifyProps: model.GetDefaultChannelNotifyProps(), - SchemeGuest: false, - SchemeUser: true, - }) - require.NoError(t, err) - - defer func() { - _ = th.GetSqlStore().Channel().Delete(channel.Id, model.GetMillis()) - _ = th.GetSqlStore().Channel().RemoveMember(th.Context, channel.Id, userId) - }() - - scheduledPost := &model.ScheduledPost{ - Draft: model.Draft{ - CreateAt: model.GetMillis(), - UserId: userId, - ChannelId: channel.Id, - Message: "this is a scheduled post", - }, - ScheduledAt: model.GetMillis() + 100000, // 100 seconds in the future - } - createdScheduledPost, appErr := th.App.SaveScheduledPost(th.Context, scheduledPost, user1ConnID) - require.Nil(t, appErr) - require.NotNil(t, createdScheduledPost) - - // now we'll try updating it - newScheduledAtTime := model.GetMillis() + 9999999 - createdScheduledPost.ScheduledAt = newScheduledAtTime - createdScheduledPost.Message = "Updated Message!!!" - updatedScheduledPost, appErr := th.App.UpdateScheduledPost(th.Context, th.BasicUser2.Id, createdScheduledPost, user1ConnID) - require.NotNil(t, appErr) - require.Equal(t, http.StatusForbidden, appErr.StatusCode) - require.Nil(t, updatedScheduledPost) - }) - t.Run("should only allow updating limited fields", func(t *testing.T) { // first we'll create a scheduled post userId := model.NewId() @@ -853,41 +805,6 @@ func TestDeleteScheduledPost(t *testing.T) { require.Nil(t, reFetchedScheduledPost) }) - t.Run("should not allow deleting someone else's scheduled post", func(t *testing.T) { - // first we'll create a scheduled post - scheduledPost := &model.ScheduledPost{ - Draft: model.Draft{ - CreateAt: model.GetMillis(), - UserId: th.BasicUser.Id, - ChannelId: th.BasicChannel.Id, - Message: "this is a scheduled post", - }, - ScheduledAt: model.GetMillis() + 100000, // 100 seconds in the future - } - createdScheduledPost, appErr := th.App.SaveScheduledPost(th.Context, scheduledPost, user1ConnID) - require.Nil(t, appErr) - require.NotNil(t, createdScheduledPost) - - fetchedScheduledPost, err := th.Server.Store().ScheduledPost().Get(scheduledPost.Id) - require.NoError(t, err) - require.NotNil(t, fetchedScheduledPost) - require.Equal(t, createdScheduledPost.Id, fetchedScheduledPost.Id) - require.Equal(t, createdScheduledPost.Message, fetchedScheduledPost.Message) - - // now we'll delete it - var deletedScheduledPost *model.ScheduledPost - deletedScheduledPost, appErr = th.App.DeleteScheduledPost(th.Context, th.BasicUser2.Id, scheduledPost.Id, "connection_id") - require.NotNil(t, appErr) - require.Nil(t, deletedScheduledPost) - - // try to fetch it again - reFetchedScheduledPost, err := th.Server.Store().ScheduledPost().Get(scheduledPost.Id) - require.NoError(t, err) - require.NotNil(t, reFetchedScheduledPost) - require.Equal(t, createdScheduledPost.Id, reFetchedScheduledPost.Id) - require.Equal(t, createdScheduledPost.Message, reFetchedScheduledPost.Message) - }) - t.Run("should producer error when deleting non existing scheduled post", func(t *testing.T) { var deletedScheduledPost *model.ScheduledPost deletedScheduledPost, appErr := th.App.DeleteScheduledPost(th.Context, th.BasicUser.Id, model.NewId(), "connection_id")
server/i18n/en.json+4 −0 modified@@ -7928,6 +7928,10 @@ "id": "app.update_error", "translation": "update error" }, + { + "id": "app.update_scheduled_post.convert_to_post.error", + "translation": "Unable to convert scheduled post to post format." + }, { "id": "app.update_scheduled_post.existing_scheduled_post.not_exist", "translation": "Scheduled post does not exist."
Vulnerability mechanics
Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
4- github.com/advisories/GHSA-xpvf-6qcc-9jqcghsaADVISORY
- mattermost.com/security-updatesghsavendor-advisoryWEB
- nvd.nist.gov/vuln/detail/CVE-2026-4265ghsaADVISORY
- github.com/mattermost/mattermost/commit/c7f6efdfb035490f494b3177996ee5f4b278c988ghsaWEB
News mentions
0No linked articles in our index yet.