CVE-2026-3495
Description
Mattermost versions 11.5.x <= 11.5.1, 10.11.x <= 10.11.13 fail to escape some variables that could contain malicious content during error page composition which allows an attacker with access to edit some site configuration to execute some malicious code via injecting some JS as part of those values.. Mattermost Advisory ID: MMSA-2026-00622
AI Insight
LLM-synthesized narrative grounded in this CVE's description and references.
Mattermost fails to escape certain variables during error page composition, allowing authenticated admins with site config edit access to inject JavaScript.
Vulnerability
Mattermost versions 11.5.x <= 11.5.1 and 10.11.x <= 10.11.13 fail to escape some variables that could contain malicious content during error page composition. This allows an attacker with site configuration edit access to inject JavaScript as part of those values. [1]
Exploitation
An attacker must have access to edit some site configuration settings in Mattermost, and provide values containing malicious JavaScript payloads. When an error page is composed later using those values, the unescaped content is rendered, leading to execution of the injected script. [1]
Impact
Successful exploitation allows an attacker to execute arbitrary JavaScript in the context of an error page. This can lead to information disclosure, session hijacking, or other client-side attacks, depending on the privileges of the victim viewing the error page. [1]
Mitigation
The vulnerability is fixed in Mattermost versions 11.5.2 and 10.11.14. Users should upgrade to these versions or later. No workaround is available. [1]
AI Insight generated on May 21, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.
Affected products
1- Range: <=11.5.1, <=10.11.13
Patches
3a66b08b56d0fencode special characters on some error pages (#35492) (#35651)
2 files changed · +47 −4
server/channels/utils/api.go+4 −4 modified@@ -93,7 +93,7 @@ func RenderMobileAuthComplete(w http.ResponseWriter, redirectURL string) { <h2> `+i18n.T("api.oauth.auth_complete")+` </h2> <p id="redirecting-message"> `+i18n.T("api.oauth.redirecting_back")+` </p> <p id="close-tab-message" style="display: none"> `+i18n.T("api.oauth.close_browser")+` </p> - <p> `+i18n.T("api.oauth.click_redirect", model.StringInterface{"Link": link})+` </p> + <p> `+string(i18n.TranslateAsHTML(i18n.T, "api.oauth.click_redirect", map[string]any{"Link": redirectURL}))+` </p> <meta http-equiv="refresh" content="2; url=`+link+`"> <script> window.onload = function() { @@ -110,17 +110,17 @@ func RenderMobileError(config *model.Config, w http.ResponseWriter, err *model.A var link = template.HTMLEscapeString(redirectURL) u, redirectErr := url.Parse(redirectURL) if redirectErr != nil || !slices.Contains(config.NativeAppSettings.AppCustomURLSchemes, u.Scheme) { - link = *config.ServiceSettings.SiteURL + link = template.HTMLEscapeString(*config.ServiceSettings.SiteURL) } RenderMobileMessage(w, ` <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 512" style="width: 64px; height: 64px; fill: #ccc"> <!-- Font Awesome Free 5.15.3 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) --> <path d="M569.517 440.013C587.975 472.007 564.806 512 527.94 512H48.054c-36.937 0-59.999-40.055-41.577-71.987L246.423 23.985c18.467-32.009 64.72-31.951 83.154 0l239.94 416.028zM288 354c-25.405 0-46 20.595-46 46s20.595 46 46 46 46-20.595 46-46-20.595-46-46-46zm-43.673-165.346l7.418 136c.347 6.364 5.609 11.346 11.982 11.346h48.546c6.373 0 11.635-4.982 11.982-11.346l7.418-136c.375-6.874-5.098-12.654-11.982-12.654h-63.383c-6.884 0-12.356 5.78-11.981 12.654z"/> </svg> <h2> `+i18n.T("error")+` </h2> - <p> `+err.Message+` </p> + <p> `+template.HTMLEscapeString(err.Message)+` </p> <a href="`+link+`"> - `+i18n.T("api.back_to_app", map[string]any{"SiteName": config.TeamSettings.SiteName})+` + `+string(i18n.TranslateAsHTML(i18n.T, "api.back_to_app", map[string]any{"SiteName": config.TeamSettings.SiteName}))+` </a> `) }
server/channels/utils/api_test.go+43 −0 modified@@ -14,12 +14,14 @@ import ( "net/http" "net/http/httptest" "net/url" + "strings" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/mattermost/mattermost/server/public/model" + "github.com/mattermost/mattermost/server/public/shared/i18n" ) func TestRenderWebError(t *testing.T) { @@ -49,3 +51,44 @@ func TestRenderWebError(t *testing.T) { h := sha256.Sum256([]byte("/error?foo=bar")) assert.True(t, ecdsa.Verify(&key.PublicKey, h[:], rs.R, rs.S)) } + +func TestRenderMobileError(t *testing.T) { + require.NoError(t, i18n.TranslationsPreInitFromFileBytes("en.json", []byte(`[{"id":"api.back_to_app","translation":"Back to {{.SiteName}}"}]`))) + + cfg := &model.Config{} + cfg.SetDefaults() + *cfg.ServiceSettings.SiteURL = "http://localhost:8065" + *cfg.TeamSettings.SiteName = "Mattermost<test>" + cfg.NativeAppSettings.AppCustomURLSchemes = []string{"mattermost"} + + appErr := model.NewAppError("test", "api.test.error", nil, "details", http.StatusBadRequest) + appErr.Message = "Something went <wrong>" + + t.Run("renders html with special characters encoded in site name", func(t *testing.T) { + w := httptest.NewRecorder() + RenderMobileError(cfg, w, appErr, "mattermost://auth/complete") + + body := w.Body.String() + assert.Equal(t, http.StatusOK, w.Code) + assert.Contains(t, body, "Mattermost<test>") + assert.NotContains(t, body, "Mattermost<test>") + }) + + t.Run("renders html with special characters encoded in error message", func(t *testing.T) { + w := httptest.NewRecorder() + RenderMobileError(cfg, w, appErr, "mattermost://auth/complete") + + body := w.Body.String() + assert.Contains(t, body, "Something went <wrong>") + assert.NotContains(t, body, "Something went <wrong>") + }) + + t.Run("falls back to site url for invalid redirect scheme", func(t *testing.T) { + w := httptest.NewRecorder() + RenderMobileError(cfg, w, appErr, "https://evil.example.com/callback") + + body := w.Body.String() + assert.Contains(t, body, "http://localhost:8065") + assert.False(t, strings.Contains(body, "evil.example.com")) + }) +}
532f2882d119[MM-67377] cherry-pick Fix (#35336) (#35657)
10 files changed · +413 −18
server/channels/api4/command.go+12 −0 modified@@ -169,6 +169,18 @@ func moveCommand(c *Context, w http.ResponseWriter, r *http.Request) { return } + if c.AppContext.Session().UserId != cmd.CreatorId && !c.App.SessionHasPermissionToTeam(*c.AppContext.Session(), cmd.TeamId, model.PermissionManageOthersSlashCommands) { + c.LogAudit("fail - inappropriate permissions") + c.SetPermissionError(model.PermissionManageOthersSlashCommands) + return + } + + // Verify the command creator is a member of the destination team + if _, appErr = c.App.GetTeamMember(c.AppContext, cmr.TeamId, cmd.CreatorId); appErr != nil { + c.Err = model.NewAppError("moveCommand", "api.command.move_command.creator_not_in_team.app_error", nil, "", http.StatusBadRequest) + return + } + if appErr = c.App.MoveCommand(newTeam, cmd); appErr != nil { c.Err = appErr return
server/channels/api4/command_test.go+291 −0 modified@@ -162,6 +162,136 @@ func TestUpdateCommand(t *testing.T) { _, resp, err := th.SystemAdminClient.UpdateCommand(context.Background(), cmd2) require.Error(t, err) CheckUnauthorizedStatus(t, resp) + + // Permission tests + th.LoginBasic() + + // Give BasicUser permission to manage their own commands + th.AddPermissionToRole(model.PermissionManageSlashCommands.Id, model.TeamUserRoleId) + defer th.RemovePermissionFromRole(model.PermissionManageSlashCommands.Id, model.TeamUserRoleId) + + t.Run("UserCanUpdateTheirOwnCommand", func(t *testing.T) { + // Create a command owned by BasicUser + cmd := &model.Command{ + CreatorId: th.BasicUser.Id, + TeamId: team.Id, + URL: "http://nowhere.com", + Method: model.CommandMethodPost, + Trigger: "trigger_own", + } + createdCmd, _ := th.App.CreateCommand(cmd) + + // Update the command + createdCmd.URL = "http://newurl.com" + updatedCmd, _, err := th.Client.UpdateCommand(context.Background(), createdCmd) + require.NoError(t, err) + require.Equal(t, "http://newurl.com", updatedCmd.URL) + }) + + t.Run("UserWithoutManageOthersCannotUpdateOthersCommand", func(t *testing.T) { + // Create a command owned by BasicUser2 + cmd := &model.Command{ + CreatorId: th.BasicUser2.Id, + TeamId: team.Id, + URL: "http://nowhere.com", + Method: model.CommandMethodPost, + Trigger: "trigger_other", + } + createdCmd, _ := th.App.CreateCommand(cmd) + + // Try to update the command + createdCmd.URL = "http://newurl.com" + _, resp, err := th.Client.UpdateCommand(context.Background(), createdCmd) + require.Error(t, err) + CheckForbiddenStatus(t, resp) + }) + + t.Run("UserWithManageOthersCanUpdateOthersCommand", func(t *testing.T) { + // Give BasicUser permission to manage others' commands + th.AddPermissionToRole(model.PermissionManageOthersSlashCommands.Id, model.TeamUserRoleId) + defer th.RemovePermissionFromRole(model.PermissionManageOthersSlashCommands.Id, model.TeamUserRoleId) + + // Create a command owned by BasicUser2 + cmd := &model.Command{ + CreatorId: th.BasicUser2.Id, + TeamId: team.Id, + URL: "http://nowhere.com", + Method: model.CommandMethodPost, + Trigger: "trigger_other2", + } + createdCmd, _ := th.App.CreateCommand(cmd) + + // Update the command + createdCmd.URL = "http://newurl.com" + updatedCmd, _, err := th.Client.UpdateCommand(context.Background(), createdCmd) + require.NoError(t, err) + require.Equal(t, "http://newurl.com", updatedCmd.URL) + }) + + t.Run("UserWithOnlyManageOwnCannotUpdateOthersCommand", func(t *testing.T) { + // BasicUser should only have ManageOwn permission (already set up in the test) + // Create a command owned by BasicUser2 + cmd := &model.Command{ + CreatorId: th.BasicUser2.Id, + TeamId: team.Id, + URL: "http://nowhere.com", + Method: model.CommandMethodPost, + Trigger: "trigger_other3", + } + createdCmd, _ := th.App.CreateCommand(cmd) + + // Try to update the command + createdCmd.URL = "http://newurl.com" + _, resp, err := th.Client.UpdateCommand(context.Background(), createdCmd) + require.Error(t, err) + CheckForbiddenStatus(t, resp) + }) + + t.Run("CannotUpdateCommandToDuplicateCustomTrigger", func(t *testing.T) { + cmdA := &model.Command{ + CreatorId: th.BasicUser.Id, + TeamId: team.Id, + URL: "http://nowhere.com/a", + Method: model.CommandMethodPost, + Trigger: "duplicate_custom_a", + } + createdCmdA, appErr := th.App.CreateCommand(cmdA) + require.Nil(t, appErr) + + cmdB := &model.Command{ + CreatorId: th.BasicUser.Id, + TeamId: team.Id, + URL: "http://nowhere.com/b", + Method: model.CommandMethodPost, + Trigger: "duplicate_custom_b", + } + createdCmdB, appErr := th.App.CreateCommand(cmdB) + require.Nil(t, appErr) + + createdCmdB.Trigger = createdCmdA.Trigger + _, resp, err := th.Client.UpdateCommand(context.Background(), createdCmdB) + require.Error(t, err) + CheckBadRequestStatus(t, resp) + CheckErrorID(t, err, "api.command.duplicate_trigger.app_error") + }) + + t.Run("CannotUpdateCommandToBuiltInTrigger", func(t *testing.T) { + cmd := &model.Command{ + CreatorId: th.BasicUser.Id, + TeamId: team.Id, + URL: "http://nowhere.com/c", + Method: model.CommandMethodPost, + Trigger: "custom_for_builtin_collision", + } + createdCmd, appErr := th.App.CreateCommand(cmd) + require.Nil(t, appErr) + + createdCmd.Trigger = "join" + _, resp, err := th.Client.UpdateCommand(context.Background(), createdCmd) + require.Error(t, err) + CheckBadRequestStatus(t, resp) + CheckErrorID(t, err, "api.command.duplicate_trigger.app_error") + }) } func TestMoveCommand(t *testing.T) { @@ -178,6 +308,8 @@ func TestMoveCommand(t *testing.T) { }() th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.EnableCommands = true }) + th.LinkUserToTeam(user, newTeam) + cmd1 := &model.Command{ CreatorId: user.Id, TeamId: team.Id, @@ -222,6 +354,165 @@ func TestMoveCommand(t *testing.T) { resp, err = th.SystemAdminClient.MoveCommand(context.Background(), newTeam.Id, rcmd2.Id) require.Error(t, err) CheckUnauthorizedStatus(t, resp) + + // Set up for permission tests + th.LoginBasic() + th.LinkUserToTeam(th.BasicUser, newTeam) + th.LinkUserToTeam(th.BasicUser2, newTeam) + + // Give BasicUser permission to manage their own commands on both teams + th.AddPermissionToRole(model.PermissionManageSlashCommands.Id, model.TeamUserRoleId) + defer th.RemovePermissionFromRole(model.PermissionManageSlashCommands.Id, model.TeamUserRoleId) + + t.Run("UserWithoutManageOthersPermissionCannotMoveOthersCommand", func(t *testing.T) { + // Create a command owned by BasicUser2 + cmd := &model.Command{ + CreatorId: th.BasicUser2.Id, + TeamId: team.Id, + URL: "http://nowhere.com", + Method: model.CommandMethodPost, + Trigger: "trigger3", + } + rcmd, _ := th.App.CreateCommand(cmd) + + // BasicUser should not be able to move BasicUser2's command + resp, err := th.Client.MoveCommand(context.Background(), newTeam.Id, rcmd.Id) + require.Error(t, err) + CheckForbiddenStatus(t, resp) + + // Verify the command was not moved + movedCmd, _ := th.App.GetCommand(rcmd.Id) + require.Equal(t, team.Id, movedCmd.TeamId) + }) + + t.Run("UserWithManageOthersPermissionCanMoveOthersCommand", func(t *testing.T) { + // Create a command owned by BasicUser2 + cmd := &model.Command{ + CreatorId: th.BasicUser2.Id, + TeamId: team.Id, + URL: "http://nowhere.com", + Method: model.CommandMethodPost, + Trigger: "trigger4", + } + rcmd, _ := th.App.CreateCommand(cmd) + + // Give BasicUser the permission to manage others' commands + th.AddPermissionToRole(model.PermissionManageOthersSlashCommands.Id, model.TeamUserRoleId) + defer th.RemovePermissionFromRole(model.PermissionManageOthersSlashCommands.Id, model.TeamUserRoleId) + + // Now BasicUser should be able to move BasicUser2's command + _, err := th.Client.MoveCommand(context.Background(), newTeam.Id, rcmd.Id) + require.NoError(t, err) + + // Verify the command was moved + movedCmd, _ := th.App.GetCommand(rcmd.Id) + require.Equal(t, newTeam.Id, movedCmd.TeamId) + }) + + t.Run("CreatorCanMoveTheirOwnCommand", func(t *testing.T) { + // Create a command owned by BasicUser + cmd := &model.Command{ + CreatorId: th.BasicUser.Id, + TeamId: team.Id, + URL: "http://nowhere.com", + Method: model.CommandMethodPost, + Trigger: "trigger5", + } + rcmd, _ := th.App.CreateCommand(cmd) + + // BasicUser should be able to move their own command + _, err := th.Client.MoveCommand(context.Background(), newTeam.Id, rcmd.Id) + require.NoError(t, err) + + // Verify the command was moved + movedCmd, _ := th.App.GetCommand(rcmd.Id) + require.Equal(t, newTeam.Id, movedCmd.TeamId) + }) + + t.Run("UserWithOnlyManageOwnCannotMoveOthersCommand", func(t *testing.T) { + // BasicUser should only have ManageOwn permission (already set up in the test) + // Create a command owned by BasicUser2 + cmd := &model.Command{ + CreatorId: th.BasicUser2.Id, + TeamId: team.Id, + URL: "http://nowhere.com", + Method: model.CommandMethodPost, + Trigger: "trigger6", + } + rcmd, _ := th.App.CreateCommand(cmd) + + // BasicUser should not be able to move BasicUser2's command + resp, err := th.Client.MoveCommand(context.Background(), newTeam.Id, rcmd.Id) + require.Error(t, err) + CheckForbiddenStatus(t, resp) + + // Verify the command was not moved + notMovedCmd, _ := th.App.GetCommand(rcmd.Id) + require.Equal(t, team.Id, notMovedCmd.TeamId) + }) + + t.Run("CannotMoveCommandWhenCreatorHasNoPermissionToNewTeam", func(t *testing.T) { + // Create a third team that the command creator (BasicUser2) is NOT a member of + thirdTeam := th.CreateTeam() + th.LinkUserToTeam(th.BasicUser, thirdTeam) + + // Give BasicUser permission to manage others' commands + th.AddPermissionToRole(model.PermissionManageOthersSlashCommands.Id, model.TeamUserRoleId) + defer th.RemovePermissionFromRole(model.PermissionManageOthersSlashCommands.Id, model.TeamUserRoleId) + + // Create a command owned by BasicUser2 + // Note: BasicUser2 is NOT a member of thirdTeam (only member of team and newTeam) + cmd := &model.Command{ + CreatorId: th.BasicUser2.Id, + TeamId: team.Id, + URL: "http://nowhere.com", + Method: model.CommandMethodPost, + Trigger: "trigger7", + } + rcmd, _ := th.App.CreateCommand(cmd) + + // BasicUser attempts to move BasicUser2's command to thirdTeam + // This should fail because BasicUser2 doesn't have permission to thirdTeam + resp, err := th.Client.MoveCommand(context.Background(), thirdTeam.Id, rcmd.Id) + require.Error(t, err) + CheckBadRequestStatus(t, resp) + + // Verify the command was not moved + notMovedCmd, _ := th.App.GetCommand(rcmd.Id) + require.Equal(t, team.Id, notMovedCmd.TeamId) + }) + + t.Run("CannotMoveCommandToTeamWithDuplicateTrigger", func(t *testing.T) { + trigger := "move_duplicate_trigger" + + sourceCmd := &model.Command{ + CreatorId: th.BasicUser.Id, + TeamId: team.Id, + URL: "http://nowhere.com/source", + Method: model.CommandMethodPost, + Trigger: trigger, + } + sourceCreatedCmd, appErr := th.App.CreateCommand(sourceCmd) + require.Nil(t, appErr) + + targetCmd := &model.Command{ + CreatorId: th.BasicUser.Id, + TeamId: newTeam.Id, + URL: "http://nowhere.com/target", + Method: model.CommandMethodPost, + Trigger: trigger, + } + _, appErr = th.App.CreateCommand(targetCmd) + require.Nil(t, appErr) + + resp, err := th.Client.MoveCommand(context.Background(), newTeam.Id, sourceCreatedCmd.Id) + require.Error(t, err) + CheckBadRequestStatus(t, resp) + CheckErrorID(t, err, "api.command.duplicate_trigger.app_error") + + notMovedCmd, _ := th.App.GetCommand(sourceCreatedCmd.Id) + require.Equal(t, team.Id, notMovedCmd.TeamId) + }) } func TestDeleteCommand(t *testing.T) {
server/channels/api4/post_test.go+2 −0 modified@@ -275,6 +275,8 @@ func TestCreatePost(t *testing.T) { require.Error(t, err) CheckUnauthorizedStatus(t, resp) assert.Nil(t, rpost) + + th.LoginBasic() }) t.Run("should prevent creating post with files when user lacks upload_file permission in target channel", func(t *testing.T) {
server/channels/app/command.go+34 −16 modified@@ -671,22 +671,8 @@ func (a *App) CreateCommand(cmd *model.Command) (*model.Command, *model.AppError func (a *App) createCommand(cmd *model.Command) (*model.Command, *model.AppError) { cmd.Trigger = strings.ToLower(cmd.Trigger) - teamCmds, err := a.Srv().Store().Command().GetByTeam(cmd.TeamId) - if err != nil { - return nil, model.NewAppError("CreateCommand", "app.command.createcommand.internal_error", nil, "", http.StatusInternalServerError).Wrap(err) - } - - for _, existingCommand := range teamCmds { - if cmd.Trigger == existingCommand.Trigger { - return nil, model.NewAppError("CreateCommand", "api.command.duplicate_trigger.app_error", nil, "", http.StatusBadRequest) - } - } - - for _, builtInProvider := range commandProviders { - builtInCommand := builtInProvider.GetCommand(a, i18n.T) - if builtInCommand != nil && cmd.Trigger == builtInCommand.Trigger { - return nil, model.NewAppError("CreateCommand", "api.command.duplicate_trigger.app_error", nil, "", http.StatusBadRequest) - } + if appErr := a.validateCommandTriggerUniqueness(cmd.TeamId, cmd.Trigger, ""); appErr != nil { + return nil, appErr } command, nErr := a.Srv().Store().Command().Save(cmd) @@ -703,6 +689,30 @@ func (a *App) createCommand(cmd *model.Command) (*model.Command, *model.AppError return command, nil } +func (a *App) validateCommandTriggerUniqueness(teamID, trigger, excludeCommandID string) *model.AppError { + trigger = strings.ToLower(trigger) + + for _, builtInProvider := range commandProviders { + builtInCommand := builtInProvider.GetCommand(a, i18n.T) + if builtInCommand != nil && trigger == strings.ToLower(builtInCommand.Trigger) { + return model.NewAppError("validateCommandTriggerUniqueness", "api.command.duplicate_trigger.app_error", nil, "", http.StatusBadRequest) + } + } + + teamCmds, err := a.Srv().Store().Command().GetByTeam(teamID) + if err != nil { + return model.NewAppError("validateCommandTriggerUniqueness", "app.command.validatecommandtriggeruniqueness.internal_error", nil, "", http.StatusInternalServerError).Wrap(err) + } + + for _, existingCommand := range teamCmds { + if existingCommand.Id != excludeCommandID && trigger == strings.ToLower(existingCommand.Trigger) { + return model.NewAppError("validateCommandTriggerUniqueness", "api.command.duplicate_trigger.app_error", nil, "", http.StatusBadRequest) + } + } + + return nil +} + func (a *App) GetCommand(commandID string) (*model.Command, *model.AppError) { if !*a.Config().ServiceSettings.EnableCommands { return nil, model.NewAppError("GetCommand", "api.command.disabled.app_error", nil, "", http.StatusNotImplemented) @@ -736,6 +746,10 @@ func (a *App) UpdateCommand(oldCmd, updatedCmd *model.Command) (*model.Command, updatedCmd.PluginId = oldCmd.PluginId updatedCmd.TeamId = oldCmd.TeamId + if appErr := a.validateCommandTriggerUniqueness(updatedCmd.TeamId, updatedCmd.Trigger, updatedCmd.Id); appErr != nil { + return nil, appErr + } + command, err := a.Srv().Store().Command().Update(updatedCmd) if err != nil { var nfErr *store.ErrNotFound @@ -754,6 +768,10 @@ func (a *App) UpdateCommand(oldCmd, updatedCmd *model.Command) (*model.Command, } func (a *App) MoveCommand(team *model.Team, command *model.Command) *model.AppError { + if appErr := a.validateCommandTriggerUniqueness(team.Id, command.Trigger, command.Id); appErr != nil { + return appErr + } + command.TeamId = team.Id _, err := a.Srv().Store().Command().Update(command)
server/channels/app/plugin_api.go+4 −0 modified@@ -1305,6 +1305,10 @@ func (api *PluginAPI) UpdateCommand(commandID string, updatedCmd *model.Command) updatedCmd.TeamId = oldCmd.TeamId } + if appErr := api.app.validateCommandTriggerUniqueness(updatedCmd.TeamId, updatedCmd.Trigger, updatedCmd.Id); appErr != nil { + return nil, appErr + } + return api.app.Srv().Store().Command().Update(updatedCmd) }
server/channels/app/plugin_api_test.go+17 −0 modified@@ -2247,6 +2247,23 @@ func TestPluginAPIUpdateCommand(t *testing.T) { require.NoError(t, appErr) require.Equal(t, "anothernewtriggeragain", newCmd4.Trigger) require.Equal(t, team1.Id, newCmd4.TeamId) + + // Updating a command's trigger to one that already exists should fail. + cmd2 := &model.Command{ + TeamId: team1.Id, + Trigger: "uniquetrigger", + Method: "G", + URL: "http://test.com/uniquetrigger", + } + cmd2, appErr = api.CreateCommand(cmd2) + require.NoError(t, appErr) + + cmd2.Trigger = "anotherNewTriggerAgain" + _, appErr = api.UpdateCommand(cmd2.Id, cmd2) + require.Error(t, appErr) + var appError *model.AppError + require.ErrorAs(t, appErr, &appError) + require.Equal(t, "api.command.duplicate_trigger.app_error", appError.Id) } func TestPluginAPIIsEnterpriseReady(t *testing.T) {
server/channels/app/slashcommands/command_test.go+15 −0 modified@@ -64,6 +64,21 @@ func TestMoveCommand(t *testing.T) { retrievedCommand, err = th.App.GetCommand(command.Id) assert.Nil(t, err) assert.EqualValues(t, targetTeam.Id, retrievedCommand.TeamId) + + // Move a command to a team where the trigger already exists should fail. + command2 := &model.Command{} + command2.CreatorId = model.NewId() + command2.Method = model.CommandMethodPost + command2.TeamId = sourceTeam.Id + command2.URL = "http://nowhere.com/" + command2.Trigger = "trigger1" + + command2, err = th.App.CreateCommand(command2) + assert.Nil(t, err) + + moveErr := th.App.MoveCommand(targetTeam, command2) + assert.NotNil(t, moveErr) + assert.Equal(t, "api.command.duplicate_trigger.app_error", moveErr.Id) } func TestCreateCommandPost(t *testing.T) {
server/i18n/en.json+9 −1 modified@@ -719,6 +719,10 @@ "id": "api.command.invite_people.sent", "translation": "Email invite(s) sent" }, + { + "id": "api.command.move_command.creator_not_in_team.app_error", + "translation": "Creator not in team" + }, { "id": "api.command.team_mismatch.app_error", "translation": "Unable to update commands across teams." @@ -5074,6 +5078,10 @@ "id": "app.command.updatecommand.internal_error", "translation": "Unable to update the command." }, + { + "id": "app.command.validatecommandtriggeruniqueness.internal_error", + "translation": "The specified trigger keyword already exists." + }, { "id": "app.command_webhook.create_command_webhook.existing", "translation": "You cannot update an existing CommandWebhook." @@ -8650,7 +8658,7 @@ }, { "id": "ent.saml.update_cpa.multiple_errors", - "translation": "Error(s) updating CPA values" + "translation": "Error(s) patching CPA value" }, { "id": "ent.user.complete_switch_with_oauth.blank_email.app_error",
server/public/model/command.go+4 −1 modified@@ -5,6 +5,7 @@ package model import ( "net/http" + "regexp" "strings" ) @@ -15,6 +16,8 @@ const ( MaxTriggerLength = 128 ) +var validCommandTriggerChars = regexp.MustCompile(`^[A-Za-z0-9_./-]+$`) + type Command struct { Id string `json:"id"` Token string `json:"token"` @@ -96,7 +99,7 @@ func (o *Command) IsValid() *AppError { return NewAppError("Command.IsValid", "model.command.is_valid.team_id.app_error", nil, "", http.StatusBadRequest) } - if len(o.Trigger) < MinTriggerLength || len(o.Trigger) > MaxTriggerLength || strings.Index(o.Trigger, "/") == 0 || strings.Contains(o.Trigger, " ") { + if len(o.Trigger) < MinTriggerLength || len(o.Trigger) > MaxTriggerLength || strings.Index(o.Trigger, "/") == 0 || !validCommandTriggerChars.MatchString(o.Trigger) { return NewAppError("Command.IsValid", "model.command.is_valid.trigger.app_error", nil, "", http.StatusBadRequest) }
server/public/model/command_test.go+25 −0 modified@@ -72,6 +72,31 @@ func TestCommandIsValid(t *testing.T) { o.Trigger = strings.Repeat("1", 128) require.Nil(t, o.IsValid()) + validTriggers := []string{"abc", "ABC", "abc123", "a_b-c.d/e"} + for _, trigger := range validTriggers { + o.Trigger = trigger + require.Nil(t, o.IsValid(), "trigger should be valid: %q", trigger) + } + + invalidTriggers := []string{ + " trigger", + "tri gger", + "tri\tger", + "tri\nger", + "tri\rger", + "tri\x00ger", + "/trigger", + "tri?ger", + "tri*ger", + "trígger", + "trigger😀", + } + for _, trigger := range invalidTriggers { + o.Trigger = trigger + require.NotNil(t, o.IsValid(), "trigger should be invalid: %q", trigger) + } + + o.Trigger = "trigger" o.URL = "" require.NotNil(t, o.IsValid(), "should be invalid")
305be3213480encode special characters on some error pages (#35492) (#35652)
2 files changed · +47 −4
server/channels/utils/api.go+4 −4 modified@@ -98,7 +98,7 @@ func RenderMobileAuthComplete(w http.ResponseWriter, redirectURL string) { <h2> `+i18n.T("api.oauth.auth_complete")+` </h2> <p id="redirecting-message"> `+i18n.T("api.oauth.redirecting_back")+` </p> <p id="close-tab-message" style="display: none"> `+i18n.T("api.oauth.close_browser")+` </p> - <p> `+i18n.T("api.oauth.click_redirect", model.StringInterface{"Link": link})+` </p> + <p> `+string(i18n.TranslateAsHTML(i18n.T, "api.oauth.click_redirect", map[string]any{"Link": redirectURL}))+` </p> <meta http-equiv="refresh" content="2; url=`+link+`"> <script> window.onload = function() { @@ -115,17 +115,17 @@ func RenderMobileError(config *model.Config, w http.ResponseWriter, err *model.A var link = template.HTMLEscapeString(redirectURL) u, redirectErr := url.Parse(redirectURL) if redirectErr != nil || !slices.Contains(config.NativeAppSettings.AppCustomURLSchemes, u.Scheme) { - link = *config.ServiceSettings.SiteURL + link = template.HTMLEscapeString(*config.ServiceSettings.SiteURL) } RenderMobileMessage(w, ` <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 512" style="width: 64px; height: 64px; fill: #ccc"> <!-- Font Awesome Free 5.15.3 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) --> <path d="M569.517 440.013C587.975 472.007 564.806 512 527.94 512H48.054c-36.937 0-59.999-40.055-41.577-71.987L246.423 23.985c18.467-32.009 64.72-31.951 83.154 0l239.94 416.028zM288 354c-25.405 0-46 20.595-46 46s20.595 46 46 46 46-20.595 46-46-20.595-46-46-46zm-43.673-165.346l7.418 136c.347 6.364 5.609 11.346 11.982 11.346h48.546c6.373 0 11.635-4.982 11.982-11.346l7.418-136c.375-6.874-5.098-12.654-11.982-12.654h-63.383c-6.884 0-12.356 5.78-11.981 12.654z"/> </svg> <h2> `+i18n.T("error")+` </h2> - <p> `+err.Message+` </p> + <p> `+template.HTMLEscapeString(err.Message)+` </p> <a href="`+link+`"> - `+i18n.T("api.back_to_app", map[string]any{"SiteName": config.TeamSettings.SiteName})+` + `+string(i18n.TranslateAsHTML(i18n.T, "api.back_to_app", map[string]any{"SiteName": config.TeamSettings.SiteName}))+` </a> `) }
server/channels/utils/api_test.go+43 −0 modified@@ -14,12 +14,14 @@ import ( "net/http" "net/http/httptest" "net/url" + "strings" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/mattermost/mattermost/server/public/model" + "github.com/mattermost/mattermost/server/public/shared/i18n" ) func TestRenderWebError(t *testing.T) { @@ -49,3 +51,44 @@ func TestRenderWebError(t *testing.T) { h := sha256.Sum256([]byte("/error?foo=bar")) assert.True(t, ecdsa.Verify(&key.PublicKey, h[:], rs.R, rs.S)) } + +func TestRenderMobileError(t *testing.T) { + require.NoError(t, i18n.TranslationsPreInitFromFileBytes("en.json", []byte(`[{"id":"api.back_to_app","translation":"Back to {{.SiteName}}"}]`))) + + cfg := &model.Config{} + cfg.SetDefaults() + *cfg.ServiceSettings.SiteURL = "http://localhost:8065" + *cfg.TeamSettings.SiteName = "Mattermost<test>" + cfg.NativeAppSettings.AppCustomURLSchemes = []string{"mattermost"} + + appErr := model.NewAppError("test", "api.test.error", nil, "details", http.StatusBadRequest) + appErr.Message = "Something went <wrong>" + + t.Run("renders html with special characters encoded in site name", func(t *testing.T) { + w := httptest.NewRecorder() + RenderMobileError(cfg, w, appErr, "mattermost://auth/complete") + + body := w.Body.String() + assert.Equal(t, http.StatusOK, w.Code) + assert.Contains(t, body, "Mattermost<test>") + assert.NotContains(t, body, "Mattermost<test>") + }) + + t.Run("renders html with special characters encoded in error message", func(t *testing.T) { + w := httptest.NewRecorder() + RenderMobileError(cfg, w, appErr, "mattermost://auth/complete") + + body := w.Body.String() + assert.Contains(t, body, "Something went <wrong>") + assert.NotContains(t, body, "Something went <wrong>") + }) + + t.Run("falls back to site url for invalid redirect scheme", func(t *testing.T) { + w := httptest.NewRecorder() + RenderMobileError(cfg, w, appErr, "https://evil.example.com/callback") + + body := w.Body.String() + assert.Contains(t, body, "http://localhost:8065") + assert.False(t, strings.Contains(body, "evil.example.com")) + }) +}
Vulnerability mechanics
Root cause
"Failure to escape user-controllable site configuration variables during error page composition allows stored cross-site scripting."
Attack vector
An attacker with high privileges (access to edit site configuration) can inject malicious JavaScript into configuration values that are not escaped when rendered in error pages. The advisory states that Mattermost versions 11.5.x <= 11.5.1 and 10.11.x <= 10.11.13 fail to escape some variables that could contain malicious content during error page composition [CWE-79]. The CVSS vector (AV:N/AC:L/PR:H/UI:N/S:U/C:L/I:L/A:N) indicates the attack is network-based, requires high privileges (site configuration edit access), and does not require user interaction. When the error page is served to other users, the injected script executes in their browser context.
Affected code
The advisory does not specify the exact files where error page composition occurs. The patches in the bundle modify server/channels/app/command.go (trigger uniqueness validation), server/channels/api4/command.go (permission checks for moveCommand), and test files [patch_id=1009797][patch_id=1009798][patch_id=1009799]. The actual escaping fix for error page variables is not shown in the provided patch diffs.
What the fix does
The patches shown in the bundle primarily address permission enforcement for slash command operations (update, move) and add trigger uniqueness validation [patch_id=1009797][patch_id=1009798][patch_id=1009799]. However, the advisory describes the root cause as failure to escape variables during error page composition. The patches do not directly show the escaping fix; the advisory and CWE-79 citation indicate the fix involves proper neutralization of user-controllable input before it is placed in error page output. The permission-related changes in the patches prevent unauthorized command modifications, which reduces the attack surface for injecting malicious content through command configuration.
Preconditions
- authAttacker must have high privileges to edit site configuration values
- inputThe injected configuration value must be rendered in an error page served to other users
- networkNetwork access to the Mattermost server is required
Generated on May 21, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
1- mattermost.com/security-updatesnvdVendor Advisory
News mentions
0No linked articles in our index yet.