CVE-2026-2325
Description
Mattermost versions 11.5.x <= 11.5.1, 10.11.x <= 10.11.13, 11.4.x <= 11.4.3 fail to limit the size of the request body on the start meeting API endpoint, which allows an authenticated attacker to cause resource exhaustion or denial of service via a crafted oversized HTTP POST request to {{/api/v1/meetings}}.. Mattermost Advisory ID: MMSA-2026-00608
AI Insight
LLM-synthesized narrative grounded in this CVE's description and references.
Mattermost fails to limit request body size on the start meeting API, allowing authenticated attackers to cause resource exhaustion or denial of service.
Vulnerability
Mattermost versions 11.5.x up to and including 11.5.1, 10.11.x up to and including 10.11.13, and 11.4.x up to and including 11.4.3 fail to enforce a maximum request body size on the start meeting API endpoint (/api/v1/meetings). This allows an authenticated attacker to send a crafted oversized HTTP POST request, leading to resource exhaustion or denial of service [1].
Exploitation
An attacker must be authenticated to Mattermost and able to reach the vulnerable endpoint. The attack involves sending a single oversized HTTP POST request to the start meeting API with an excessively large body. No special privileges or user interaction beyond standard authentication are required. The crafted request consumes server resources (memory/CPU) beyond expected limits [1].
Impact
Successful exploitation results in resource exhaustion, leading to a denial of service condition for the affected Mattermost server. The confidentiality and integrity of data are not compromised, but availability is degraded or completely interrupted [1].
Mitigation
Mattermost has addressed this vulnerability in versions 11.5.2, 10.11.14, and 11.4.4. Organizations should upgrade to these patched versions. No workarounds are documented in the reference. This CVE is not listed on CISA's Known Exploited Vulnerabilities (KEV) catalog [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.x <= 11.5.1, 10.11.x <= 10.11.13, 11.4.x <= 11.4.3
Patches
1574401e6c3e3[MM-67377] Fix (#35336) (#35646)
8 files changed · +181 −17
server/channels/api4/command_test.go+78 −0 modified@@ -325,6 +325,52 @@ func TestUpdateCommand(t *testing.T) { 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) { @@ -511,6 +557,38 @@ func TestMoveCommand(t *testing.T) { 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/app/command.go+34 −16 modified@@ -687,22 +687,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) @@ -719,6 +705,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) @@ -752,6 +762,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 @@ -770,6 +784,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@@ -1326,6 +1326,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@@ -2651,6 +2651,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+4 −0 modified@@ -5370,6 +5370,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."
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")
Vulnerability mechanics
Root cause
"The start meeting API endpoint does not limit the size of the request body, allowing an authenticated attacker to send an oversized HTTP POST request and exhaust server resources."
Attack vector
An authenticated attacker sends a crafted oversized HTTP POST request to the `/api/v1/meetings` endpoint. Because the endpoint fails to enforce any limit on the request body size [CWE-770], the server allocates resources proportional to the payload without throttling. A sufficiently large payload can exhaust memory or CPU, leading to denial of service for other users. The attack requires only low-privilege authentication and is performed over the network.
Affected code
The advisory identifies the start meeting API endpoint at `/api/v1/meetings` as the vulnerable code path. The patch bundle does not include the specific server-side file or function where the request body size limit was added. The patches provided instead modify `server/channels/app/command.go`, `server/channels/api4/command_test.go`, `server/public/model/command.go`, and related test files to improve command trigger validation.
What the fix does
The advisory states that the fix limits the request body size on the start meeting API endpoint, but the supplied patch bundle does not contain the actual server-side fix for the `/api/v1/meetings` endpoint. The patches shown instead refactor command trigger uniqueness validation into a shared helper (`validateCommandTriggerUniqueness`) and add stricter trigger character validation via a regex in `command.go`. These changes are unrelated to the body-size limit issue described in the CVE. The advisory does not include the specific diff that adds the request body size limit.
Preconditions
- authThe attacker must be authenticated to the Mattermost instance.
- networkThe attacker must be able to send HTTP POST requests to the /api/v1/meetings endpoint.
- configNo restriction on request body size is enforced by the server on that endpoint.
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.