Low severityNVD Advisory· Published Sep 19, 2025· Updated Sep 19, 2025
IDOR in board file download allows any user to download any file by UUID
CVE-2025-9081
Description
Mattermost versions 10.5.x <= 10.5.8, 9.11.x <= 9.11.17 fail to properly validate access controls which allows any authenticated user to download sensitive files via board file download endpoint using UUID enumeration
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
github.com/mattermost/mattermost-plugin-boardsGo | < 0.0.0-20250716054606-3f3e3becfe1d | 0.0.0-20250716054606-3f3e3becfe1d |
github.com/mattermost/mattermost/server/v8Go | < 8.0.0-20250721095935-11c36f4d1e44 | 8.0.0-20250721095935-11c36f4d1e44 |
github.com/mattermost/mattermost-serverGo | >= 10.5.0-rc1, < 10.5.9 | 10.5.9 |
github.com/mattermost/mattermost-serverGo | >= 9.11.0-rc1, < 9.11.18 | 9.11.18 |
Affected products
1- Range: 10.5.0
Patches
13f3e3becfe1dAdded file ownership validation to file access endpoints (#114)
3 files changed · +181 −0
server/api/files.go+7 −0 modified@@ -303,6 +303,12 @@ func (a *API) getFileInfo(w http.ResponseWriter, r *http.Request) { auditRec.AddMeta("teamID", teamID) auditRec.AddMeta("filename", filename) + // Validate that the file belongs to the specified board and team + if err := a.app.ValidateFileOwnership(teamID, boardID, filename); err != nil { + a.errorResponse(w, r, model.NewErrPermission("access denied to file")) + return + } + fileInfo, err := a.app.GetFileInfo(filename) if err != nil && !model.IsErrNotFound(err) { a.errorResponse(w, r, err) @@ -316,6 +322,7 @@ func (a *API) getFileInfo(w http.ResponseWriter, r *http.Request) { } jsonBytesResponse(w, http.StatusOK, data) + auditRec.Success() } func (a *API) handleUploadFile(w http.ResponseWriter, r *http.Request) {
server/app/files.go+69 −0 modified@@ -23,6 +23,7 @@ const emptyString = "empty" var errEmptyFilename = errors.New("IsFileArchived: empty filename not allowed") var ErrFileNotFound = errors.New("file not found") +var ErrFileNotReferencedByBoard = errors.New("file not referenced by board") func (a *App) SaveFile(reader io.Reader, teamID, boardID, filename string, asTemplate bool) (string, error) { // NOTE: File extension includes the dot @@ -86,7 +87,75 @@ func (a *App) GetFileInfo(filename string) (*mm_model.FileInfo, error) { return fileInfo, nil } +// ValidateFileOwnership checks if a file belongs to the specified board and team. +func (a *App) ValidateFileOwnership(teamID, boardID, filename string) error { + fileInfo, err := a.GetFileInfo(filename) + if err != nil { + return err + } + if fileInfo != nil && fileInfo.Path != "" && fileInfo.Path != emptyString { + expectedPath := filepath.Join(teamID, boardID, filename) + if fileInfo.Path == expectedPath { + return nil + } + if err := a.validateFileReferencedByBoard(boardID, filename); err != nil { + return model.NewErrPermission("file does not belong to the specified board") + } + } else { + if err := a.validateFileReferencedByBoard(boardID, filename); err != nil { + return model.NewErrPermission("file does not belong to the specified board") + } + } + return nil +} + +// validateFileReferencedByBoard checks if a file is referenced by blocks in the specified board. +// Files use different storage patterns (teamID/boardID/filename for templates, boards/YYYYMMDD/filename for regular files). +// Path mismatches don't indicate malicious files, just different storage patterns. +func (a *App) validateFileReferencedByBoard(boardID, filename string) error { + imageBlocks, err := a.store.GetBlocksWithType(boardID, model.TypeImage) + if err != nil { + return err + } + + attachmentBlocks, err := a.store.GetBlocksWithType(boardID, model.TypeAttachment) + if err != nil { + return err + } + + // Check image blocks + for _, block := range imageBlocks { + if fileID, ok := block.Fields[model.BlockFieldFileId].(string); ok && fileID == filename { + return nil + } + if attachmentID, ok := block.Fields[model.BlockFieldAttachmentId].(string); ok && attachmentID == filename { + return nil + } + } + + // Check attachment blocks + for _, block := range attachmentBlocks { + if fileID, ok := block.Fields[model.BlockFieldFileId].(string); ok && fileID == filename { + return nil + } + if attachmentID, ok := block.Fields[model.BlockFieldAttachmentId].(string); ok && attachmentID == filename { + return nil + } + } + + return fmt.Errorf("%w: file %s is not referenced by any block in board %s", ErrFileNotReferencedByBoard, filename, boardID) +} + func (a *App) GetFile(teamID, boardID, fileName string) (*mm_model.FileInfo, filestore.ReadCloseSeeker, error) { + if err := a.ValidateFileOwnership(teamID, boardID, fileName); err != nil { + a.logger.Error("GetFile: File ownership validation failed", + mlog.String("Team", teamID), + mlog.String("board", boardID), + mlog.String("filename", fileName), + mlog.Err(err)) + return nil, nil, err + } + fileInfo, filePath, err := a.GetFilePath(teamID, boardID, fileName) if err != nil { a.logger.Error("GetFile: Failed to GetFilePath.", mlog.String("Team", teamID), mlog.String("board", boardID), mlog.String("filename", fileName), mlog.Err(err))
server/app/files_test.go+105 −0 modified@@ -929,6 +929,111 @@ func TestUserCreatedTemplateFilePathValidation(t *testing.T) { }) } +func TestValidateFileOwnership(t *testing.T) { + th, _ := SetupTestHelper(t) + + validTeamID := "validteamid1234567890123456" + validBoardID := "bvalidboard1234567890123456" + otherBoardID := "botherboard1234567890123456" + filename := "7validfile1234567890123456.txt" + + t.Run("Should allow access to file that belongs to the board", func(t *testing.T) { + // Mock file info with path matching the board + fileInfo := &mm_model.FileInfo{ + Id: "validfile1234567890123456", + Path: filepath.Join(validTeamID, validBoardID, filename), + } + th.Store.EXPECT().GetFileInfo("validfile1234567890123456").Return(fileInfo, nil) + + err := th.App.ValidateFileOwnership(validTeamID, validBoardID, filename) + assert.NoError(t, err) + }) + + t.Run("Should allow access to file with base path that is referenced by board", func(t *testing.T) { + // Mock file info with base path (newer storage format) + fileInfo := &mm_model.FileInfo{ + Id: "validfile1234567890123456", + Path: filepath.Join(utils.GetBaseFilePath(), filename), + } + th.Store.EXPECT().GetFileInfo("validfile1234567890123456").Return(fileInfo, nil) + + // Mock block that references the file + block := &model.Block{ + ID: "blockid1234567890123456789", + BoardID: validBoardID, + Type: model.TypeImage, + Fields: map[string]interface{}{model.BlockFieldFileId: filename}, + } + th.Store.EXPECT().GetBlocksForBoard(validBoardID).Return([]*model.Block{block}, nil) + + err := th.App.ValidateFileOwnership(validTeamID, validBoardID, filename) + assert.NoError(t, err) + }) + + t.Run("Should deny access to file that belongs to different board", func(t *testing.T) { + // Mock file info with path from different board + fileInfo := &mm_model.FileInfo{ + Id: "validfile1234567890123456", + Path: filepath.Join(validTeamID, otherBoardID, filename), + } + th.Store.EXPECT().GetFileInfo("validfile1234567890123456").Return(fileInfo, nil) + + // Mock empty blocks for the requested board (file not referenced) + th.Store.EXPECT().GetBlocksForBoard(validBoardID).Return([]*model.Block{}, nil) + + err := th.App.ValidateFileOwnership(validTeamID, validBoardID, filename) + assert.Error(t, err) + assert.Contains(t, err.Error(), "file does not belong to the specified board") + }) + + t.Run("Should deny access to file that is not referenced by any block in the board", func(t *testing.T) { + fileInfo := &mm_model.FileInfo{ + Id: "validfile1234567890123456", + Path: filepath.Join(utils.GetBaseFilePath(), filename), + } + th.Store.EXPECT().GetFileInfo("validfile1234567890123456").Return(fileInfo, nil) + + block := &model.Block{ + ID: "blockid1234567890123456789", + BoardID: validBoardID, + Type: model.TypeImage, + Fields: map[string]interface{}{model.BlockFieldFileId: "different_file.txt"}, + } + th.Store.EXPECT().GetBlocksForBoard(validBoardID).Return([]*model.Block{block}, nil) + + err := th.App.ValidateFileOwnership(validTeamID, validBoardID, filename) + assert.Error(t, err) + assert.Contains(t, err.Error(), "file does not belong to the specified board") + }) + + t.Run("Should allow access to file referenced by attachment field", func(t *testing.T) { + fileInfo := &mm_model.FileInfo{ + Id: "validfile1234567890123456", + Path: filepath.Join(utils.GetBaseFilePath(), filename), + } + th.Store.EXPECT().GetFileInfo("validfile1234567890123456").Return(fileInfo, nil) + + block := &model.Block{ + ID: "blockid1234567890123456789", + BoardID: validBoardID, + Type: model.TypeAttachment, + Fields: map[string]interface{}{model.BlockFieldAttachmentId: filename}, + } + th.Store.EXPECT().GetBlocksForBoard(validBoardID).Return([]*model.Block{block}, nil) + + err := th.App.ValidateFileOwnership(validTeamID, validBoardID, filename) + assert.NoError(t, err) + }) + + t.Run("Should handle file info not found", func(t *testing.T) { + th.Store.EXPECT().GetFileInfo("validfile1234567890123456").Return(nil, model.NewErrNotFound("file not found")) + + err := th.App.ValidateFileOwnership(validTeamID, validBoardID, filename) + assert.Error(t, err) + assert.Contains(t, err.Error(), "file not found") + }) +} + func TestGetFilePathWithGlobalTeamID(t *testing.T) { th, _ := SetupTestHelper(t)
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
6- github.com/advisories/GHSA-f72g-52v7-mg3pghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2025-9081ghsaADVISORY
- github.com/mattermost/mattermost-plugin-boards/commit/3f3e3becfe1d66db0d0f4fd235f04afd6e1ec40bghsaWEB
- github.com/mattermost/mattermost-plugin-boards/pull/114ghsaWEB
- mattermost.com/security-updatesghsaWEB
- pkg.go.dev/vuln/GO-2025-3978ghsaWEB
News mentions
0No linked articles in our index yet.