From ad7f230f066ac463fec92ce2b18cf65e60a6afbf Mon Sep 17 00:00:00 2001 From: Felipe Martin Date: Wed, 4 Mar 2026 19:30:52 +0100 Subject: [PATCH] fix: only match root-level JSONL files when importing a zip When importing a Mattermost export zip, the code iterated over all files to find the first .jsonl by extension. Exported attachments under data/ could themselves be .jsonl files, causing the import to pick an attachment as the manifest instead of the actual root-level JSONL file. Extract an IsRootJsonlFile helper in the imports package and use it in the import process worker, mmctl validator, and bulk import test to restrict the search to files with no directory component. --- server/channels/app/import_test.go | 2 +- .../channels/app/imports/import_validators.go | 8 +++++ .../app/imports/import_validators_test.go | 12 +++++++ server/channels/jobs/import_process/worker.go | 34 +++++++++---------- .../cmd/mmctl/commands/importer/validate.go | 8 ++--- 5 files changed, 41 insertions(+), 23 deletions(-) diff --git a/server/channels/app/import_test.go b/server/channels/app/import_test.go index e7ee9e09fbe..6f2adb9d070 100644 --- a/server/channels/app/import_test.go +++ b/server/channels/app/import_test.go @@ -650,7 +650,7 @@ func TestImportBulkImportWithAttachments(t *testing.T) { var jsonFile io.ReadCloser for _, f := range importZipReader.File { - if filepath.Ext(f.Name) != ".jsonl" { + if !imports.IsRootJsonlFile(f.Name) { continue } diff --git a/server/channels/app/imports/import_validators.go b/server/channels/app/imports/import_validators.go index fac52335d63..7aee8378943 100644 --- a/server/channels/app/imports/import_validators.go +++ b/server/channels/app/imports/import_validators.go @@ -16,6 +16,14 @@ import ( "github.com/mattermost/mattermost/server/public/shared/mlog" ) +// IsRootJsonlFile reports whether the given zip entry name refers to a .jsonl +// file at the root of the archive (no directory component). This is used to +// locate the import manifest while ignoring .jsonl files that may exist as +// exported attachments in subdirectories. +func IsRootJsonlFile(name string) bool { + return filepath.Ext(name) == ".jsonl" && filepath.Dir(name) == "." +} + func ValidateSchemeImportData(data *SchemeImportData) *model.AppError { if data.Scope == nil { return model.NewAppError("BulkImport", "app.import.validate_scheme_import_data.null_scope.error", nil, "", http.StatusBadRequest) diff --git a/server/channels/app/imports/import_validators_test.go b/server/channels/app/imports/import_validators_test.go index a046d0db4a5..09ce78b906c 100644 --- a/server/channels/app/imports/import_validators_test.go +++ b/server/channels/app/imports/import_validators_test.go @@ -16,6 +16,18 @@ import ( "github.com/mattermost/mattermost/server/v8/channels/utils/fileutils" ) +func TestIsRootJsonlFile(t *testing.T) { + assert.True(t, IsRootJsonlFile("import.jsonl")) + assert.True(t, IsRootJsonlFile("export.jsonl")) + assert.False(t, IsRootJsonlFile("data/export.jsonl")) + assert.False(t, IsRootJsonlFile("data/attachments/report.jsonl")) + assert.False(t, IsRootJsonlFile("data/deep/nested/file.jsonl")) + assert.False(t, IsRootJsonlFile("import.json")) + assert.False(t, IsRootJsonlFile("import.zip")) + assert.False(t, IsRootJsonlFile("data/photo.jpg")) + assert.False(t, IsRootJsonlFile("")) +} + func TestImportValidateSchemeImportData(t *testing.T) { // Test with minimum required valid properties and team scope. data := SchemeImportData{ diff --git a/server/channels/jobs/import_process/worker.go b/server/channels/jobs/import_process/worker.go index 1202f783dda..f9e974b8405 100644 --- a/server/channels/jobs/import_process/worker.go +++ b/server/channels/jobs/import_process/worker.go @@ -19,6 +19,7 @@ import ( "github.com/mattermost/mattermost/server/public/shared/configservice" "github.com/mattermost/mattermost/server/public/shared/mlog" "github.com/mattermost/mattermost/server/public/shared/request" + "github.com/mattermost/mattermost/server/v8/channels/app/imports" "github.com/mattermost/mattermost/server/v8/channels/jobs" "github.com/mattermost/mattermost/server/v8/platform/shared/filestore" ) @@ -100,29 +101,28 @@ func MakeWorker(jobServer *jobs.JobServer, app AppIface) *jobs.SimpleWorker { } // find JSONL import file. - var jsonFile io.ReadCloser + var jsonZipFile *zip.File for _, f := range importZipReader.File { - if filepath.Ext(f.Name) != ".jsonl" { - continue + if imports.IsRootJsonlFile(f.Name) { + jsonZipFile = f + break } - // avoid "zip slip" - if strings.Contains(f.Name, "..") { - return model.NewAppError("ImportProcessWorker", "import_process.worker.do_job.open_file", nil, "jsonFilePath contains path traversal", http.StatusForbidden) - } - - jsonFile, err = f.Open() - if err != nil { - return model.NewAppError("ImportProcessWorker", "import_process.worker.do_job.open_file", nil, "", http.StatusInternalServerError).Wrap(err) - } - - defer jsonFile.Close() - break } - - if jsonFile == nil { + if jsonZipFile == nil { return model.NewAppError("ImportProcessWorker", "import_process.worker.do_job.missing_jsonl", nil, "jsonFile was nil", http.StatusBadRequest) } + // avoid "zip slip" + if strings.Contains(jsonZipFile.Name, "..") { + return model.NewAppError("ImportProcessWorker", "import_process.worker.do_job.open_file", nil, "jsonFilePath contains path traversal", http.StatusForbidden) + } + + jsonFile, err := jsonZipFile.Open() + if err != nil { + return model.NewAppError("ImportProcessWorker", "import_process.worker.do_job.open_file", nil, "", http.StatusInternalServerError).Wrap(err) + } + defer jsonFile.Close() + extractContent := job.Data["extract_content"] == "true" // do the actual import. lineNumber, appErr := app.BulkImportWithPath(appContext, jsonFile, importZipReader, false, extractContent, runtime.NumCPU(), model.ExportDataDir) diff --git a/server/cmd/mmctl/commands/importer/validate.go b/server/cmd/mmctl/commands/importer/validate.go index e8192b70974..64d1b85f9fa 100644 --- a/server/cmd/mmctl/commands/importer/validate.go +++ b/server/cmd/mmctl/commands/importer/validate.go @@ -248,12 +248,10 @@ func (v *Validator) Validate() error { var jsonlZip *zip.File for _, zfile := range z.File { - if filepath.Ext(zfile.Name) != ".jsonl" { - continue + if imports.IsRootJsonlFile(zfile.Name) { + jsonlZip = zfile + break } - - jsonlZip = zfile - break } if jsonlZip == nil { return fmt.Errorf("could not find a .jsonl file in the import archive")