[MM-64402] Improve validation of imported attachments (#31201)

* Improve validation of imported attachments

* Simplify multiple errors handling

* Improve logic

* Fix abs paths in tests

* Remove redundant clean

* Implement additional validation

* Fix absolute paths in test

* Add additional tests

---------

Co-authored-by: Lorenzo Gallegos <1328683+enzowritescode@users.noreply.github.com>
Этот коммит содержится в:
Claudio Costa
2025-05-28 23:44:50 -06:00
коммит произвёл GitHub
родитель 07239a5217
Коммит d38c27f96f
7 изменённых файлов: 773 добавлений и 32 удалений

Просмотреть файл

@@ -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 {

Просмотреть файл

@@ -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 {

Просмотреть файл

@@ -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)
})

Просмотреть файл

@@ -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
}

Просмотреть файл

@@ -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")
}
})
}
}

Просмотреть файл

@@ -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])
})
}

Просмотреть файл

@@ -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."