CVE-2026-6961
Description
A path traversal flaw in Mattermost's federated file sync lets an attacker on a controlled server write files arbitrarily.
AI Insight
LLM-synthesized narrative grounded in this CVE's description and references.
A path traversal flaw in Mattermost's federated file sync lets an attacker on a controlled server write files arbitrarily.
Vulnerability
An improper sanitization of the FileInfo.Name field received from federated peers during shared channel file sync allows path traversal attacks. Mattermost versions 11.6.x up to 11.6.1, 11.5.x up to 11.5.4, and 10.11.x up to 10.11.15 and 10.11.16 are affected [1].
Exploitation
An attacker who controls a federated server can craft a malicious filename containing path traversal sequences (e.g., ../). When the target server syncs a shared channel file from that peer, the unsanitized filename is used directly to write to the filestore, bypassing intended directory restrictions [1].
Impact
Successful exploitation allows the attacker to write files to arbitrary locations within the target server's filestore. This can lead to unauthorized file modification, potential code execution if writable paths include web-accessible directories, or other localized integrity and confidentiality impacts depending on the server's configuration [1].
Mitigation
Mattermost released security updates to address this vulnerability; administrators should upgrade to versions 11.6.2, 11.5.5, 10.11.17, or later [1]. No workarounds are documented in the available reference, and the issue is not listed on CISA's KEV as of publication.
AI Insight generated on Jun 12, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.
Affected products
1- Range: <=11.6.1, <=11.5.4, <=10.11.16
Patches
5a9f3868e1eeeMM-68439 Centralize filename handling for FileInfo (#36223) (#36253)
5 files changed · +173 −2
server/channels/app/upload.go+12 −1 modified@@ -23,6 +23,11 @@ import ( const minFirstPartSize = 5 * 1024 * 1024 // 5MB func (a *App) genFileInfoFromReader(name string, file io.ReadSeeker, size int64) (*model.FileInfo, error) { + name = model.SanitizeFilename(name) + if name == "" { + return nil, model.NewAppError("genFileInfoFromReader", "app.upload.gen_file_info.invalid_filename.app_error", nil, "", http.StatusBadRequest) + } + ext := strings.ToLower(filepath.Ext(name)) info := &model.FileInfo{ @@ -285,7 +290,13 @@ func (a *App) UploadData(rctx request.CTX, us *model.UploadSession, rd io.Reader info, genErr := a.genFileInfoFromReader(us.Filename, file, us.FileSize) file.Close() if genErr != nil { - return nil, model.NewAppError("UploadData", "app.upload.upload_data.gen_info.app_error", nil, "", http.StatusInternalServerError).Wrap(genErr) + var appErr *model.AppError + switch { + case errors.As(genErr, &appErr): + return nil, appErr + default: + return nil, model.NewAppError("UploadData", "app.upload.upload_data.gen_info.app_error", nil, "", http.StatusInternalServerError).Wrap(genErr) + } } info.CreatorId = us.UserId
server/channels/store/searchtest/file_info_layer.go+1 −1 modified@@ -1621,7 +1621,7 @@ func testFileInfoSlashShouldNotBeCharSeparator(t *testing.T, th *SearchTestHelpe require.NoError(t, err) defer th.deleteUserPosts(th.User.Id) - p1, err := th.createFileInfo(th.User.Id, post.Id, post.ChannelId, "alpha/beta gamma, theta", "alpha/beta gamma, theta", "jpg", "image/jpeg", 0, 0) + p1, err := th.createFileInfo(th.User.Id, post.Id, post.ChannelId, "testfile.jpg", "alpha/beta gamma, theta", "jpg", "image/jpeg", 0, 0) require.NoError(t, err) defer th.deleteUserFileInfos(th.User.Id)
server/i18n/en.json+8 −0 modified@@ -8136,6 +8136,10 @@ "id": "app.upload.create.save.app_error", "translation": "Failed to save upload." }, + { + "id": "app.upload.gen_file_info.invalid_filename.app_error", + "translation": "Invalid filename." + }, { "id": "app.upload.get.app_error", "translation": "Failed to get upload." @@ -10728,6 +10732,10 @@ "id": "model.file_info.is_valid.id.app_error", "translation": "Invalid value for id." }, + { + "id": "model.file_info.is_valid.name.app_error", + "translation": "Invalid value for name." + }, { "id": "model.file_info.is_valid.path.app_error", "translation": "Invalid value for path."
server/public/model/file_info.go+66 −0 modified@@ -8,11 +8,19 @@ import ( "net/http" "path/filepath" "strings" + "unicode/utf8" + + "golang.org/x/text/unicode/norm" ) const ( FileinfoSortByCreated = "CreateAt" FileinfoSortBySize = "Size" + + // MaxFilenameLength is the maximum length, in Unicode codepoints, of a + // sanitized FileInfo.Name. It matches the VARCHAR(256) width of the + // fileinfo.name column. + MaxFilenameLength = 256 ) // GetFileInfosOptions contains options for getting FileInfos @@ -117,9 +125,67 @@ func (fi *FileInfo) IsValid() *AppError { return NewAppError("FileInfo.IsValid", "model.file_info.is_valid.path.app_error", nil, "id="+fi.Id, http.StatusBadRequest) } + if fi.Name != "" && !IsValidFilename(fi.Name) { + return NewAppError("FileInfo.IsValid", "model.file_info.is_valid.name.app_error", nil, "id="+fi.Id, http.StatusBadRequest) + } + return nil } +// IsValidFilename reports whether name is acceptable as FileInfo.Name. +// It rejects empty strings, bare "." and "..", names exceeding +// MaxFilenameLength, path separators, and ASCII control characters. +// The input is not mutated; see SanitizeFilename for the mutating form. +func IsValidFilename(name string) bool { + if name == "" || name == "." || name == ".." { + return false + } + if utf8.RuneCountInString(name) > MaxFilenameLength { + return false + } + if strings.ContainsAny(name, `/\`) { + return false + } + return !strings.ContainsFunc(name, func(r rune) bool { + return r < 0x20 || r == 0x7f + }) +} + +// SanitizeFilename returns a canonical form of name suitable for +// FileInfo.Name. It NFC-normalizes Unicode, removes ASCII control +// characters, collapses backslashes to forward slashes, reduces the +// value to its final path element via filepath.Base, and truncates +// to MaxFilenameLength codepoints to match the DB column width. +// +// Returns an empty string when nothing usable remains (for example +// when the input was "", ".", "..", "/", or entirely control +// characters); callers should treat an empty result as a failure. +func SanitizeFilename(name string) string { + if name == "" { + return "" + } + + name = norm.NFC.String(name) + name = strings.Map(func(r rune) rune { + if r < 0x20 || r == 0x7f { + return -1 + } + return r + }, name) + name = strings.ReplaceAll(name, `\`, "/") + name = filepath.Base(name) + + if name == "." || name == ".." || name == string(filepath.Separator) { + return "" + } + + if runes := []rune(name); len(runes) > MaxFilenameLength { + name = string(runes[:MaxFilenameLength]) + } + + return name +} + func (fi *FileInfo) IsImage() bool { return strings.HasPrefix(fi.MimeType, "image") }
server/public/model/file_info_test.go+86 −0 modified@@ -6,6 +6,7 @@ package model import ( _ "image/gif" _ "image/png" + "strings" "testing" "github.com/stretchr/testify/assert" @@ -60,6 +61,91 @@ func TestFileInfoIsValid(t *testing.T) { assert.Nil(t, info.IsValid(), "creatorId isn't valid") info.CreatorId = creatorId }) + + t.Run("Empty Name is valid", func(t *testing.T) { + info.Name = "" + assert.Nil(t, info.IsValid()) + }) + + t.Run("Non-empty Name must be a plain filename", func(t *testing.T) { + originalName := info.Name + defer func() { info.Name = originalName }() + + badNames := []string{ + ".", + "..", + "../a.png", + `..\..\a.png`, + "foo/bar.png", + `foo\bar.png`, + "foo\x00.png", + } + for _, bad := range badNames { + info.Name = bad + assert.NotNilf(t, info.IsValid(), "expected %q to be rejected", bad) + } + }) +} + +func TestIsValidFilename(t *testing.T) { + cases := []struct { + name string + valid bool + }{ + {"hello.png", true}, + {"hello world (1).png", true}, + {"日本語.txt", true}, + {"", false}, + {".", false}, + {"..", false}, + {"../a.png", false}, + {`..\..\a`, false}, + {"a/b", false}, + {`foo\bar.png`, false}, + {"a\x00b", false}, + {"foo\tbar.png", false}, + {"foo\rbar.png", false}, + // MaxFilenameLength matches the VARCHAR(256) column; longer inputs + // that bypass SanitizeFilename's truncation must still fail here. + {strings.Repeat("a", MaxFilenameLength+1), false}, + {strings.Repeat("a", MaxFilenameLength), true}, + } + for _, tc := range cases { + assert.Equalf(t, tc.valid, IsValidFilename(tc.name), "input %q", tc.name) + } +} + +func TestSanitizeFilename(t *testing.T) { + cases := []struct { + name string + in string + want string + }{ + {"plain name unchanged", "hello.png", "hello.png"}, + {"preserves spaces and parens", "hello world (1).png", "hello world (1).png"}, + {"reduces leading dotdot path to basename", "../../a.png", "a.png"}, + {"handles backslash separators", `..\..\a.exe`, "a.exe"}, + {"reduces nested path to basename", "a/b/c.png", "c.png"}, + {"strips null bytes", "foo\x00bar.png", "foobar.png"}, + {"strips control chars", "foo\tbar\x1f.png", "foobar.png"}, + {"rejects bare dotdot", "..", ""}, + {"rejects bare dot", ".", ""}, + {"rejects empty", "", ""}, + {"rejects root", "/", ""}, + {"rejects path ending in separator", "../", ""}, + {"truncates to max length by runes", strings.Repeat("a", MaxFilenameLength+50), strings.Repeat("a", MaxFilenameLength)}, + {"NFC-normalizes NFD input", "ガ.txt", "ガ.txt"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := SanitizeFilename(tc.in) + assert.Equal(t, tc.want, got) + if got != "" { + // SanitizeFilename output must always satisfy IsValidFilename. + assert.True(t, IsValidFilename(got), "sanitized output %q must be valid", got) + } + }) + } } func TestFileInfoIsImage(t *testing.T) {
c896a63dc44cMM-68439 Centralize filename handling for FileInfo (#36223) (#36252)
5 files changed · +173 −2
server/channels/app/upload.go+12 −1 modified@@ -23,6 +23,11 @@ import ( const minFirstPartSize = 5 * 1024 * 1024 // 5MB func (a *App) genFileInfoFromReader(name string, file io.ReadSeeker, size int64) (*model.FileInfo, error) { + name = model.SanitizeFilename(name) + if name == "" { + return nil, model.NewAppError("genFileInfoFromReader", "app.upload.gen_file_info.invalid_filename.app_error", nil, "", http.StatusBadRequest) + } + ext := strings.ToLower(filepath.Ext(name)) info := &model.FileInfo{ @@ -285,7 +290,13 @@ func (a *App) UploadData(rctx request.CTX, us *model.UploadSession, rd io.Reader info, genErr := a.genFileInfoFromReader(us.Filename, file, us.FileSize) file.Close() if genErr != nil { - return nil, model.NewAppError("UploadData", "app.upload.upload_data.gen_info.app_error", nil, "", http.StatusInternalServerError).Wrap(genErr) + var appErr *model.AppError + switch { + case errors.As(genErr, &appErr): + return nil, appErr + default: + return nil, model.NewAppError("UploadData", "app.upload.upload_data.gen_info.app_error", nil, "", http.StatusInternalServerError).Wrap(genErr) + } } info.CreatorId = us.UserId
server/channels/store/searchtest/file_info_layer.go+1 −1 modified@@ -1621,7 +1621,7 @@ func testFileInfoSlashShouldNotBeCharSeparator(t *testing.T, th *SearchTestHelpe require.NoError(t, err) defer th.deleteUserPosts(th.User.Id) - p1, err := th.createFileInfo(th.User.Id, post.Id, post.ChannelId, "alpha/beta gamma, theta", "alpha/beta gamma, theta", "jpg", "image/jpeg", 0, 0) + p1, err := th.createFileInfo(th.User.Id, post.Id, post.ChannelId, "testfile.jpg", "alpha/beta gamma, theta", "jpg", "image/jpeg", 0, 0) require.NoError(t, err) defer th.deleteUserFileInfos(th.User.Id)
server/i18n/en.json+8 −0 modified@@ -8196,6 +8196,10 @@ "id": "app.upload.create.save.app_error", "translation": "Failed to save upload." }, + { + "id": "app.upload.gen_file_info.invalid_filename.app_error", + "translation": "Invalid filename." + }, { "id": "app.upload.get.app_error", "translation": "Failed to get upload." @@ -10796,6 +10800,10 @@ "id": "model.file_info.is_valid.id.app_error", "translation": "Invalid value for id." }, + { + "id": "model.file_info.is_valid.name.app_error", + "translation": "Invalid value for name." + }, { "id": "model.file_info.is_valid.path.app_error", "translation": "Invalid value for path."
server/public/model/file_info.go+66 −0 modified@@ -8,11 +8,19 @@ import ( "net/http" "path/filepath" "strings" + "unicode/utf8" + + "golang.org/x/text/unicode/norm" ) const ( FileinfoSortByCreated = "CreateAt" FileinfoSortBySize = "Size" + + // MaxFilenameLength is the maximum length, in Unicode codepoints, of a + // sanitized FileInfo.Name. It matches the VARCHAR(256) width of the + // fileinfo.name column. + MaxFilenameLength = 256 ) // FileDownloadType represents the type of file download or access being performed. @@ -131,9 +139,67 @@ func (fi *FileInfo) IsValid() *AppError { return NewAppError("FileInfo.IsValid", "model.file_info.is_valid.path.app_error", nil, "id="+fi.Id, http.StatusBadRequest) } + if fi.Name != "" && !IsValidFilename(fi.Name) { + return NewAppError("FileInfo.IsValid", "model.file_info.is_valid.name.app_error", nil, "id="+fi.Id, http.StatusBadRequest) + } + return nil } +// IsValidFilename reports whether name is acceptable as FileInfo.Name. +// It rejects empty strings, bare "." and "..", names exceeding +// MaxFilenameLength, path separators, and ASCII control characters. +// The input is not mutated; see SanitizeFilename for the mutating form. +func IsValidFilename(name string) bool { + if name == "" || name == "." || name == ".." { + return false + } + if utf8.RuneCountInString(name) > MaxFilenameLength { + return false + } + if strings.ContainsAny(name, `/\`) { + return false + } + return !strings.ContainsFunc(name, func(r rune) bool { + return r < 0x20 || r == 0x7f + }) +} + +// SanitizeFilename returns a canonical form of name suitable for +// FileInfo.Name. It NFC-normalizes Unicode, removes ASCII control +// characters, collapses backslashes to forward slashes, reduces the +// value to its final path element via filepath.Base, and truncates +// to MaxFilenameLength codepoints to match the DB column width. +// +// Returns an empty string when nothing usable remains (for example +// when the input was "", ".", "..", "/", or entirely control +// characters); callers should treat an empty result as a failure. +func SanitizeFilename(name string) string { + if name == "" { + return "" + } + + name = norm.NFC.String(name) + name = strings.Map(func(r rune) rune { + if r < 0x20 || r == 0x7f { + return -1 + } + return r + }, name) + name = strings.ReplaceAll(name, `\`, "/") + name = filepath.Base(name) + + if name == "." || name == ".." || name == string(filepath.Separator) { + return "" + } + + if runes := []rune(name); len(runes) > MaxFilenameLength { + name = string(runes[:MaxFilenameLength]) + } + + return name +} + func (fi *FileInfo) IsImage() bool { return strings.HasPrefix(fi.MimeType, "image") }
server/public/model/file_info_test.go+86 −0 modified@@ -6,6 +6,7 @@ package model import ( _ "image/gif" _ "image/png" + "strings" "testing" "github.com/stretchr/testify/assert" @@ -60,6 +61,91 @@ func TestFileInfoIsValid(t *testing.T) { assert.Nil(t, info.IsValid(), "creatorId isn't valid") info.CreatorId = creatorId }) + + t.Run("Empty Name is valid", func(t *testing.T) { + info.Name = "" + assert.Nil(t, info.IsValid()) + }) + + t.Run("Non-empty Name must be a plain filename", func(t *testing.T) { + originalName := info.Name + defer func() { info.Name = originalName }() + + badNames := []string{ + ".", + "..", + "../a.png", + `..\..\a.png`, + "foo/bar.png", + `foo\bar.png`, + "foo\x00.png", + } + for _, bad := range badNames { + info.Name = bad + assert.NotNilf(t, info.IsValid(), "expected %q to be rejected", bad) + } + }) +} + +func TestIsValidFilename(t *testing.T) { + cases := []struct { + name string + valid bool + }{ + {"hello.png", true}, + {"hello world (1).png", true}, + {"日本語.txt", true}, + {"", false}, + {".", false}, + {"..", false}, + {"../a.png", false}, + {`..\..\a`, false}, + {"a/b", false}, + {`foo\bar.png`, false}, + {"a\x00b", false}, + {"foo\tbar.png", false}, + {"foo\rbar.png", false}, + // MaxFilenameLength matches the VARCHAR(256) column; longer inputs + // that bypass SanitizeFilename's truncation must still fail here. + {strings.Repeat("a", MaxFilenameLength+1), false}, + {strings.Repeat("a", MaxFilenameLength), true}, + } + for _, tc := range cases { + assert.Equalf(t, tc.valid, IsValidFilename(tc.name), "input %q", tc.name) + } +} + +func TestSanitizeFilename(t *testing.T) { + cases := []struct { + name string + in string + want string + }{ + {"plain name unchanged", "hello.png", "hello.png"}, + {"preserves spaces and parens", "hello world (1).png", "hello world (1).png"}, + {"reduces leading dotdot path to basename", "../../a.png", "a.png"}, + {"handles backslash separators", `..\..\a.exe`, "a.exe"}, + {"reduces nested path to basename", "a/b/c.png", "c.png"}, + {"strips null bytes", "foo\x00bar.png", "foobar.png"}, + {"strips control chars", "foo\tbar\x1f.png", "foobar.png"}, + {"rejects bare dotdot", "..", ""}, + {"rejects bare dot", ".", ""}, + {"rejects empty", "", ""}, + {"rejects root", "/", ""}, + {"rejects path ending in separator", "../", ""}, + {"truncates to max length by runes", strings.Repeat("a", MaxFilenameLength+50), strings.Repeat("a", MaxFilenameLength)}, + {"NFC-normalizes NFD input", "ガ.txt", "ガ.txt"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := SanitizeFilename(tc.in) + assert.Equal(t, tc.want, got) + if got != "" { + // SanitizeFilename output must always satisfy IsValidFilename. + assert.True(t, IsValidFilename(got), "sanitized output %q must be valid", got) + } + }) + } } func TestFileInfoIsImage(t *testing.T) {
1a0643df00c1MM-68526: Harden remote cluster patch response (#36288) (#36311)
2 files changed · +10 −3
server/channels/api4/remote_cluster.go+2 −0 modified@@ -660,6 +660,8 @@ func patchRemoteCluster(c *Context, w http.ResponseWriter, r *http.Request) { return } + updatedRC.Sanitize() + auditRec.Success() auditRec.AddEventResultState(updatedRC)
server/channels/api4/remote_cluster_test.go+8 −3 modified@@ -479,9 +479,10 @@ func TestGenerateRemoteClusterInvite(t *testing.T) { func TestGetRemoteCluster(t *testing.T) { mainHelper.Parallel(t) newRC := &model.RemoteCluster{ - Name: "remotecluster", - SiteURL: "http://example.com", - Token: model.NewId(), + Name: "remotecluster", + SiteURL: "http://example.com", + Token: model.NewId(), + RemoteToken: model.NewId(), } t.Run("Should not work if the remote cluster service is not enabled", func(t *testing.T) { @@ -530,6 +531,7 @@ func TestGetRemoteCluster(t *testing.T) { require.Equal(t, rc.RemoteId, fetchedRC.RemoteId) require.Equal(t, th.BasicTeam.Id, fetchedRC.DefaultTeamId) require.Empty(t, fetchedRC.Token) + require.Empty(t, fetchedRC.RemoteToken) }) } @@ -540,6 +542,7 @@ func TestPatchRemoteCluster(t *testing.T) { DisplayName: "initialvalue", SiteURL: "http://example.com", Token: model.NewId(), + RemoteToken: model.NewId(), } rcp := &model.RemoteClusterPatch{DisplayName: model.NewPointer("different value")} @@ -593,6 +596,8 @@ func TestPatchRemoteCluster(t *testing.T) { require.NoError(t, err) require.Equal(t, "patched!", patchedRC.DisplayName) require.Equal(t, newTeamId, patchedRC.DefaultTeamId) + require.Empty(t, patchedRC.Token) + require.Empty(t, patchedRC.RemoteToken) }) }
c5e67e28271cMM-68526: Harden remote cluster patch response (#36288) (#36310)
2 files changed · +10 −3
server/channels/api4/remote_cluster.go+2 −0 modified@@ -660,6 +660,8 @@ func patchRemoteCluster(c *Context, w http.ResponseWriter, r *http.Request) { return } + updatedRC.Sanitize() + auditRec.Success() auditRec.AddEventResultState(updatedRC)
server/channels/api4/remote_cluster_test.go+8 −3 modified@@ -619,9 +619,10 @@ func TestGenerateRemoteClusterInvite(t *testing.T) { func TestGetRemoteCluster(t *testing.T) { mainHelper.Parallel(t) newRC := &model.RemoteCluster{ - Name: "remotecluster", - SiteURL: "http://example.com", - Token: model.NewId(), + Name: "remotecluster", + SiteURL: "http://example.com", + Token: model.NewId(), + RemoteToken: model.NewId(), } t.Run("Should not work if the remote cluster service is not enabled", func(t *testing.T) { @@ -670,6 +671,7 @@ func TestGetRemoteCluster(t *testing.T) { require.Equal(t, rc.RemoteId, fetchedRC.RemoteId) require.Equal(t, th.BasicTeam.Id, fetchedRC.DefaultTeamId) require.Empty(t, fetchedRC.Token) + require.Empty(t, fetchedRC.RemoteToken) }) } @@ -737,6 +739,7 @@ func TestPatchRemoteCluster(t *testing.T) { DisplayName: "initialvalue", SiteURL: "http://example.com", Token: model.NewId(), + RemoteToken: model.NewId(), } rcp := &model.RemoteClusterPatch{DisplayName: model.NewPointer("different value")} @@ -790,6 +793,8 @@ func TestPatchRemoteCluster(t *testing.T) { require.NoError(t, err) require.Equal(t, "patched!", patchedRC.DisplayName) require.Equal(t, newTeamId, patchedRC.DefaultTeamId) + require.Empty(t, patchedRC.Token) + require.Empty(t, patchedRC.RemoteToken) }) }
202d125afa87[release-10.11] MM-68547: Tighten authorization on group syncable link and patch endpoints (#36434)
8 files changed · +1006 −23
e2e-tests/cypress/tests/integration/channels/enterprise/system_console/group_configuration_spec.js+6 −2 modified@@ -394,8 +394,10 @@ describe('group configuration', () => { // # Save settings savePage(); - // * Check the groupteam via the API to ensure its role wasn't updated + // * Check the groupteam via the API to ensure the team was + // removed (delete_at != 0) and its role wasn't updated. cy.apiGetGroupTeam(groupID, testTeam.id).then(({body}) => { + expect(body.delete_at).to.not.eq(0); expect(body.scheme_admin).to.eq(false); }); }); @@ -519,8 +521,10 @@ describe('group configuration', () => { // # Save settings savePage(); - // * Check the groupteam via the API to ensure its role wasn't updated + // * Check the groupteam via the API to ensure the channel was + // removed (delete_at != 0) and its role wasn't updated. cy.apiGetGroupChannel(groupID, testChannel.id).then(({body}) => { + expect(body.delete_at).to.not.eq(0); expect(body.scheme_admin).to.eq(false); }); });
server/channels/api4/group.go+68 −6 modified@@ -377,10 +377,35 @@ func linkGroupSyncable(c *Context, w http.ResponseWriter, r *http.Request) { return } - groupSyncable := &model.GroupSyncable{ - GroupId: c.Params.GroupId, - SyncableId: syncableID, - Type: syncableType, + appErr = verifySchemeAdminAssignmentPermission(c, syncableType, syncableID, patch) + if appErr != nil { + appErr.Where = "Api4.linkGroupSyncable" + c.Err = appErr + return + } + + // Upsert onto the existing row only when it is currently active so + // unspecified fields are preserved. A fresh link, or a re-link of a + // soft-deleted row, starts from a zero-value struct so that fields + // the caller did not (or was not authorized to) set are not carried + // over from the previous incarnation. The downstream upsert clears + // DeleteAt when re-activating. + existing, appErr := c.App.GetGroupSyncable(c.Params.GroupId, syncableID, syncableType) + if appErr != nil && appErr.StatusCode != http.StatusNotFound { + appErr.Where = "Api4.linkGroupSyncable" + c.Err = appErr + return + } + + var groupSyncable *model.GroupSyncable + if existing != nil && existing.DeleteAt == 0 { + groupSyncable = existing + } else { + groupSyncable = &model.GroupSyncable{ + GroupId: c.Params.GroupId, + SyncableId: syncableID, + Type: syncableType, + } } groupSyncable.Patch(patch) groupSyncable, appErr = c.App.UpsertGroupSyncable(groupSyncable) @@ -392,8 +417,9 @@ func linkGroupSyncable(c *Context, w http.ResponseWriter, r *http.Request) { auditRec.AddEventResultState(groupSyncable) auditRec.AddEventObjectType("group_syncable") + syncRoles := patch.SchemeAdmin != nil c.App.Srv().Go(func() { - c.App.SyncRolesAndMembership(c.AppContext, syncableID, syncableType, c.Params.GroupId) + c.App.SyncRolesAndMembership(c.AppContext, syncableID, syncableType, c.Params.GroupId, syncRoles) }) w.WriteHeader(http.StatusCreated) @@ -560,6 +586,13 @@ func patchGroupSyncable(c *Context, w http.ResponseWriter, r *http.Request) { return } + appErr = verifySchemeAdminAssignmentPermission(c, syncableType, syncableID, patch) + if appErr != nil { + appErr.Where = "Api4.patchGroupSyncable" + c.Err = appErr + return + } + groupSyncable, appErr := c.App.GetGroupSyncable(c.Params.GroupId, syncableID, syncableType) if appErr != nil { c.Err = appErr @@ -577,8 +610,9 @@ func patchGroupSyncable(c *Context, w http.ResponseWriter, r *http.Request) { auditRec.AddEventResultState(groupSyncable) auditRec.AddEventObjectType("group_syncable") + syncRoles := patch.SchemeAdmin != nil c.App.Srv().Go(func() { - c.App.SyncRolesAndMembership(c.AppContext, syncableID, syncableType, c.Params.GroupId) + c.App.SyncRolesAndMembership(c.AppContext, syncableID, syncableType, c.Params.GroupId, syncRoles) }) b, err := json.Marshal(groupSyncable) @@ -710,6 +744,34 @@ func verifyLinkUnlinkPermission(c *Context, syncableType model.GroupSyncableType return nil } +// verifySchemeAdminAssignmentPermission requires the caller to hold the +// role-management permission for the target syncable +// (manage_team_roles / manage_channel_roles), or the sysconsole groups +// write permission, before an explicit SchemeAdmin value in the patch is +// accepted. A nil patch.SchemeAdmin is a no-op. +func verifySchemeAdminAssignmentPermission(c *Context, syncableType model.GroupSyncableType, syncableID string, patch *model.GroupSyncablePatch) *model.AppError { + if patch == nil || patch.SchemeAdmin == nil { + return nil + } + + if c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionSysconsoleWriteUserManagementGroups) { + return nil + } + + switch syncableType { + case model.GroupSyncableTypeTeam: + if !c.App.SessionHasPermissionToTeam(*c.AppContext.Session(), syncableID, model.PermissionManageTeamRoles) { + return model.MakePermissionError(c.AppContext.Session(), []*model.Permission{model.PermissionManageTeamRoles}) + } + case model.GroupSyncableTypeChannel: + if ok, _ := c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), syncableID, model.PermissionManageChannelRoles); !ok { + return model.MakePermissionError(c.AppContext.Session(), []*model.Permission{model.PermissionManageChannelRoles}) + } + } + + return nil +} + func getGroupMembers(c *Context, w http.ResponseWriter, r *http.Request) { permissionErr := requireLicense(c) if permissionErr != nil {
server/channels/api4/group_test.go+733 −0 modified@@ -2847,3 +2847,736 @@ func TestDeleteMembersFromGroup(t *testing.T) { CheckBadRequestStatus(t, response) }) } + +// newSchemeAdminTestLdapGroup creates a fresh LDAP-source group with +// AllowReference=true. +func newSchemeAdminTestLdapGroup(t *testing.T, th *TestHelper) *model.Group { + t.Helper() + id := model.NewId() + g, appErr := th.App.CreateGroup(&model.Group{ + DisplayName: "dn_" + id, + Name: model.NewPointer("name" + id), + Source: model.GroupSourceLdap, + Description: "description_" + id, + RemoteId: model.NewPointer(model.NewId()), + AllowReference: true, + }) + require.Nil(t, appErr) + return g +} + +// findPersistedGroupSyncable returns the persisted GroupSyncable for a +// given (groupID, syncableID, syncableType) tuple, including SchemeAdmin. +func findPersistedGroupSyncable(t *testing.T, th *TestHelper, groupID, syncableID string, syncableType model.GroupSyncableType) *model.GroupSyncable { + t.Helper() + syncables, appErr := th.App.GetGroupSyncables(groupID, syncableType) + require.Nil(t, appErr) + for _, s := range syncables { + if s.SyncableId == syncableID { + return s + } + } + return nil +} + +func TestLinkGroupTeam_SchemeAdminRequiresElevatedPermission(t *testing.T) { + mainHelper.Parallel(t) + th := Setup(t).InitBasic() + + th.App.Srv().SetLicense(model.NewTestLicense("ldap")) + + schemeAdminTrue := &model.GroupSyncablePatch{ + AutoAdd: model.NewPointer(true), + SchemeAdmin: model.NewPointer(true), + } + + t.Run("regular team user with invite_user must NOT be able to set scheme_admin: true", func(t *testing.T) { + g := newSchemeAdminTestLdapGroup(t, th) + + groupSyncable, response, err := th.Client.LinkGroupSyncable(context.Background(), g.Id, th.BasicTeam.Id, model.GroupSyncableTypeTeam, schemeAdminTrue) + require.Error(t, err) + CheckForbiddenStatus(t, response) + assert.Nil(t, groupSyncable) + + persisted := findPersistedGroupSyncable(t, th, g.Id, th.BasicTeam.Id, model.GroupSyncableTypeTeam) + if persisted != nil { + assert.False(t, persisted.SchemeAdmin) + } + }) + + t.Run("system admin can still set scheme_admin: true", func(t *testing.T) { + g := newSchemeAdminTestLdapGroup(t, th) + + _, response, err := th.SystemAdminClient.LinkGroupSyncable(context.Background(), g.Id, th.BasicTeam.Id, model.GroupSyncableTypeTeam, schemeAdminTrue) + require.NoError(t, err) + CheckCreatedStatus(t, response) + + persisted := findPersistedGroupSyncable(t, th, g.Id, th.BasicTeam.Id, model.GroupSyncableTypeTeam) + require.NotNil(t, persisted) + assert.True(t, persisted.SchemeAdmin) + }) + + t.Run("regular team user can still link with scheme_admin omitted", func(t *testing.T) { + g := newSchemeAdminTestLdapGroup(t, th) + + patch := &model.GroupSyncablePatch{ + AutoAdd: model.NewPointer(true), + } + groupSyncable, response, err := th.Client.LinkGroupSyncable(context.Background(), g.Id, th.BasicTeam.Id, model.GroupSyncableTypeTeam, patch) + require.NoError(t, err) + CheckCreatedStatus(t, response) + require.NotNil(t, groupSyncable) + + persisted := findPersistedGroupSyncable(t, th, g.Id, th.BasicTeam.Id, model.GroupSyncableTypeTeam) + require.NotNil(t, persisted) + assert.False(t, persisted.SchemeAdmin) + }) + + t.Run("regular team user must NOT be able to link with scheme_admin: false explicitly", func(t *testing.T) { + g := newSchemeAdminTestLdapGroup(t, th) + + patch := &model.GroupSyncablePatch{ + AutoAdd: model.NewPointer(true), + SchemeAdmin: model.NewPointer(false), + } + groupSyncable, response, err := th.Client.LinkGroupSyncable(context.Background(), g.Id, th.BasicTeam.Id, model.GroupSyncableTypeTeam, patch) + require.Error(t, err) + CheckForbiddenStatus(t, response) + assert.Nil(t, groupSyncable) + + persisted := findPersistedGroupSyncable(t, th, g.Id, th.BasicTeam.Id, model.GroupSyncableTypeTeam) + if persisted != nil { + assert.False(t, persisted.SchemeAdmin) + } + }) +} + +func TestLinkGroupChannel_SchemeAdminRequiresElevatedPermission(t *testing.T) { + mainHelper.Parallel(t) + th := Setup(t).InitBasic() + + th.App.Srv().SetLicense(model.NewTestLicense("ldap")) + + // A regular user can only link a channel syncable when the group is + // already linked to the parent team, so seed the team link as sysadmin. + mkLinkedGroup := func(t *testing.T) *model.Group { + t.Helper() + g := newSchemeAdminTestLdapGroup(t, th) + _, response, err := th.SystemAdminClient.LinkGroupSyncable(context.Background(), g.Id, th.BasicTeam.Id, model.GroupSyncableTypeTeam, &model.GroupSyncablePatch{ + AutoAdd: model.NewPointer(true), + }) + require.NoError(t, err) + CheckCreatedStatus(t, response) + return g + } + + schemeAdminTrue := &model.GroupSyncablePatch{ + AutoAdd: model.NewPointer(true), + SchemeAdmin: model.NewPointer(true), + } + + t.Run("regular channel user with manage_*_channel_members must NOT be able to set scheme_admin: true", func(t *testing.T) { + g := mkLinkedGroup(t) + + groupSyncable, response, err := th.Client.LinkGroupSyncable(context.Background(), g.Id, th.BasicChannel.Id, model.GroupSyncableTypeChannel, schemeAdminTrue) + require.Error(t, err) + CheckForbiddenStatus(t, response) + assert.Nil(t, groupSyncable) + + persisted := findPersistedGroupSyncable(t, th, g.Id, th.BasicChannel.Id, model.GroupSyncableTypeChannel) + if persisted != nil { + assert.False(t, persisted.SchemeAdmin) + } + }) + + t.Run("system admin can still set scheme_admin: true", func(t *testing.T) { + g := mkLinkedGroup(t) + + _, response, err := th.SystemAdminClient.LinkGroupSyncable(context.Background(), g.Id, th.BasicChannel.Id, model.GroupSyncableTypeChannel, schemeAdminTrue) + require.NoError(t, err) + CheckCreatedStatus(t, response) + + persisted := findPersistedGroupSyncable(t, th, g.Id, th.BasicChannel.Id, model.GroupSyncableTypeChannel) + require.NotNil(t, persisted) + assert.True(t, persisted.SchemeAdmin) + }) + + t.Run("regular channel user can still link with scheme_admin omitted", func(t *testing.T) { + g := mkLinkedGroup(t) + + patch := &model.GroupSyncablePatch{ + AutoAdd: model.NewPointer(true), + } + groupSyncable, response, err := th.Client.LinkGroupSyncable(context.Background(), g.Id, th.BasicChannel.Id, model.GroupSyncableTypeChannel, patch) + require.NoError(t, err) + CheckCreatedStatus(t, response) + require.NotNil(t, groupSyncable) + + persisted := findPersistedGroupSyncable(t, th, g.Id, th.BasicChannel.Id, model.GroupSyncableTypeChannel) + require.NotNil(t, persisted) + assert.False(t, persisted.SchemeAdmin) + }) + + t.Run("regular channel user must NOT be able to link with scheme_admin: false explicitly", func(t *testing.T) { + g := mkLinkedGroup(t) + + patch := &model.GroupSyncablePatch{ + AutoAdd: model.NewPointer(true), + SchemeAdmin: model.NewPointer(false), + } + groupSyncable, response, err := th.Client.LinkGroupSyncable(context.Background(), g.Id, th.BasicChannel.Id, model.GroupSyncableTypeChannel, patch) + require.Error(t, err) + CheckForbiddenStatus(t, response) + assert.Nil(t, groupSyncable) + + persisted := findPersistedGroupSyncable(t, th, g.Id, th.BasicChannel.Id, model.GroupSyncableTypeChannel) + if persisted != nil { + assert.False(t, persisted.SchemeAdmin) + } + }) +} + +func TestPatchGroupTeam_SchemeAdminRequiresElevatedPermission(t *testing.T) { + mainHelper.Parallel(t) + th := Setup(t).InitBasic() + + th.App.Srv().SetLicense(model.NewTestLicense("ldap")) + + // schemeAdmin controls the seeded SchemeAdmin value on the team syncable. + setupLinkedGroup := func(t *testing.T, schemeAdmin bool) *model.Group { + t.Helper() + g := newSchemeAdminTestLdapGroup(t, th) + _, response, err := th.SystemAdminClient.LinkGroupSyncable(context.Background(), g.Id, th.BasicTeam.Id, model.GroupSyncableTypeTeam, &model.GroupSyncablePatch{ + AutoAdd: model.NewPointer(true), + SchemeAdmin: model.NewPointer(schemeAdmin), + }) + require.NoError(t, err) + CheckCreatedStatus(t, response) + return g + } + + schemeAdminTrue := &model.GroupSyncablePatch{ + SchemeAdmin: model.NewPointer(true), + } + + schemeAdminFalse := &model.GroupSyncablePatch{ + SchemeAdmin: model.NewPointer(false), + } + + t.Run("regular team user with invite_user must NOT be able to patch scheme_admin: true", func(t *testing.T) { + g := setupLinkedGroup(t, false) + + _, response, err := th.Client.PatchGroupSyncable(context.Background(), g.Id, th.BasicTeam.Id, model.GroupSyncableTypeTeam, schemeAdminTrue) + require.Error(t, err) + CheckForbiddenStatus(t, response) + + persisted := findPersistedGroupSyncable(t, th, g.Id, th.BasicTeam.Id, model.GroupSyncableTypeTeam) + require.NotNil(t, persisted) + assert.False(t, persisted.SchemeAdmin) + }) + + t.Run("system admin can still patch scheme_admin: true", func(t *testing.T) { + g := setupLinkedGroup(t, false) + + _, response, err := th.SystemAdminClient.PatchGroupSyncable(context.Background(), g.Id, th.BasicTeam.Id, model.GroupSyncableTypeTeam, schemeAdminTrue) + require.NoError(t, err) + CheckOKStatus(t, response) + + persisted := findPersistedGroupSyncable(t, th, g.Id, th.BasicTeam.Id, model.GroupSyncableTypeTeam) + require.NotNil(t, persisted) + assert.True(t, persisted.SchemeAdmin) + }) + + t.Run("regular team user can still patch other fields with scheme_admin omitted", func(t *testing.T) { + g := setupLinkedGroup(t, true) + + patch := &model.GroupSyncablePatch{ + AutoAdd: model.NewPointer(false), + } + _, response, err := th.Client.PatchGroupSyncable(context.Background(), g.Id, th.BasicTeam.Id, model.GroupSyncableTypeTeam, patch) + require.NoError(t, err) + CheckOKStatus(t, response) + + persisted := findPersistedGroupSyncable(t, th, g.Id, th.BasicTeam.Id, model.GroupSyncableTypeTeam) + require.NotNil(t, persisted) + assert.False(t, persisted.AutoAdd) + assert.True(t, persisted.SchemeAdmin) + }) + + t.Run("regular team user must NOT be able to patch scheme_admin: false", func(t *testing.T) { + g := setupLinkedGroup(t, true) + + _, response, err := th.Client.PatchGroupSyncable(context.Background(), g.Id, th.BasicTeam.Id, model.GroupSyncableTypeTeam, schemeAdminFalse) + require.Error(t, err) + CheckForbiddenStatus(t, response) + + persisted := findPersistedGroupSyncable(t, th, g.Id, th.BasicTeam.Id, model.GroupSyncableTypeTeam) + require.NotNil(t, persisted) + assert.True(t, persisted.SchemeAdmin) + }) + + t.Run("system admin can still patch scheme_admin: false", func(t *testing.T) { + g := setupLinkedGroup(t, true) + + _, response, err := th.SystemAdminClient.PatchGroupSyncable(context.Background(), g.Id, th.BasicTeam.Id, model.GroupSyncableTypeTeam, schemeAdminFalse) + require.NoError(t, err) + CheckOKStatus(t, response) + + persisted := findPersistedGroupSyncable(t, th, g.Id, th.BasicTeam.Id, model.GroupSyncableTypeTeam) + require.NotNil(t, persisted) + assert.False(t, persisted.SchemeAdmin) + }) + + t.Run("sysconsole_write_user_management_groups holder can patch scheme_admin in either direction", func(t *testing.T) { + // system_manager bundles sysconsole_write_user_management_groups, + // the override honoured by verifySchemeAdminAssignmentPermission. + th.LoginSystemManager() + + gPromote := setupLinkedGroup(t, false) + _, response, err := th.SystemManagerClient.PatchGroupSyncable(context.Background(), gPromote.Id, th.BasicTeam.Id, model.GroupSyncableTypeTeam, schemeAdminTrue) + require.NoError(t, err) + CheckOKStatus(t, response) + persistedPromote := findPersistedGroupSyncable(t, th, gPromote.Id, th.BasicTeam.Id, model.GroupSyncableTypeTeam) + require.NotNil(t, persistedPromote) + assert.True(t, persistedPromote.SchemeAdmin) + + gDemote := setupLinkedGroup(t, true) + _, response, err = th.SystemManagerClient.PatchGroupSyncable(context.Background(), gDemote.Id, th.BasicTeam.Id, model.GroupSyncableTypeTeam, schemeAdminFalse) + require.NoError(t, err) + CheckOKStatus(t, response) + persistedDemote := findPersistedGroupSyncable(t, th, gDemote.Id, th.BasicTeam.Id, model.GroupSyncableTypeTeam) + require.NotNil(t, persistedDemote) + assert.False(t, persistedDemote.SchemeAdmin) + }) +} + +func TestPatchGroupChannel_SchemeAdminRequiresElevatedPermission(t *testing.T) { + mainHelper.Parallel(t) + th := Setup(t).InitBasic() + + th.App.Srv().SetLicense(model.NewTestLicense("ldap")) + + // schemeAdmin controls the seeded SchemeAdmin value on the channel + // syncable. The team syncable is seeded so the channel link succeeds. + setupLinkedGroup := func(t *testing.T, schemeAdmin bool) *model.Group { + t.Helper() + g := newSchemeAdminTestLdapGroup(t, th) + _, response, err := th.SystemAdminClient.LinkGroupSyncable(context.Background(), g.Id, th.BasicTeam.Id, model.GroupSyncableTypeTeam, &model.GroupSyncablePatch{ + AutoAdd: model.NewPointer(true), + }) + require.NoError(t, err) + CheckCreatedStatus(t, response) + + _, response, err = th.SystemAdminClient.LinkGroupSyncable(context.Background(), g.Id, th.BasicChannel.Id, model.GroupSyncableTypeChannel, &model.GroupSyncablePatch{ + AutoAdd: model.NewPointer(true), + SchemeAdmin: model.NewPointer(schemeAdmin), + }) + require.NoError(t, err) + CheckCreatedStatus(t, response) + return g + } + + schemeAdminTrue := &model.GroupSyncablePatch{ + SchemeAdmin: model.NewPointer(true), + } + + schemeAdminFalse := &model.GroupSyncablePatch{ + SchemeAdmin: model.NewPointer(false), + } + + t.Run("regular channel user with manage_*_channel_members must NOT be able to patch scheme_admin: true", func(t *testing.T) { + g := setupLinkedGroup(t, false) + + _, response, err := th.Client.PatchGroupSyncable(context.Background(), g.Id, th.BasicChannel.Id, model.GroupSyncableTypeChannel, schemeAdminTrue) + require.Error(t, err) + CheckForbiddenStatus(t, response) + + persisted := findPersistedGroupSyncable(t, th, g.Id, th.BasicChannel.Id, model.GroupSyncableTypeChannel) + require.NotNil(t, persisted) + assert.False(t, persisted.SchemeAdmin) + }) + + t.Run("system admin can still patch scheme_admin: true", func(t *testing.T) { + g := setupLinkedGroup(t, false) + + _, response, err := th.SystemAdminClient.PatchGroupSyncable(context.Background(), g.Id, th.BasicChannel.Id, model.GroupSyncableTypeChannel, schemeAdminTrue) + require.NoError(t, err) + CheckOKStatus(t, response) + + persisted := findPersistedGroupSyncable(t, th, g.Id, th.BasicChannel.Id, model.GroupSyncableTypeChannel) + require.NotNil(t, persisted) + assert.True(t, persisted.SchemeAdmin) + }) + + t.Run("regular channel user can still patch other fields with scheme_admin omitted", func(t *testing.T) { + g := setupLinkedGroup(t, true) + + patch := &model.GroupSyncablePatch{ + AutoAdd: model.NewPointer(false), + } + _, response, err := th.Client.PatchGroupSyncable(context.Background(), g.Id, th.BasicChannel.Id, model.GroupSyncableTypeChannel, patch) + require.NoError(t, err) + CheckOKStatus(t, response) + + persisted := findPersistedGroupSyncable(t, th, g.Id, th.BasicChannel.Id, model.GroupSyncableTypeChannel) + require.NotNil(t, persisted) + assert.False(t, persisted.AutoAdd) + assert.True(t, persisted.SchemeAdmin) + }) + + t.Run("regular channel user must NOT be able to patch scheme_admin: false", func(t *testing.T) { + g := setupLinkedGroup(t, true) + + _, response, err := th.Client.PatchGroupSyncable(context.Background(), g.Id, th.BasicChannel.Id, model.GroupSyncableTypeChannel, schemeAdminFalse) + require.Error(t, err) + CheckForbiddenStatus(t, response) + + persisted := findPersistedGroupSyncable(t, th, g.Id, th.BasicChannel.Id, model.GroupSyncableTypeChannel) + require.NotNil(t, persisted) + assert.True(t, persisted.SchemeAdmin) + }) + + t.Run("system admin can still patch scheme_admin: false", func(t *testing.T) { + g := setupLinkedGroup(t, true) + + _, response, err := th.SystemAdminClient.PatchGroupSyncable(context.Background(), g.Id, th.BasicChannel.Id, model.GroupSyncableTypeChannel, schemeAdminFalse) + require.NoError(t, err) + CheckOKStatus(t, response) + + persisted := findPersistedGroupSyncable(t, th, g.Id, th.BasicChannel.Id, model.GroupSyncableTypeChannel) + require.NotNil(t, persisted) + assert.False(t, persisted.SchemeAdmin) + }) + + t.Run("sysconsole_write_user_management_groups holder can patch scheme_admin in either direction", func(t *testing.T) { + // system_manager bundles sysconsole_write_user_management_groups, + // the override honoured by verifySchemeAdminAssignmentPermission. + th.LoginSystemManager() + + gPromote := setupLinkedGroup(t, false) + _, response, err := th.SystemManagerClient.PatchGroupSyncable(context.Background(), gPromote.Id, th.BasicChannel.Id, model.GroupSyncableTypeChannel, schemeAdminTrue) + require.NoError(t, err) + CheckOKStatus(t, response) + persistedPromote := findPersistedGroupSyncable(t, th, gPromote.Id, th.BasicChannel.Id, model.GroupSyncableTypeChannel) + require.NotNil(t, persistedPromote) + assert.True(t, persistedPromote.SchemeAdmin) + + gDemote := setupLinkedGroup(t, true) + _, response, err = th.SystemManagerClient.PatchGroupSyncable(context.Background(), gDemote.Id, th.BasicChannel.Id, model.GroupSyncableTypeChannel, schemeAdminFalse) + require.NoError(t, err) + CheckOKStatus(t, response) + persistedDemote := findPersistedGroupSyncable(t, th, gDemote.Id, th.BasicChannel.Id, model.GroupSyncableTypeChannel) + require.NotNil(t, persistedDemote) + assert.False(t, persistedDemote.SchemeAdmin) + }) +} + +func TestLinkGroupTeam_LinkOnExistingPreservesSchemeAdmin(t *testing.T) { + mainHelper.Parallel(t) + th := Setup(t).InitBasic() + + th.App.Srv().SetLicense(model.NewTestLicense("ldap")) + + seedSchemeAdminTrue := func(t *testing.T) *model.Group { + t.Helper() + g := newSchemeAdminTestLdapGroup(t, th) + _, response, err := th.SystemAdminClient.LinkGroupSyncable(context.Background(), g.Id, th.BasicTeam.Id, model.GroupSyncableTypeTeam, &model.GroupSyncablePatch{ + AutoAdd: model.NewPointer(true), + SchemeAdmin: model.NewPointer(true), + }) + require.NoError(t, err) + CheckCreatedStatus(t, response) + persisted := findPersistedGroupSyncable(t, th, g.Id, th.BasicTeam.Id, model.GroupSyncableTypeTeam) + require.NotNil(t, persisted) + require.True(t, persisted.SchemeAdmin) + return g + } + + t.Run("regular team user calling LINK with scheme_admin omitted must not change persisted scheme_admin", func(t *testing.T) { + g := seedSchemeAdminTrue(t) + + patch := &model.GroupSyncablePatch{ + AutoAdd: model.NewPointer(true), + } + _, _, _ = th.Client.LinkGroupSyncable(context.Background(), g.Id, th.BasicTeam.Id, model.GroupSyncableTypeTeam, patch) + + persisted := findPersistedGroupSyncable(t, th, g.Id, th.BasicTeam.Id, model.GroupSyncableTypeTeam) + require.NotNil(t, persisted) + assert.True(t, persisted.SchemeAdmin) + }) + + t.Run("regular team user calling LINK with scheme_admin: false must not change persisted scheme_admin", func(t *testing.T) { + g := seedSchemeAdminTrue(t) + + patch := &model.GroupSyncablePatch{ + AutoAdd: model.NewPointer(true), + SchemeAdmin: model.NewPointer(false), + } + _, _, _ = th.Client.LinkGroupSyncable(context.Background(), g.Id, th.BasicTeam.Id, model.GroupSyncableTypeTeam, patch) + + persisted := findPersistedGroupSyncable(t, th, g.Id, th.BasicTeam.Id, model.GroupSyncableTypeTeam) + require.NotNil(t, persisted) + assert.True(t, persisted.SchemeAdmin) + }) +} + +func TestLinkGroupChannel_LinkOnExistingPreservesSchemeAdmin(t *testing.T) { + mainHelper.Parallel(t) + th := Setup(t).InitBasic() + + th.App.Srv().SetLicense(model.NewTestLicense("ldap")) + + seedSchemeAdminTrue := func(t *testing.T) *model.Group { + t.Helper() + g := newSchemeAdminTestLdapGroup(t, th) + _, response, err := th.SystemAdminClient.LinkGroupSyncable(context.Background(), g.Id, th.BasicTeam.Id, model.GroupSyncableTypeTeam, &model.GroupSyncablePatch{ + AutoAdd: model.NewPointer(true), + }) + require.NoError(t, err) + CheckCreatedStatus(t, response) + + _, response, err = th.SystemAdminClient.LinkGroupSyncable(context.Background(), g.Id, th.BasicChannel.Id, model.GroupSyncableTypeChannel, &model.GroupSyncablePatch{ + AutoAdd: model.NewPointer(true), + SchemeAdmin: model.NewPointer(true), + }) + require.NoError(t, err) + CheckCreatedStatus(t, response) + persisted := findPersistedGroupSyncable(t, th, g.Id, th.BasicChannel.Id, model.GroupSyncableTypeChannel) + require.NotNil(t, persisted) + require.True(t, persisted.SchemeAdmin) + return g + } + + t.Run("regular channel user calling LINK with scheme_admin omitted must not change persisted scheme_admin", func(t *testing.T) { + g := seedSchemeAdminTrue(t) + + patch := &model.GroupSyncablePatch{ + AutoAdd: model.NewPointer(true), + } + _, _, _ = th.Client.LinkGroupSyncable(context.Background(), g.Id, th.BasicChannel.Id, model.GroupSyncableTypeChannel, patch) + + persisted := findPersistedGroupSyncable(t, th, g.Id, th.BasicChannel.Id, model.GroupSyncableTypeChannel) + require.NotNil(t, persisted) + assert.True(t, persisted.SchemeAdmin) + }) + + t.Run("regular channel user calling LINK with scheme_admin: false must not change persisted scheme_admin", func(t *testing.T) { + g := seedSchemeAdminTrue(t) + + patch := &model.GroupSyncablePatch{ + AutoAdd: model.NewPointer(true), + SchemeAdmin: model.NewPointer(false), + } + _, _, _ = th.Client.LinkGroupSyncable(context.Background(), g.Id, th.BasicChannel.Id, model.GroupSyncableTypeChannel, patch) + + persisted := findPersistedGroupSyncable(t, th, g.Id, th.BasicChannel.Id, model.GroupSyncableTypeChannel) + require.NotNil(t, persisted) + assert.True(t, persisted.SchemeAdmin) + }) +} + +func TestLinkGroupTeam_LinkOnSoftDeletedDoesNotPreserveSchemeAdmin(t *testing.T) { + mainHelper.Parallel(t) + th := Setup(t).InitBasic() + + th.App.Srv().SetLicense(model.NewTestLicense("ldap")) + + t.Run("regular team user re-linking a soft-deleted syncable with scheme_admin omitted must persist scheme_admin: false", func(t *testing.T) { + g := newSchemeAdminTestLdapGroup(t, th) + + _, response, err := th.SystemAdminClient.LinkGroupSyncable(context.Background(), g.Id, th.BasicTeam.Id, model.GroupSyncableTypeTeam, &model.GroupSyncablePatch{ + AutoAdd: model.NewPointer(true), + SchemeAdmin: model.NewPointer(true), + }) + require.NoError(t, err) + CheckCreatedStatus(t, response) + + response, err = th.SystemAdminClient.UnlinkGroupSyncable(context.Background(), g.Id, th.BasicTeam.Id, model.GroupSyncableTypeTeam) + require.NoError(t, err) + CheckOKStatus(t, response) + + patch := &model.GroupSyncablePatch{ + AutoAdd: model.NewPointer(true), + } + _, response, err = th.Client.LinkGroupSyncable(context.Background(), g.Id, th.BasicTeam.Id, model.GroupSyncableTypeTeam, patch) + require.NoError(t, err) + CheckCreatedStatus(t, response) + + persisted := findPersistedGroupSyncable(t, th, g.Id, th.BasicTeam.Id, model.GroupSyncableTypeTeam) + require.NotNil(t, persisted) + assert.False(t, persisted.SchemeAdmin) + }) +} + +func TestPatchGroupTeam_OmittedSchemeAdminDoesNotDemoteDirectAdmin(t *testing.T) { + mainHelper.Parallel(t) + th := Setup(t).InitBasic() + + th.App.Srv().SetLicense(model.NewTestLicense("ldap")) + + g := newSchemeAdminTestLdapGroup(t, th) + + _, response, err := th.SystemAdminClient.LinkGroupSyncable(context.Background(), g.Id, th.BasicTeam.Id, model.GroupSyncableTypeTeam, &model.GroupSyncablePatch{ + AutoAdd: model.NewPointer(true), + }) + require.NoError(t, err) + CheckCreatedStatus(t, response) + + th.UpdateUserToTeamAdmin(th.BasicUser2, th.BasicTeam) + + patch := &model.GroupSyncablePatch{ + AutoAdd: model.NewPointer(false), + } + _, response, err = th.Client.PatchGroupSyncable(context.Background(), g.Id, th.BasicTeam.Id, model.GroupSyncableTypeTeam, patch) + require.NoError(t, err) + CheckOKStatus(t, response) + + time.Sleep(2 * time.Second) + + tm, appErr := th.App.GetTeamMember(th.Context, th.BasicTeam.Id, th.BasicUser2.Id) + require.Nil(t, appErr) + assert.True(t, tm.SchemeAdmin) +} + +func TestPatchGroupChannel_OmittedSchemeAdminDoesNotDemoteDirectAdmin(t *testing.T) { + mainHelper.Parallel(t) + th := Setup(t).InitBasic() + + th.App.Srv().SetLicense(model.NewTestLicense("ldap")) + + g := newSchemeAdminTestLdapGroup(t, th) + + _, response, err := th.SystemAdminClient.LinkGroupSyncable(context.Background(), g.Id, th.BasicTeam.Id, model.GroupSyncableTypeTeam, &model.GroupSyncablePatch{ + AutoAdd: model.NewPointer(true), + }) + require.NoError(t, err) + CheckCreatedStatus(t, response) + + _, response, err = th.SystemAdminClient.LinkGroupSyncable(context.Background(), g.Id, th.BasicChannel.Id, model.GroupSyncableTypeChannel, &model.GroupSyncablePatch{ + AutoAdd: model.NewPointer(true), + }) + require.NoError(t, err) + CheckCreatedStatus(t, response) + + th.MakeUserChannelAdmin(th.BasicUser2, th.BasicChannel) + + patch := &model.GroupSyncablePatch{ + AutoAdd: model.NewPointer(false), + } + _, response, err = th.Client.PatchGroupSyncable(context.Background(), g.Id, th.BasicChannel.Id, model.GroupSyncableTypeChannel, patch) + require.NoError(t, err) + CheckOKStatus(t, response) + + time.Sleep(2 * time.Second) + + cm, appErr := th.App.GetChannelMember(th.Context, th.BasicChannel.Id, th.BasicUser2.Id) + require.Nil(t, appErr) + assert.True(t, cm.SchemeAdmin) +} + +func TestLinkGroupTeam_OmittedSchemeAdminDoesNotDemoteDirectAdmin(t *testing.T) { + mainHelper.Parallel(t) + th := Setup(t).InitBasic() + + th.App.Srv().SetLicense(model.NewTestLicense("ldap")) + + g := newSchemeAdminTestLdapGroup(t, th) + + th.UpdateUserToTeamAdmin(th.BasicUser2, th.BasicTeam) + + patch := &model.GroupSyncablePatch{ + AutoAdd: model.NewPointer(true), + } + _, response, err := th.Client.LinkGroupSyncable(context.Background(), g.Id, th.BasicTeam.Id, model.GroupSyncableTypeTeam, patch) + require.NoError(t, err) + CheckCreatedStatus(t, response) + + time.Sleep(2 * time.Second) + + tm, appErr := th.App.GetTeamMember(th.Context, th.BasicTeam.Id, th.BasicUser2.Id) + require.Nil(t, appErr) + assert.True(t, tm.SchemeAdmin) +} + +func TestLinkGroupChannel_OmittedSchemeAdminDoesNotDemoteDirectAdmin(t *testing.T) { + mainHelper.Parallel(t) + th := Setup(t).InitBasic() + + th.App.Srv().SetLicense(model.NewTestLicense("ldap")) + + g := newSchemeAdminTestLdapGroup(t, th) + + _, response, err := th.SystemAdminClient.LinkGroupSyncable(context.Background(), g.Id, th.BasicTeam.Id, model.GroupSyncableTypeTeam, &model.GroupSyncablePatch{ + AutoAdd: model.NewPointer(true), + }) + require.NoError(t, err) + CheckCreatedStatus(t, response) + + th.MakeUserChannelAdmin(th.BasicUser2, th.BasicChannel) + + patch := &model.GroupSyncablePatch{ + AutoAdd: model.NewPointer(true), + } + _, response, err = th.Client.LinkGroupSyncable(context.Background(), g.Id, th.BasicChannel.Id, model.GroupSyncableTypeChannel, patch) + require.NoError(t, err) + CheckCreatedStatus(t, response) + + time.Sleep(2 * time.Second) + + cm, appErr := th.App.GetChannelMember(th.Context, th.BasicChannel.Id, th.BasicUser2.Id) + require.Nil(t, appErr) + assert.True(t, cm.SchemeAdmin) +} + +func TestLinkGroupTeam_SchemeAdminTruePromotesGroupMembers(t *testing.T) { + mainHelper.Parallel(t) + th := Setup(t).InitBasic() + + th.App.Srv().SetLicense(model.NewTestLicense("ldap")) + + g := newSchemeAdminTestLdapGroup(t, th) + + _, appErr := th.App.UpsertGroupMember(g.Id, th.BasicUser2.Id) + require.Nil(t, appErr) + + _, response, err := th.SystemAdminClient.LinkGroupSyncable(context.Background(), g.Id, th.BasicTeam.Id, model.GroupSyncableTypeTeam, &model.GroupSyncablePatch{ + AutoAdd: model.NewPointer(true), + SchemeAdmin: model.NewPointer(true), + }) + require.NoError(t, err) + CheckCreatedStatus(t, response) + + time.Sleep(2 * time.Second) + + tm, appErr := th.App.GetTeamMember(th.Context, th.BasicTeam.Id, th.BasicUser2.Id) + require.Nil(t, appErr) + assert.True(t, tm.SchemeAdmin) +} + +func TestLinkGroupTeam_AutoAddOnlyAddsGroupMembers(t *testing.T) { + mainHelper.Parallel(t) + th := Setup(t).InitBasic() + + th.App.Srv().SetLicense(model.NewTestLicense("ldap")) + + g := newSchemeAdminTestLdapGroup(t, th) + + newUser := th.CreateUser() + _, appErr := th.App.UpsertGroupMember(g.Id, newUser.Id) + require.Nil(t, appErr) + + _, appErr = th.App.GetTeamMember(th.Context, th.BasicTeam.Id, newUser.Id) + require.NotNil(t, appErr) + + _, response, err := th.SystemAdminClient.LinkGroupSyncable(context.Background(), g.Id, th.BasicTeam.Id, model.GroupSyncableTypeTeam, &model.GroupSyncablePatch{ + AutoAdd: model.NewPointer(true), + }) + require.NoError(t, err) + CheckCreatedStatus(t, response) + + time.Sleep(2 * time.Second) + + tm, appErr := th.App.GetTeamMember(th.Context, th.BasicTeam.Id, newUser.Id) + require.Nil(t, appErr) + assert.Equal(t, newUser.Id, tm.UserId) +}
server/channels/app/syncables.go+8 −6 modified@@ -268,18 +268,20 @@ func (a *App) SyncSyncableRoles(rctx request.CTX, syncableID string, syncableTyp return nil } -// SyncRolesAndMembership updates the SchemeAdmin status and membership of all of the members of the given -// syncable. -func (a *App) SyncRolesAndMembership(rctx request.CTX, syncableID string, syncableType model.GroupSyncableType, groupID string) { +// SyncRolesAndMembership updates the membership of the given syncable and, +// when syncRoles is true, also reconciles SchemeAdmin status for its members. +func (a *App) SyncRolesAndMembership(rctx request.CTX, syncableID string, syncableType model.GroupSyncableType, groupID string, syncRoles bool) { group, appErr := a.GetGroup(groupID, nil, nil) if appErr != nil { rctx.Logger().Warn("Error getting group", mlog.Err(appErr)) return } - appErr = a.SyncSyncableRoles(rctx, syncableID, syncableType) - if appErr != nil { - rctx.Logger().Warn("Error syncing syncable roles", mlog.Err(appErr)) + if syncRoles { + appErr = a.SyncSyncableRoles(rctx, syncableID, syncableType) + if appErr != nil { + rctx.Logger().Warn("Error syncing syncable roles", mlog.Err(appErr)) + } } var since int64
server/channels/app/syncables_test.go+144 −0 modified@@ -677,3 +677,147 @@ func TestSyncSyncableRoles(t *testing.T) { require.True(t, cm.SchemeAdmin) } } + +func TestSyncRolesAndMembership_RoleSyncGate(t *testing.T) { + mainHelper.Parallel(t) + th := Setup(t).InitBasic() + + setup := func(t *testing.T) (*model.Team, *model.Channel, *model.Group, *model.User) { + t.Helper() + + team := th.CreateTeam() + channel := th.CreateChannel(th.Context, team) + group := th.CreateGroup() + + _, err := th.App.UpsertGroupSyncable(&model.GroupSyncable{ + SyncableId: team.Id, + Type: model.GroupSyncableTypeTeam, + GroupId: group.Id, + AutoAdd: true, + }) + require.Nil(t, err) + + _, err = th.App.UpsertGroupSyncable(&model.GroupSyncable{ + SyncableId: channel.Id, + Type: model.GroupSyncableTypeChannel, + GroupId: group.Id, + AutoAdd: true, + }) + require.Nil(t, err) + + directAdmin := th.CreateUser() + _, appErr := th.App.AddTeamMember(th.Context, team.Id, directAdmin.Id) + require.Nil(t, appErr) + _, appErr = th.App.AddUserToChannel(th.Context, directAdmin, channel, false) + require.Nil(t, appErr) + + tm, storeErr := th.App.Srv().Store().Team().GetMember(th.Context, team.Id, directAdmin.Id) + require.NoError(t, storeErr) + tm.SchemeAdmin = true + _, storeErr = th.App.Srv().Store().Team().UpdateMember(th.Context, tm) + require.NoError(t, storeErr) + + cm, storeErr := th.App.Srv().Store().Channel().GetMember(th.Context.Context(), channel.Id, directAdmin.Id) + require.NoError(t, storeErr) + cm.SchemeAdmin = true + _, storeErr = th.App.Srv().Store().Channel().UpdateMember(th.Context, cm) + require.NoError(t, storeErr) + + return team, channel, group, directAdmin + } + + t.Run("syncRoles=false preserves the existing SchemeAdmin on team members", func(t *testing.T) { + team, _, group, directAdmin := setup(t) + + th.App.SyncRolesAndMembership(th.Context, team.Id, model.GroupSyncableTypeTeam, group.Id, false) + + tm, appErr := th.App.GetTeamMember(th.Context, team.Id, directAdmin.Id) + require.Nil(t, appErr) + assert.True(t, tm.SchemeAdmin) + }) + + t.Run("syncRoles=false preserves the existing SchemeAdmin on channel members", func(t *testing.T) { + _, channel, group, directAdmin := setup(t) + + th.App.SyncRolesAndMembership(th.Context, channel.Id, model.GroupSyncableTypeChannel, group.Id, false) + + cm, appErr := th.App.GetChannelMember(th.Context, channel.Id, directAdmin.Id) + require.Nil(t, appErr) + assert.True(t, cm.SchemeAdmin) + }) + + t.Run("syncRoles=true reconciles team SchemeAdmin against PermittedSyncableAdmins", func(t *testing.T) { + team, _, group, directAdmin := setup(t) + + th.App.SyncRolesAndMembership(th.Context, team.Id, model.GroupSyncableTypeTeam, group.Id, true) + + tm, appErr := th.App.GetTeamMember(th.Context, team.Id, directAdmin.Id) + require.Nil(t, appErr) + assert.False(t, tm.SchemeAdmin) + }) + + t.Run("syncRoles=true reconciles channel SchemeAdmin against PermittedSyncableAdmins", func(t *testing.T) { + _, channel, group, directAdmin := setup(t) + + th.App.SyncRolesAndMembership(th.Context, channel.Id, model.GroupSyncableTypeChannel, group.Id, true) + + cm, appErr := th.App.GetChannelMember(th.Context, channel.Id, directAdmin.Id) + require.Nil(t, appErr) + assert.False(t, cm.SchemeAdmin) + }) +} + +func TestSyncRolesAndMembership_AlwaysSyncsMembership(t *testing.T) { + mainHelper.Parallel(t) + th := Setup(t).InitBasic() + + setup := func(t *testing.T) (*model.Team, *model.Channel, *model.Group, *model.User) { + t.Helper() + + team := th.CreateTeam() + channel := th.CreateChannel(th.Context, team) + group := th.CreateGroup() + + _, err := th.App.UpsertGroupSyncable(&model.GroupSyncable{ + SyncableId: team.Id, + Type: model.GroupSyncableTypeTeam, + GroupId: group.Id, + AutoAdd: true, + }) + require.Nil(t, err) + + _, err = th.App.UpsertGroupSyncable(&model.GroupSyncable{ + SyncableId: channel.Id, + Type: model.GroupSyncableTypeChannel, + GroupId: group.Id, + AutoAdd: true, + }) + require.Nil(t, err) + + groupMember := th.CreateUser() + _, err = th.App.UpsertGroupMember(group.Id, groupMember.Id) + require.Nil(t, err) + + return team, channel, group, groupMember + } + + t.Run("syncRoles=false still adds group members to the team", func(t *testing.T) { + team, _, group, groupMember := setup(t) + + th.App.SyncRolesAndMembership(th.Context, team.Id, model.GroupSyncableTypeTeam, group.Id, false) + + tm, appErr := th.App.GetTeamMember(th.Context, team.Id, groupMember.Id) + require.Nil(t, appErr) + assert.Equal(t, groupMember.Id, tm.UserId) + }) + + t.Run("syncRoles=false still adds group members to the channel", func(t *testing.T) { + _, channel, group, groupMember := setup(t) + + th.App.SyncRolesAndMembership(th.Context, channel.Id, model.GroupSyncableTypeChannel, group.Id, false) + + cm, appErr := th.App.GetChannelMember(th.Context, channel.Id, groupMember.Id) + require.Nil(t, appErr) + assert.Equal(t, groupMember.Id, cm.UserId) + }) +}
server/channels/store/sqlstore/group_store.go+3 −1 modified@@ -777,6 +777,7 @@ func (s *SqlGroupStore) getGroupSyncable(groupID string, syncableID string, sync groupSyncable.DeleteAt = groupTeam.DeleteAt groupSyncable.UpdateAt = groupTeam.UpdateAt groupSyncable.Type = syncableType + groupSyncable.SchemeAdmin = groupTeam.SchemeAdmin case model.GroupSyncableTypeChannel: groupChannel := result.(*groupChannel) groupSyncable.SyncableId = groupChannel.ChannelId @@ -786,6 +787,7 @@ func (s *SqlGroupStore) getGroupSyncable(groupID string, syncableID string, sync groupSyncable.DeleteAt = groupChannel.DeleteAt groupSyncable.UpdateAt = groupChannel.UpdateAt groupSyncable.Type = syncableType + groupSyncable.SchemeAdmin = groupChannel.SchemeAdmin default: return nil, fmt.Errorf("unable to convert syncableType: %s", syncableType.String()) } @@ -1834,7 +1836,7 @@ func (s *SqlGroupStore) AdminRoleGroupsForSyncableMember(userID, syncableID stri func (s *SqlGroupStore) PermittedSyncableAdmins(syncableID string, syncableType model.GroupSyncableType) ([]string, error) { builder := s.getQueryBuilder().Select("UserId"). From(fmt.Sprintf("Group%ss", syncableType)). - Join(fmt.Sprintf("GroupMembers ON GroupMembers.GroupId = Group%ss.GroupId AND Group%[1]ss.SchemeAdmin = TRUE AND GroupMembers.DeleteAt = 0", syncableType.String())).Where(fmt.Sprintf("Group%[1]ss.%[1]sId = ?", syncableType.String()), syncableID) + Join(fmt.Sprintf("GroupMembers ON GroupMembers.GroupId = Group%ss.GroupId AND Group%[1]ss.SchemeAdmin = TRUE AND Group%[1]ss.DeleteAt = 0 AND GroupMembers.DeleteAt = 0", syncableType.String())).Where(fmt.Sprintf("Group%[1]ss.%[1]sId = ?", syncableType.String()), syncableID) var userIDs []string if err := s.GetMaster().SelectBuilder(&userIDs, builder); err != nil {
server/channels/store/storetest/group_store.go+28 −0 modified@@ -1616,9 +1616,19 @@ func testGetGroupSyncable(t *testing.T, rctx request.CTX, ss store.Store) { require.Equal(t, gt1.GroupId, dgt.GroupId) require.Equal(t, gt1.SyncableId, dgt.SyncableId) require.Equal(t, gt1.AutoAdd, dgt.AutoAdd) + require.Equal(t, gt1.SchemeAdmin, dgt.SchemeAdmin) require.NotZero(t, gt1.CreateAt) require.NotZero(t, gt1.UpdateAt) require.Zero(t, gt1.DeleteAt) + + // Round-trip SchemeAdmin: true through UpdateGroupSyncable and re-fetch. + dgt.SchemeAdmin = true + _, err = ss.Group().UpdateGroupSyncable(dgt) + require.NoError(t, err) + + dgt, err = ss.Group().GetGroupSyncable(groupTeam.GroupId, groupTeam.SyncableId, model.GroupSyncableTypeTeam) + require.NoError(t, err) + require.True(t, dgt.SchemeAdmin, "GetGroupSyncable must populate SchemeAdmin from the persisted row") } func testGetGroupSyncableErrors(t *testing.T, rctx request.CTX, ss store.Store) { @@ -4989,6 +4999,15 @@ func groupTestPermittedSyncableAdminsTeam(t *testing.T, rctx request.CTX, ss sto // deleted group syncable no longer includes group members _, err = ss.Group().DeleteGroupSyncable(group1.Id, team.Id, model.GroupSyncableTypeTeam) require.NoError(t, err) + + // The persisted row must still carry SchemeAdmin=true after soft-delete; + // PermittedSyncableAdmins excludes it via the DeleteAt = 0 predicate, not + // via the field having been silently cleared. + deletedSyncable, err := ss.Group().GetGroupSyncable(group1.Id, team.Id, model.GroupSyncableTypeTeam) + require.NoError(t, err) + require.True(t, deletedSyncable.SchemeAdmin) + require.NotZero(t, deletedSyncable.DeleteAt) + actualUserIDs, err = ss.Group().PermittedSyncableAdmins(team.Id, model.GroupSyncableTypeTeam) require.NoError(t, err) require.ElementsMatch(t, []string{user3.Id}, actualUserIDs) @@ -5096,6 +5115,15 @@ func groupTestPermittedSyncableAdminsChannel(t *testing.T, rctx request.CTX, ss // deleted group syncable no longer includes group members _, err = ss.Group().DeleteGroupSyncable(group1.Id, channel.Id, model.GroupSyncableTypeChannel) require.NoError(t, err) + + // The persisted row must still carry SchemeAdmin=true after soft-delete; + // PermittedSyncableAdmins excludes it via the DeleteAt = 0 predicate, not + // via the field having been silently cleared. + deletedSyncable, err := ss.Group().GetGroupSyncable(group1.Id, channel.Id, model.GroupSyncableTypeChannel) + require.NoError(t, err) + require.True(t, deletedSyncable.SchemeAdmin) + require.NotZero(t, deletedSyncable.DeleteAt) + actualUserIDs, err = ss.Group().PermittedSyncableAdmins(channel.Id, model.GroupSyncableTypeChannel) require.NoError(t, err) require.ElementsMatch(t, []string{user3.Id}, actualUserIDs)
webapp/channels/src/components/admin_console/group_settings/group_details/group_details.tsx+16 −8 modified@@ -417,17 +417,25 @@ class GroupDetails extends React.PureComponent<Props, State> { roleChangeKey = (groupTeamOrChannel: { type?: SyncableType; + id?: string; team_id?: string; channel_id?: string; }) => { - let id; - if ( - this.syncableTypeFromEntryType(groupTeamOrChannel.type) === - SyncableType.Team - ) { - id = groupTeamOrChannel.team_id; - } else { - id = groupTeamOrChannel.channel_id; + // Items in itemsToRemove use a generic `id`, while items coming from + // teamsToAdd/channelsToAdd use `team_id`/`channel_id`. The key must + // be identical regardless of source so the dedup in + // handleRemovedTeamsAndChannels and handleAddedTeamsAndChannels + // matches the key produced by onChangeRoles. + let id = groupTeamOrChannel.id; + if (!id) { + if ( + this.syncableTypeFromEntryType(groupTeamOrChannel.type) === + SyncableType.Team + ) { + id = groupTeamOrChannel.team_id; + } else { + id = groupTeamOrChannel.channel_id; + } } return `${id}/${groupTeamOrChannel.type}`; };
Vulnerability mechanics
Root cause
"Missing input sanitization on FileInfo.Name received from federated peers allows path traversal sequences to write files to arbitrary locations in the target server's filestore."
Attack vector
An attacker who controls a federated Mattermost server can send a shared channel file sync request containing a `FileInfo.Name` field with path traversal sequences (e.g., `../../a.png` or `..\..\a.exe`). Because the target server did not sanitize this field, the attacker can write files to arbitrary directories within the target server's filestore, potentially overwriting configuration files or other sensitive data. The attack requires high privileges (control of a federated peer) but no authentication on the target beyond the shared channel trust relationship.
Affected code
The vulnerability exists in Mattermost's shared channel file sync logic, where `FileInfo.Name` received from a federated peer was not sanitized before being used to write files to the server's filestore. The patches centralize filename handling in `server/public/model/file_info.go` by adding `SanitizeFilename` and `IsValidFilename` functions, and call `SanitizeFilename` in `server/channels/app/upload.go`'s `genFileInfoFromReader` function [patch_id=5724801][patch_id=5724799].
What the fix does
The patches introduce `SanitizeFilename` and `IsValidFilename` functions in `server/public/model/file_info.go` [patch_id=5724801][patch_id=5724799]. `SanitizeFilename` NFC-normalizes Unicode, strips ASCII control characters, collapses backslashes to forward slashes, reduces the value to its final path element via `filepath.Base`, and truncates to 256 codepoints. `IsValidFilename` rejects empty strings, bare `.`/`..`, path separators, and control characters. The upload path in `server/channels/app/upload.go` now calls `SanitizeFilename` on the incoming name and returns an error if the result is empty, preventing any path traversal from reaching the filestore.
Preconditions
- configAttacker must control a federated Mattermost server that is trusted by the target server for shared channel communication.
- configThe target server must have shared channels enabled and be connected to the attacker's federated server.
- authAttacker must be able to initiate a file sync operation within a shared channel.
Generated on Jun 12, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
1News mentions
0No linked articles in our index yet.