VYPR
High severity7.6NVD Advisory· Published Jun 12, 2026

CVE-2026-6961

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

Patches

5
a9f3868e1eee

MM-68439 Centralize filename handling for FileInfo (#36223) (#36253)

https://github.com/mattermost/mattermostMattermost BuildApr 24, 2026Fixed in 11.5.5via llm-release-walk
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) {
    
c896a63dc44c

MM-68439 Centralize filename handling for FileInfo (#36223) (#36252)

https://github.com/mattermost/mattermostMattermost BuildApr 24, 2026Fixed in 11.6.2via llm-release-walk
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) {
    
1a0643df00c1

MM-68526: Harden remote cluster patch response (#36288) (#36311)

https://github.com/mattermost/mattermostMattermost BuildApr 29, 2026Fixed in 11.5.5via llm-release-walk
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)
     	})
     }
     
    
c5e67e28271c

MM-68526: Harden remote cluster patch response (#36288) (#36310)

https://github.com/mattermost/mattermostMattermost BuildApr 28, 2026Fixed in 11.6.2via llm-release-walk
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)

https://github.com/mattermost/mattermostMaria A NunezMay 6, 2026Fixed in 10.11.17via release-tag
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

1

News mentions

0

No linked articles in our index yet.