Moderate severityNVD Advisory· Published Jul 18, 2025· Updated Jul 18, 2025
Arbitrary file read by system admin via path traversal
CVE-2025-6233
Description
Mattermost versions 10.8.x <= 10.8.1, 10.7.x <= 10.7.3, 10.5.x <= 10.5.7, 9.11.x <= 9.11.16 fail to sanitize input paths of file attachments in the bulk import JSONL file, which allows a system admin to read arbitrary system files via path traversal.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
github.com/mattermost/mattermost-serverGo | >= 10.8.0, < 10.8.2 | 10.8.2 |
github.com/mattermost/mattermost-serverGo | >= 10.7.0, < 10.7.4 | 10.7.4 |
github.com/mattermost/mattermost-serverGo | >= 10.5.0, < 10.5.8 | 10.5.8 |
github.com/mattermost/mattermost-serverGo | >= 9.11.0, < 9.11.17 | 9.11.17 |
github.com/mattermost/mattermost/server/v8Go | < 8.0.0-20250529054450-d38c27f96fcf | 8.0.0-20250529054450-d38c27f96fcf |
Affected products
1- Range: 10.8.0
Patches
1d38c27f96fcf[MM-64402] Improve validation of imported attachments (#31201)
7 files changed · +773 −32
server/channels/app/import_functions.go+2 −2 modified@@ -193,7 +193,6 @@ func (a *App) importTeam(rctx request.CTX, data *imports.TeamImportData, dryRun var team *model.Team team, err := a.Srv().Store().Team().GetByName(teamName) - if err != nil { team = &model.Team{ Name: teamName, @@ -2063,6 +2062,7 @@ func (a *App) updateFileInfoWithPostId(rctx request.CTX, post *model.Post) { } } } + func (a *App) importDirectChannel(rctx request.CTX, data *imports.DirectChannelImportData, dryRun bool) *model.AppError { var err *model.AppError if err = imports.ValidateDirectChannelImportData(data); err != nil { @@ -2117,7 +2117,7 @@ func (a *App) importDirectChannel(rctx request.CTX, data *imports.DirectChannelI return model.NewAppError("BulkImport", "app.import.import_direct_channel.get_channel_members.error", nil, "", http.StatusBadRequest).Wrap(err) } - var ems = make([]model.ChannelMember, 0, totalMembers) + ems := make([]model.ChannelMember, 0, totalMembers) var page int for int64(len(ems)) < totalMembers {
server/channels/app/import.go+32 −7 modified@@ -7,6 +7,7 @@ import ( "archive/zip" "bufio" "encoding/json" + "errors" "fmt" "io" "net/http" @@ -45,20 +46,32 @@ func processAttachmentPaths(c request.CTX, files *[]imports.AttachmentImportData if files == nil { return nil } + var ok bool + var errs []error for i, f := range *files { if f.Path != nil { - path := filepath.Join(basePath, *f.Path) + originalPath := *f.Path + + path, valid := imports.ValidateAttachmentPathForImport(originalPath, basePath) + *f.Path = path + + if !valid { + errs = append(errs, fmt.Errorf("invalid attachment path %q", originalPath)) + continue + } + if len(filesMap) > 0 { - if (*files)[i].Data, ok = filesMap[path]; !ok { - return fmt.Errorf("attachment %q not found in map", path) + if (*files)[i].Data, ok = filesMap[*f.Path]; !ok { + errs = append(errs, fmt.Errorf("attachment %q not found in map", originalPath)) + continue } } } } - return nil + return errors.Join(errs...) } func processAttachments(c request.CTX, line *imports.LineImportData, basePath string, filesMap map[string]*zip.File) error { @@ -88,7 +101,11 @@ func processAttachments(c request.CTX, line *imports.LineImportData, basePath st } case "user": if line.User.ProfileImage != nil { - path := filepath.Join(basePath, *line.User.ProfileImage) + path, valid := imports.ValidateAttachmentPathForImport(*line.User.ProfileImage, basePath) + if !valid { + return fmt.Errorf("invalid profile image path %q", *line.User.ProfileImage) + } + *line.User.ProfileImage = path if len(filesMap) > 0 { if line.User.ProfileImageData, ok = filesMap[path]; !ok { @@ -98,7 +115,11 @@ func processAttachments(c request.CTX, line *imports.LineImportData, basePath st } case "bot": if line.Bot.ProfileImage != nil { - path := filepath.Join(basePath, *line.Bot.ProfileImage) + path, valid := imports.ValidateAttachmentPathForImport(*line.Bot.ProfileImage, basePath) + if !valid { + return fmt.Errorf("invalid bot profile image path %q", *line.Bot.ProfileImage) + } + *line.Bot.ProfileImage = path if len(filesMap) > 0 { if line.Bot.ProfileImageData, ok = filesMap[path]; !ok { @@ -108,7 +129,11 @@ func processAttachments(c request.CTX, line *imports.LineImportData, basePath st } case "emoji": if line.Emoji.Image != nil { - path := filepath.Join(basePath, *line.Emoji.Image) + path, valid := imports.ValidateAttachmentPathForImport(*line.Emoji.Image, basePath) + if !valid { + return fmt.Errorf("invalid emoji image path %q", *line.Emoji.Image) + } + *line.Emoji.Image = path if len(filesMap) > 0 { if line.Emoji.Data, ok = filesMap[path]; !ok {
server/channels/app/imports/import_validators.go+78 −0 modified@@ -7,6 +7,7 @@ import ( "encoding/json" "net/http" "os" + "path/filepath" "strings" "unicode/utf8" @@ -191,6 +192,10 @@ func ValidateChannelImportData(data *ChannelImportData) *model.AppError { func ValidateUserImportData(data *UserImportData) *model.AppError { if data.ProfileImage != nil && data.ProfileImageData == nil { + // Check if the resolved path is within the expected base path. + if _, valid := ValidateAttachmentPathForImport(*data.ProfileImage, model.ExportDataDir); !valid { + return model.NewAppError("BulkImport", "app.import.validate_user_import_data.invalid_image_path.error", map[string]any{"Path": *data.ProfileImage}, "", http.StatusBadRequest) + } if _, err := os.Stat(*data.ProfileImage); os.IsNotExist(err) { return model.NewAppError("BulkImport", "app.import.validate_user_import_data.profile_image.error", nil, "", http.StatusNotFound).Wrap(err) } else if err != nil { @@ -320,6 +325,11 @@ func ValidateUserImportData(data *UserImportData) *model.AppError { func ValidateBotImportData(data *BotImportData) *model.AppError { if data.ProfileImage != nil && data.ProfileImageData == nil { + // Check if the resolved path is within the expected base path. + if _, valid := ValidateAttachmentPathForImport(*data.ProfileImage, model.ExportDataDir); !valid { + return model.NewAppError("BulkImport", "app.import.validate_user_import_data.invalid_image_path.error", map[string]any{"Path": *data.ProfileImage}, "", http.StatusBadRequest) + } + if _, err := os.Stat(*data.ProfileImage); os.IsNotExist(err) { return model.NewAppError("BulkImport", "app.import.validate_user_import_data.profile_image.error", nil, "", http.StatusNotFound).Wrap(err) } else if err != nil { @@ -487,6 +497,14 @@ func ValidateReplyImportData(data *ReplyImportData, parentCreateAt int64, maxPos } } + if data.Attachments != nil { + for _, attachment := range *data.Attachments { + if err := ValidateAttachmentImportData(&attachment); err != nil { + return model.NewAppError("BulkImport", "app.import.validate_reply_import_data.attachment.error", nil, "", http.StatusNotFound).Wrap(err) + } + } + } + return nil } @@ -537,6 +555,14 @@ func ValidatePostImportData(data *PostImportData, maxPostSize int) *model.AppErr return model.NewAppError("BulkImport", "app.import.validate_post_import_data.props_too_large.error", nil, "", http.StatusBadRequest) } + if data.Attachments != nil { + for _, attachment := range *data.Attachments { + if err := ValidateAttachmentImportData(&attachment); err != nil { + return model.NewAppError("BulkImport", "app.import.validate_post_import_data.attachment.error", nil, "", http.StatusNotFound).Wrap(err) + } + } + } + return nil } @@ -653,6 +679,14 @@ func ValidateDirectPostImportData(data *DirectPostImportData, maxPostSize int) * } } + if data.Attachments != nil { + for _, attachment := range *data.Attachments { + if err := ValidateAttachmentImportData(&attachment); err != nil { + return model.NewAppError("BulkImport", "app.import.validate_direct_post_import_data.attachment.error", nil, "", http.StatusNotFound).Wrap(err) + } + } + } + return nil } @@ -671,6 +705,11 @@ func ValidateEmojiImportData(data *EmojiImportData) *model.AppError { return model.NewAppError("BulkImport", "app.import.validate_emoji_import_data.image_missing.error", nil, "", http.StatusBadRequest) } + // Check if the resolved path is within the expected base path. + if _, valid := ValidateAttachmentPathForImport(*data.Image, model.ExportDataDir); !valid { + return model.NewAppError("BulkImport", "app.import.validate_emoji_import_data.invalid_image_path.error", map[string]any{"Path": *data.Image}, "", http.StatusBadRequest) + } + if err := model.IsValidEmojiName(*data.Name); err != nil { return err } @@ -749,3 +788,42 @@ func isValidGuestRoles(data UserImportData) bool { return true } + +// ValidateAttachmentPathForImport joins 'path' to 'basePath' (defaulting to "." if empty) and ensures +// the result does not escape the base directory. Returns the cleaned joined path (and true), +// or an empty string (and false) if the result escapes the base. +func ValidateAttachmentPathForImport(path, basePath string) (string, bool) { + if basePath == "" { + basePath = "." + } + + joined := filepath.Join(basePath, path) + + // Check if the resolved joined path is within basePath + rel, err := filepath.Rel(basePath, joined) + if err != nil { + return "", false + } + if strings.HasPrefix(rel, ".."+string(filepath.Separator)) || rel == ".." { + return "", false + } + + return joined, true +} + +func ValidateAttachmentImportData(data *AttachmentImportData) *model.AppError { + if data == nil { + return nil + } + + if data.Path == nil || *data.Path == "" { + return nil + } + + // Check if the resolved path is within the expected base path. + if _, valid := ValidateAttachmentPathForImport(*data.Path, model.ExportDataDir); !valid { + return model.NewAppError("BulkImport", "app.import.validate_attachment_import_data.invalid_path.error", map[string]any{"Path": *data.Path}, "", http.StatusBadRequest) + } + + return nil +}
server/channels/app/imports/import_validators_test.go+384 −5 modified@@ -499,6 +499,12 @@ func TestImportValidateUserImportData(t *testing.T) { err = ValidateUserImportData(&data) require.NotNil(t, err, "Validation should have failed due to not existing profile image file.") + // Invalid image path + data.ProfileImage = model.NewPointer("../invalid/path/file.jpg") + err = ValidateUserImportData(&data) + require.NotNil(t, err, "Validation should have failed due to invalid profile image file path.") + require.Equal(t, "app.import.validate_user_import_data.invalid_image_path.error", err.Id) + data.ProfileImage = nil // Invalid Emails @@ -623,8 +629,8 @@ func TestImportValidateUserImportData(t *testing.T) { data.NotifyProps.MentionKeys = model.NewPointer("valid") checkNoError(t, ValidateUserImportData(&data)) - //Test the email batching interval validators - //Happy paths + // Test the email batching interval validators + // Happy paths data.EmailInterval = model.NewPointer("immediately") checkNoError(t, ValidateUserImportData(&data)) @@ -634,7 +640,7 @@ func TestImportValidateUserImportData(t *testing.T) { data.EmailInterval = model.NewPointer("hour") checkNoError(t, ValidateUserImportData(&data)) - //Invalid values + // Invalid values data.EmailInterval = model.NewPointer("invalid") checkError(t, ValidateUserImportData(&data)) @@ -731,6 +737,12 @@ func TestImportValidateBotImportData(t *testing.T) { data.Owner = model.NewPointer(strings.Repeat("abcdefghij", 7)) err = ValidateBotImportData(&data) require.NotNil(t, err, "Should have failed due to too long OwnerID.") + + // Invalid profile image path + data.ProfileImage = model.NewPointer("../invalid/path/file.jpg") + err = ValidateBotImportData(&data) + require.NotNil(t, err, "Should have failed due to invalid profile image file path.") + require.Equal(t, "app.import.validate_user_import_data.invalid_image_path.error", err.Id) } func TestImportValidateUserTeamsImportData(t *testing.T) { @@ -943,6 +955,21 @@ func TestImportValidateReplyImportData(t *testing.T) { } err = ValidateReplyImportData(&data, parentCreateAt, maxPostSize) require.NotNil(t, err, "Should have failed due to 0 create-at value.") + + // Test with invalid attachment path. + data = ReplyImportData{ + User: model.NewPointer("username"), + Message: model.NewPointer("message"), + CreateAt: model.NewPointer(model.GetMillis()), + Attachments: &[]AttachmentImportData{ + { + Path: model.NewPointer("invalid/../../../path/to/file.txt"), + }, + }, + } + err = ValidateReplyImportData(&data, parentCreateAt, maxPostSize) + require.NotNil(t, err, "Should have failed due to invalid attachment path.") + require.Equal(t, err.Id, "app.import.validate_reply_import_data.attachment.error") } func TestImportValidatePostImportData(t *testing.T) { @@ -1085,6 +1112,24 @@ func TestImportValidatePostImportData(t *testing.T) { require.NotNil(t, err, "Should have failed due to long props.") assert.Equal(t, err.Id, "app.import.validate_post_import_data.props_too_large.error") }) + + t.Run("Test with invalid attachment path", func(t *testing.T) { + data := PostImportData{ + Team: model.NewPointer("teamname"), + Channel: model.NewPointer("channelname"), + User: model.NewPointer("username"), + Message: model.NewPointer("message"), + CreateAt: model.NewPointer(model.GetMillis()), + Attachments: &[]AttachmentImportData{ + { + Path: model.NewPointer("invalid/../../../path/to/file.txt"), + }, + }, + } + err := ValidatePostImportData(&data, maxPostSize) + require.NotNil(t, err) + assert.Equal(t, err.Id, "app.import.validate_post_import_data.attachment.error") + }) } func TestImportValidateDirectChannelImportData(t *testing.T) { @@ -1438,10 +1483,29 @@ func TestImportValidateDirectPostImportData(t *testing.T) { err = ValidateDirectPostImportData(&data, maxPostSize) require.Nil(t, err, "Validation should succeed with valid optional parameters") + + // Test with invalid attachment path. + data = DirectPostImportData{ + ChannelMembers: &[]string{ + model.NewId(), + model.NewId(), + }, + User: model.NewPointer("username"), + Message: model.NewPointer("message"), + CreateAt: model.NewPointer(model.GetMillis()), + Attachments: &[]AttachmentImportData{ + { + Path: model.NewPointer("invalid/../../../path/to/file.txt"), + }, + }, + } + err = ValidateDirectPostImportData(&data, maxPostSize) + require.NotNil(t, err, "Should have failed due to invalid attachment path.") + require.Equal(t, err.Id, "app.import.validate_direct_post_import_data.attachment.error") } func TestImportValidateEmojiImportData(t *testing.T) { - var testCases = []struct { + testCases := []struct { testName string name *string image *string @@ -1456,6 +1520,7 @@ func TestImportValidateEmojiImportData(t *testing.T) { {"nil name", nil, model.NewPointer("/path/to/image"), true, false}, {"nil image", model.NewPointer("parrot2"), nil, true, false}, {"nil name and image", nil, nil, true, false}, + {"invalid image path", model.NewPointer("parrot2"), model.NewPointer("../invalid/path/to/emoji.png"), true, false}, } for _, tc := range testCases { @@ -1485,7 +1550,7 @@ func checkNoError(t *testing.T, err *model.AppError) { } func TestIsValidGuestRoles(t *testing.T) { - var testCases = []struct { + testCases := []struct { name string input UserImportData expected bool @@ -1596,3 +1661,317 @@ func TestIsValidGuestRoles(t *testing.T) { }) } } + +func TestValidateAttachmentPathForImport(t *testing.T) { + for _, tc := range []struct { + name string + path string + basePath string + expectedPath string + expectedRes bool + }{ + { + name: "valid relative path", + path: "valid/path/to/attachment", + basePath: "data", + expectedPath: "data/valid/path/to/attachment", + expectedRes: true, + }, + { + name: "valid absolute path", + path: "/valid/path/to/attachment", + basePath: "data", + expectedPath: "data/valid/path/to/attachment", + expectedRes: true, + }, + { + name: "valid relative path with empty base", + path: "data/file.jpg", + basePath: "", + expectedPath: "data/file.jpg", + expectedRes: true, + }, + { + name: "absolute path with empty base is converted to relative", + path: "/data/file.jpg", + basePath: "", + expectedPath: "data/file.jpg", + expectedRes: true, + }, + { + name: "valid path with dot segments", + path: "path/./to/attachment", + basePath: "data", + expectedPath: "data/path/to/attachment", + expectedRes: true, + }, + { + name: "valid path with internal parent reference", + path: "path/to/../to/attachment", + basePath: "data", + expectedPath: "data/path/to/attachment", + expectedRes: true, + }, + { + name: "valid path with filename containing dots", + path: "path/to/file..txt", + basePath: "data", + expectedPath: "data/path/to/file..txt", + expectedRes: true, + }, + { + name: "valid path with default base path", + path: "file.txt", + basePath: "", + expectedPath: "file.txt", + expectedRes: true, + }, + { + name: "valid path with spaces", + path: "path/to/file with spaces.jpg", + basePath: "data", + expectedPath: "data/path/to/file with spaces.jpg", + expectedRes: true, + }, + { + name: "invalid path with parent directory traversal", + path: "../file.txt", + basePath: "data", + expectedPath: "", + expectedRes: false, + }, + { + name: "invalid path with multiple parent directory traversal", + path: "../../file.txt", + basePath: "data", + expectedPath: "", + expectedRes: false, + }, + { + name: "invalid path with parent directory traversal in middle", + path: "path/../../file.txt", + basePath: "data", + expectedPath: "", + expectedRes: false, + }, + { + name: "invalid absolute path with traversal", + path: "/path/../../../not/valid", + basePath: "data", + expectedPath: "", + expectedRes: false, + }, + { + name: "invalid relative path with substring in path", + path: "../data_dir/attachment", + basePath: "data", + expectedPath: "", + expectedRes: false, + }, + { + name: "empty path", + path: "", + basePath: "data", + expectedPath: "data", + expectedRes: true, + }, + { + name: "path is just a dot", + path: ".", + basePath: "data", + expectedPath: "data", + expectedRes: true, + }, + { + name: "path with only parent reference", + path: "..", + basePath: "data", + expectedPath: "", + expectedRes: false, + }, + // Additional security test cases + { + name: "valid path with double dots in filename", + path: "....//file.txt", + basePath: "data", + expectedPath: "data/..../file.txt", + expectedRes: true, // Double dots in filename are valid, not traversal + }, + { + name: "valid path with quadruple dots in filename", + path: "..../file.txt", + basePath: "data", + expectedPath: "data/..../file.txt", + expectedRes: true, // Quadruple dots in filename are valid, not traversal + }, + { + name: "valid path with backslashes (treated as literal on Unix)", + path: "path\\..\\..\\file.txt", + basePath: "data", + expectedPath: "data/path\\..\\..\\file.txt", + expectedRes: true, // Backslashes are literal characters on Unix systems + }, + { + name: "valid path with mixed separators (backslash literal)", + path: "path\\../file.txt", + basePath: "data", + expectedPath: "data/path\\../file.txt", + expectedRes: true, // Backslash is literal, only forward slash is normalized + }, + { + name: "valid URL encoded characters (treated as literals)", + path: "%2e%2e%2ffile.txt", + basePath: "data", + expectedPath: "data/%2e%2e%2ffile.txt", + expectedRes: true, // URL encoding should be treated as literal characters + }, + { + name: "valid null byte in filename (treated as literal)", + path: "file.txt\x00../etc/passwd", + basePath: "data", + expectedPath: "data/file.txt\x00../etc/passwd", + expectedRes: true, // Null bytes should be treated as literal characters + }, + { + name: "invalid complex traversal pattern", + path: "./././../../../file.txt", + basePath: "data", + expectedPath: "", + expectedRes: false, + }, + { + name: "valid path with multiple slashes (normalized)", + path: "path///../file.txt", + basePath: "data", + expectedPath: "data/file.txt", + expectedRes: true, // Multiple slashes get normalized, no traversal occurs + }, + { + name: "invalid deep traversal attempt", + path: "../../../../../../../../../etc/passwd", + basePath: "data", + expectedPath: "", + expectedRes: false, + }, + { + name: "valid path with multiple internal dots", + path: "path/to/file...with...dots.txt", + basePath: "data", + expectedPath: "data/path/to/file...with...dots.txt", + expectedRes: true, + }, + { + name: "invalid traversal with valid-looking suffix", + path: "../trusted_NOT/secrets.txt", + basePath: "/trusted", + expectedPath: "", + expectedRes: false, + }, + { + name: "valid path with base path containing special chars", + path: "file.txt", + basePath: "data-dir_v1.0", + expectedPath: "data-dir_v1.0/file.txt", + expectedRes: true, + }, + { + name: "valid Windows-style paths (backslashes literal on Unix)", + path: "..\\..\\windows\\system32\\config", + basePath: "data", + expectedPath: "data/..\\..\\windows\\system32\\config", + expectedRes: true, // Backslashes are literal on Unix, no traversal + }, + { + name: "valid path with Unicode characters", + path: "path/to/файл.txt", + basePath: "data", + expectedPath: "data/path/to/файл.txt", + expectedRes: true, + }, + } { + t.Run(tc.name, func(t *testing.T) { + path, ok := ValidateAttachmentPathForImport(tc.path, tc.basePath) + require.Equal(t, tc.expectedPath, path) + require.Equal(t, tc.expectedRes, ok) + }) + } +} + +func TestValidateAttachmentImportData(t *testing.T) { + for _, tc := range []struct { + name string + data *AttachmentImportData + err string + }{ + { + name: "nil data", + }, + { + name: "empty path", + data: &AttachmentImportData{}, + }, + { + name: "valid absolute path", + data: &AttachmentImportData{ + Path: model.NewPointer("/valid/path/to/attachment"), + }, + }, + { + name: "invalid relative path", + data: &AttachmentImportData{ + Path: model.NewPointer("../attachment"), + }, + err: "BulkImport: app.import.validate_attachment_import_data.invalid_path.error", + }, + { + name: "invalid relative path", + data: &AttachmentImportData{ + Path: model.NewPointer("path/to/../../../attachment"), + }, + err: "BulkImport: app.import.validate_attachment_import_data.invalid_path.error", + }, + + { + name: "invalid relative path", + data: &AttachmentImportData{ + Path: model.NewPointer("../data_dir/attachment"), + }, + err: "BulkImport: app.import.validate_attachment_import_data.invalid_path.error", + }, + + { + name: "valid relative path", + data: &AttachmentImportData{ + Path: model.NewPointer("./path/to/attachment"), + }, + }, + { + name: "valid relative path", + data: &AttachmentImportData{ + Path: model.NewPointer("path/../to/attachment"), + }, + }, + { + name: "valid relative path", + data: &AttachmentImportData{ + Path: model.NewPointer("path/to/attachment"), + }, + }, + { + name: "valid relative path", + data: &AttachmentImportData{ + Path: model.NewPointer("path/to/attachment/attachment..ext"), + }, + }, + } { + t.Run(tc.name, func(t *testing.T) { + err := ValidateAttachmentImportData(tc.data) + if tc.err != "" { + require.NotNil(t, err, "Expected error but got none") + require.EqualError(t, err, tc.err, "Expected error did not match") + } else { + require.Nil(t, err, "Expected no error but got one") + } + }) + } +}
server/channels/app/import_test.go+193 −18 modified@@ -157,7 +157,7 @@ func TestImportBulkImport(t *testing.T) { username3 := model.NewUsername() emojiName := model.NewId() testsDir, _ := fileutils.FindDir("tests") - testImage := filepath.Join(testsDir, "test.png") + testImage := "test.png" teamTheme1 := `{\"awayIndicator\":\"#DBBD4E\",\"buttonBg\":\"#23A1FF\",\"buttonColor\":\"#FFFFFF\",\"centerChannelBg\":\"#ffffff\",\"centerChannelColor\":\"#333333\",\"codeTheme\":\"github\",\"image\":\"/static/files/a4a388b38b32678e83823ef1b3e17766.png\",\"linkColor\":\"#2389d7\",\"mentionBg\":\"#2389d7\",\"mentionColor\":\"#ffffff\",\"mentionHighlightBg\":\"#fff2bb\",\"mentionHighlightLink\":\"#2f81b7\",\"newMessageSeparator\":\"#FF8800\",\"onlineIndicator\":\"#7DBE00\",\"sidebarBg\":\"#fafafa\",\"sidebarHeaderBg\":\"#3481B9\",\"sidebarHeaderTextColor\":\"#ffffff\",\"sidebarText\":\"#333333\",\"sidebarTextActiveBorder\":\"#378FD2\",\"sidebarTextActiveColor\":\"#111111\",\"sidebarTextHoverBg\":\"#e6f2fa\",\"sidebarUnreadText\":\"#333333\",\"type\":\"Mattermost\"}` teamTheme2 := `{\"awayIndicator\":\"#DBBD4E\",\"buttonBg\":\"#23A100\",\"buttonColor\":\"#EEEEEE\",\"centerChannelBg\":\"#ffffff\",\"centerChannelColor\":\"#333333\",\"codeTheme\":\"github\",\"image\":\"/static/files/a4a388b38b32678e83823ef1b3e17766.png\",\"linkColor\":\"#2389d7\",\"mentionBg\":\"#2389d7\",\"mentionColor\":\"#ffffff\",\"mentionHighlightBg\":\"#fff2bb\",\"mentionHighlightLink\":\"#2f81b7\",\"newMessageSeparator\":\"#FF8800\",\"onlineIndicator\":\"#7DBE00\",\"sidebarBg\":\"#fafafa\",\"sidebarHeaderBg\":\"#3481B9\",\"sidebarHeaderTextColor\":\"#ffffff\",\"sidebarText\":\"#333333\",\"sidebarTextActiveBorder\":\"#378FD2\",\"sidebarTextActiveColor\":\"#222222\",\"sidebarTextHoverBg\":\"#e6f2fa\",\"sidebarUnreadText\":\"#444444\",\"type\":\"Mattermost\"}` @@ -178,13 +178,13 @@ func TestImportBulkImport(t *testing.T) { {"type": "direct_post", "direct_post": {"channel_members": ["` + username + `", "` + username2 + `", "` + username3 + `"], "user": "` + username + `", "message": "Hello Group Channel", "create_at": 123456789015}} {"type": "emoji", "emoji": {"name": "` + emojiName + `", "image": "` + testImage + `"}}` - line, err := th.App.BulkImport(th.Context, strings.NewReader(data1), nil, false, 2) + line, err := th.App.BulkImportWithPath(th.Context, strings.NewReader(data1), nil, false, false, 2, testsDir) require.Nil(t, err, "BulkImport should have succeeded") require.Equal(t, 0, line, "BulkImport line should be 0") // Run bulk import using a string that contains a line with invalid json. data2 := `{"type": "version", "version": 1` - line, err = th.App.BulkImport(th.Context, strings.NewReader(data2), nil, false, 2) + line, err = th.App.BulkImportWithPath(th.Context, strings.NewReader(data2), nil, false, false, 2, testsDir) require.NotNil(t, err, "Should have failed due to invalid JSON on line 1.") require.Equal(t, 1, line, "Should have failed due to invalid JSON on line 1.") @@ -193,7 +193,7 @@ func TestImportBulkImport(t *testing.T) { {"type": "channel", "channel": {"type": "O", "display_name": "xr6m6udffngark2uekvr3hoeny", "team": "` + teamName + `", "name": "` + channelName + `"}} {"type": "user", "user": {"username": "kufjgnkxkrhhfgbrip6qxkfsaa", "email": "kufjgnkxkrhhfgbrip6qxkfsaa@example.com"}} {"type": "user", "user": {"username": "bwshaim6qnc2ne7oqkd5b2s2rq", "email": "bwshaim6qnc2ne7oqkd5b2s2rq@example.com", "teams": [{"name": "` + teamName + `", "channels": [{"name": "` + channelName + `"}]}]}}` - line, err = th.App.BulkImport(th.Context, strings.NewReader(data3), nil, false, 2) + line, err = th.App.BulkImportWithPath(th.Context, strings.NewReader(data3), nil, false, false, 2, testsDir) require.NotNil(t, err, "Should have failed due to missing version line on line 1.") require.Equal(t, 1, line, "Should have failed due to missing version line on line 1.") @@ -231,6 +231,54 @@ func TestImportBulkImport(t *testing.T) { require.Nil(t, err, "BulkImport should have succeeded") require.Equal(t, 0, line, "BulkImport line should be 0") }) + + t.Run("Invalid post attachment path", func(t *testing.T) { + data7 := `{"type": "version", "version": 1} +{"type": "team", "team": {"type": "O", "display_name": "lskmw2d7a5ao7ppwqh5ljchvr4", "name": "` + teamName + `"}} +{"type": "channel", "channel": {"type": "O", "display_name": "xr6m6udffngark2uekvr3hoeny", "team": "` + teamName + `", "name": "` + channelName + `"}} +{"type": "user", "user": {"username": "` + username + `", "email": "` + username + `@example.com", "teams": [{"name": "` + teamName + `","theme": "` + teamTheme1 + `", "channels": [{"name": "` + channelName + `"}]}]}} +{"type": "user", "user": {"username": "` + username2 + `", "email": "` + username2 + `@example.com", "teams": [{"name": "` + teamName + `","theme": "` + teamTheme2 + `", "channels": [{"name": "` + channelName + `"}]}]}} +{"type": "user", "user": {"username": "` + username3 + `", "email": "` + username3 + `@example.com", "teams": [{"name": "` + teamName + `", "channels": [{"name": "` + channelName + `"}], "delete_at": 123456789016}]}} +{"type": "post", "post": {"team": "` + teamName + `", "channel": "` + channelName + `", "user": "` + username + `", "message": "Hello World", "create_at": 123456789012, "attachments":[{"path": "test.png"}]}} +{"type": "post", "post": {"team": "` + teamName + `", "channel": "` + channelName + `", "user": "` + username3 + `", "message": "Hey Everyone!", "create_at": 123456789013, "attachments":[{"path": "../test.png"}]}}` + + // Import should not fail for a single invalid attachment path. + line, err := th.App.BulkImportWithPath(th.Context, strings.NewReader(data7), nil, false, false, 2, testsDir) + require.Nil(t, err, "BulkImport should have succeeded") + require.Equal(t, 0, line, "BulkImport line should be 0") + }) + + t.Run("Invalid reply attachment path", func(t *testing.T) { + data8 := `{"type": "version", "version": 1} +{"type": "team", "team": {"type": "O", "display_name": "lskmw2d7a5ao7ppwqh5ljchvr4", "name": "` + teamName + `"}} +{"type": "channel", "channel": {"type": "O", "display_name": "xr6m6udffngark2uekvr3hoeny", "team": "` + teamName + `", "name": "` + channelName + `"}} +{"type": "user", "user": {"username": "` + username + `", "email": "` + username + `@example.com", "teams": [{"name": "` + teamName + `","theme": "` + teamTheme1 + `", "channels": [{"name": "` + channelName + `"}]}]}} +{"type": "user", "user": {"username": "` + username2 + `", "email": "` + username2 + `@example.com", "teams": [{"name": "` + teamName + `","theme": "` + teamTheme2 + `", "channels": [{"name": "` + channelName + `"}]}]}} +{"type": "user", "user": {"username": "` + username3 + `", "email": "` + username3 + `@example.com", "teams": [{"name": "` + teamName + `", "channels": [{"name": "` + channelName + `"}], "delete_at": 123456789016}]}} +{"type": "post", "post": {"team": "` + teamName + `", "channel": "` + channelName + `", "user": "` + username + `", "message": "Hello World", "create_at": 123456789012, "attachments":[{"path": "test.png"}]}} +{"type": "post", "post": {"team": "` + teamName + `", "channel": "` + channelName + `", "user": "` + username3 + `", "message": "Hey Everyone!", "create_at": 123456789013, "replies": [{"create_at": 123456789015, "user": "` + username + `", "message": "reply", "attachments":[{"path": "../test.png"}]}]}}` + + // Import should not fail for a single invalid attachment path. + line, err := th.App.BulkImportWithPath(th.Context, strings.NewReader(data8), nil, false, false, 2, testsDir) + require.Nil(t, err, "BulkImport should have succeeded") + require.Equal(t, 0, line, "BulkImport line should be 0") + }) + + t.Run("Invalid direct post attachment path", func(t *testing.T) { + data9 := `{"type": "version", "version": 1} +{"type": "team", "team": {"type": "O", "display_name": "lskmw2d7a5ao7ppwqh5ljchvr4", "name": "` + teamName + `"}} +{"type": "channel", "channel": {"type": "O", "display_name": "xr6m6udffngark2uekvr3hoeny", "team": "` + teamName + `", "name": "` + channelName + `"}} +{"type": "user", "user": {"username": "` + username + `", "email": "` + username + `@example.com", "teams": [{"name": "` + teamName + `","theme": "` + teamTheme1 + `", "channels": [{"name": "` + channelName + `"}]}]}} +{"type": "user", "user": {"username": "` + username2 + `", "email": "` + username2 + `@example.com", "teams": [{"name": "` + teamName + `","theme": "` + teamTheme2 + `", "channels": [{"name": "` + channelName + `"}]}]}} +{"type": "user", "user": {"username": "` + username3 + `", "email": "` + username3 + `@example.com", "teams": [{"name": "` + teamName + `", "channels": [{"name": "` + channelName + `"}], "delete_at": 123456789016}]}} +{"type": "direct_channel", "direct_channel": {"members": ["` + username + `", "` + username + `"]}} +{"type": "direct_post", "direct_post": {"channel_members": ["` + username + `", "` + username + `"], "user": "` + username + `", "message": "Hello Direct Channel to myself", "create_at": 123456789014, "attachments":[{"path": "../test.png"}]}}` + + // Import should not fail for a single invalid attachment path. + line, err := th.App.BulkImportWithPath(th.Context, strings.NewReader(data9), nil, false, false, 2, testsDir) + require.Nil(t, err, "BulkImport should have succeeded") + require.Equal(t, 0, line, "BulkImport line should be 0") + }) } func TestImportProcessImportDataFileVersionLine(t *testing.T) { @@ -275,6 +323,133 @@ func AssertFileIdsInPost(files []*model.FileInfo, th *TestHelper, t *testing.T) } } +func TestProcessAttachmentPaths(t *testing.T) { + c := request.TestContext(t) + + t.Run("nil attachments", func(t *testing.T) { + err := processAttachmentPaths(c, nil, "", nil) + require.NoError(t, err) + }) + + t.Run("missing file in map", func(t *testing.T) { + attachments := &[]imports.AttachmentImportData{ + { + Path: model.NewPointer("file.jpg"), + }, + } + + filesMap := map[string]*zip.File{ + "./import/other-file.jpg": nil, + } + + err := processAttachmentPaths(c, attachments, "", filesMap) + require.Error(t, err) + require.EqualError(t, err, "attachment \"file.jpg\" not found in map") + }) + + t.Run("valid paths", func(t *testing.T) { + attachments := &[]imports.AttachmentImportData{ + { + Path: model.NewPointer("file.jpg"), + }, + { + Path: model.NewPointer("somedir/file.jpg"), + }, + { + Path: model.NewPointer("./someotherdir/file.jpg"), + }, + } + + expected := &[]imports.AttachmentImportData{ + { + Path: model.NewPointer("data/file.jpg"), + }, + { + Path: model.NewPointer("data/somedir/file.jpg"), + }, + { + Path: model.NewPointer("data/someotherdir/file.jpg"), + }, + } + + err := processAttachmentPaths(c, attachments, model.ExportDataDir, nil) + require.NoError(t, err) + require.Equal(t, expected, attachments) + }) + + t.Run("uncleaned paths", func(t *testing.T) { + attachments := &[]imports.AttachmentImportData{ + { + Path: model.NewPointer("../dir/invalid.txt"), + }, + { + Path: model.NewPointer("somedir/./normal-file.jpg"), + }, + } + + expected := &[]imports.AttachmentImportData{ + { + Path: model.NewPointer("/path/to/import/dir/invalid.txt"), + }, + { + Path: model.NewPointer("/path/to/import/dir/somedir/normal-file.jpg"), + }, + } + + err := processAttachmentPaths(c, attachments, "/path/to/import/dir", nil) + require.NoError(t, err) + require.Equal(t, expected, attachments) + }) + + t.Run("paths outside base path", func(t *testing.T) { + attachments := &[]imports.AttachmentImportData{ + { + Path: model.NewPointer("../../invalid.txt"), + }, + { + Path: model.NewPointer("../../../invalid.txt"), + }, + } + + expected := &[]imports.AttachmentImportData{ + { + Path: model.NewPointer(""), + }, + { + Path: model.NewPointer(""), + }, + } + + err := processAttachmentPaths(c, attachments, "data", nil) + require.EqualError(t, err, "invalid attachment path \"../../invalid.txt\"\ninvalid attachment path \"../../../invalid.txt\"") + require.Equal(t, expected, attachments) + }) + + t.Run("mix of valid and invalid paths", func(t *testing.T) { + attachments := &[]imports.AttachmentImportData{ + { + Path: model.NewPointer("../../invalid.txt"), + }, + { + Path: model.NewPointer("valid/path/to/file"), + }, + } + + expected := &[]imports.AttachmentImportData{ + { + Path: model.NewPointer(""), + }, + { + Path: model.NewPointer("data/valid/path/to/file"), + }, + } + + err := processAttachmentPaths(c, attachments, "data", nil) + require.EqualError(t, err, "invalid attachment path \"../../invalid.txt\"") + require.Equal(t, expected, attachments) + }) +} + func TestProcessAttachments(t *testing.T) { c := request.TestContext(t) @@ -340,35 +515,35 @@ func TestProcessAttachments(t *testing.T) { t.Run("valid path", func(t *testing.T) { expected := &[]imports.AttachmentImportData{ { - Path: model.NewPointer("/tmp/file.jpg"), + Path: model.NewPointer("tmp/file.jpg"), }, { - Path: model.NewPointer("/tmp/somedir/file.jpg"), + Path: model.NewPointer("tmp/somedir/file.jpg"), }, } t.Run("post attachments", func(t *testing.T) { - err := processAttachments(c, &line, "/tmp", nil) + err := processAttachments(c, &line, "tmp", nil) require.NoError(t, err) require.Equal(t, expected, line.Post.Attachments) }) t.Run("direct post attachments", func(t *testing.T) { - err := processAttachments(c, &line2, "/tmp", nil) + err := processAttachments(c, &line2, "tmp", nil) require.NoError(t, err) require.Equal(t, expected, line2.DirectPost.Attachments) }) t.Run("profile image", func(t *testing.T) { - expected := "/tmp/profile.jpg" - err := processAttachments(c, &userLine, "/tmp", nil) + expected := "tmp/profile.jpg" + err := processAttachments(c, &userLine, "tmp", nil) require.NoError(t, err) require.Equal(t, expected, *userLine.User.ProfileImage) }) t.Run("emoji", func(t *testing.T) { - expected := "/tmp/emoji.png" - err := processAttachments(c, &emojiLine, "/tmp", nil) + expected := "tmp/emoji.png" + err := processAttachments(c, &emojiLine, "tmp", nil) require.NoError(t, err) require.Equal(t, expected, *emojiLine.Emoji.Image) }) @@ -377,24 +552,24 @@ func TestProcessAttachments(t *testing.T) { t.Run("with filesMap", func(t *testing.T) { t.Run("post attachments", func(t *testing.T) { filesMap := map[string]*zip.File{ - "/tmp/file.jpg": nil, + "tmp/file.jpg": nil, } err := processAttachments(c, &line, "", filesMap) require.Error(t, err) - filesMap["/tmp/somedir/file.jpg"] = nil + filesMap["tmp/somedir/file.jpg"] = nil err = processAttachments(c, &line, "", filesMap) require.NoError(t, err) }) t.Run("direct post attachments", func(t *testing.T) { filesMap := map[string]*zip.File{ - "/tmp/file.jpg": nil, + "tmp/file.jpg": nil, } err := processAttachments(c, &line2, "", filesMap) require.Error(t, err) - filesMap["/tmp/somedir/file.jpg"] = nil + filesMap["tmp/somedir/file.jpg"] = nil err = processAttachments(c, &line2, "", filesMap) require.NoError(t, err) }) @@ -406,7 +581,7 @@ func TestProcessAttachments(t *testing.T) { err := processAttachments(c, &userLine, "", filesMap) require.Error(t, err) - filesMap["/tmp/profile.jpg"] = nil + filesMap["tmp/profile.jpg"] = nil err = processAttachments(c, &userLine, "", filesMap) require.NoError(t, err) }) @@ -418,7 +593,7 @@ func TestProcessAttachments(t *testing.T) { err := processAttachments(c, &emojiLine, "", filesMap) require.Error(t, err) - filesMap["/tmp/emoji.png"] = nil + filesMap["tmp/emoji.png"] = nil err = processAttachments(c, &emojiLine, "", filesMap) require.NoError(t, err) })
server/cmd/mmctl/commands/import_test.go+60 −0 modified@@ -475,4 +475,64 @@ func (s *MmctlUnitTestSuite) TestImportValidateCmdF() { s.Require().Empty(res.Errors) s.Equal("Validation complete\n", printer.GetLines()[2]) }) + + s.Run("invalid file attachment path", func() { + file, err := os.Create(importFilePath) + s.Require().NoError(err) + + zipWr := zip.NewWriter(file) + wr, err := zipWr.Create("import.jsonl") + s.Require().NoError(err) + + _, err = wr.Write([]byte(importBase)) + s.Require().NoError(err) + + _, err = wr.Write([]byte(` +{"type":"post","post":{"team":"ad-1","channel":"iusto-9","user":"ashley.berry","message":"message","props":{},"create_at":1603398068740,"reactions":null,"replies":null,"attachments":[{"path": "data/../../invalid.jpg"}]}}`)) + s.Require().NoError(err) + + err = zipWr.Close() + s.Require().NoError(err) + + err = file.Close() + s.Require().NoError(err) + + printer.Clean() + + s.client. + EXPECT(). + GetUsers(context.TODO(), 0, 200, ""). + Return(nil, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetAllTeams(context.TODO(), "", 0, 200). + Return(nil, &model.Response{}, nil). + Times(1) + + s.client. + EXPECT(). + GetOldClientConfig(context.TODO(), ""). + Return(map[string]string{ + "MaxPostSize": fmt.Sprintf("%d", model.PostMessageMaxRunesV2*2), + }, &model.Response{}, nil). + Times(1) + + err = importValidateCmdF(s.client, ImportValidateCmd, []string{importFilePath}) + s.Require().Nil(err) + + s.Empty(printer.GetErrorLines()) + s.Equal(Statistics{ + Teams: 2, + Channels: 1, + DirectChannels: 1, + Users: 1, + Posts: 1, + }, printer.GetLines()[0].(Statistics)) + res := printer.GetLines()[1].(ImportValidationResult) + s.Require().Len(res.Errors, 2) + s.Require().Equal("app.import.validate_post_import_data.attachment.error", res.Errors[0].Err.(*model.AppError).Id) + s.Equal("Validation complete\n", printer.GetLines()[2]) + }) }
server/i18n/en.json+24 −0 modified@@ -5614,6 +5614,10 @@ "id": "app.import.profile_image.read_data.app_error", "translation": "Failed to read profile image data." }, + { + "id": "app.import.validate_attachment_import_data.invalid_path.error", + "translation": "Failed to validate attachment import data. Invalid path: \"{{.Path}}\"" + }, { "id": "app.import.validate_bot_import_data.owner_missing.error", "translation": "Bot owner is missing" @@ -5678,6 +5682,10 @@ "id": "app.import.validate_direct_channel_import_data.unknown_favoriter.error", "translation": "Direct channel can only be favorited by members. \"{{.Username}}\" is not a member." }, + { + "id": "app.import.validate_direct_post_import_data.attachment.error", + "translation": "Failed to validate direct post attachment data." + }, { "id": "app.import.validate_direct_post_import_data.channel_members_required.error", "translation": "Missing required direct post property: channel_members" @@ -5722,10 +5730,18 @@ "id": "app.import.validate_emoji_import_data.image_missing.error", "translation": "Import emoji image field missing or blank." }, + { + "id": "app.import.validate_emoji_import_data.invalid_image_path.error", + "translation": "Import emoji image field has an invalid path: \"{{.Path}}\"" + }, { "id": "app.import.validate_emoji_import_data.name_missing.error", "translation": "Import emoji name field missing or blank." }, + { + "id": "app.import.validate_post_import_data.attachment.error", + "translation": "Failed to validate post attachment data." + }, { "id": "app.import.validate_post_import_data.channel_missing.error", "translation": "Missing required Post property: Channel." @@ -5782,6 +5798,10 @@ "id": "app.import.validate_reaction_import_data.user_missing.error", "translation": "Missing required Reaction property: User." }, + { + "id": "app.import.validate_reply_import_data.attachment.error", + "translation": "Failed to validate reply attachment data." + }, { "id": "app.import.validate_reply_import_data.create_at_missing.error", "translation": "Missing required Reply property: create_at." @@ -5946,6 +5966,10 @@ "id": "app.import.validate_user_import_data.guest_roles_conflict.error", "translation": "User roles are not consistent with guest status." }, + { + "id": "app.import.validate_user_import_data.invalid_image_path.error", + "translation": "User profile image path is invalid: \"{{.Path}}\"" + }, { "id": "app.import.validate_user_import_data.last_name_length.error", "translation": "User Last Name is too long."
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
4News mentions
0No linked articles in our index yet.