Mattermost Jira plugin user spoofing enables Jira request forgery.
Description
Mattermost versions 11.1.x <= 11.1.0, 11.0.x <= 11.0.5, 10.12.x <= 10.12.3, 10.11.x <= 10.11.7 with the Jira plugin enabled and Mattermost Jira plugin versions <=4.4.0 fail to enforce authentication and issue-key path restrictions in the Jira plugin, which allows an unauthenticated attacker who knows a valid user ID to issue authenticated GET and POST requests to the Jira server via crafted plugin payloads that spoof the user ID and inject arbitrary issue key paths. Mattermost Advisory ID: MMSA-2025-00555
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
github.com/mattermost/mattermost/server/v8Go | < 8.0.0-20251121122154-b57c297c6d7a | 8.0.0-20251121122154-b57c297c6d7a |
github.com/mattermost/mattermost-plugin-jiraGo | < 4.4.1 | 4.4.1 |
Affected products
1- Range: @mattermost/client@10.11.0, @mattermost/client@10.12.0, @mattermost/client@11.0.4, …
Patches
6317025c411ecUpdate Jira prepackaged (#34551) (#34569)
1 file changed · +1 −1
server/Makefile+1 −1 modified@@ -155,7 +155,7 @@ PLUGIN_PACKAGES ?= $(PLUGIN_PACKAGES:) PLUGIN_PACKAGES += mattermost-plugin-calls-v1.11.0 PLUGIN_PACKAGES += mattermost-plugin-github-v2.5.0 PLUGIN_PACKAGES += mattermost-plugin-gitlab-v1.11.0 -PLUGIN_PACKAGES += mattermost-plugin-jira-v4.4.0 +PLUGIN_PACKAGES += mattermost-plugin-jira-v4.4.1 PLUGIN_PACKAGES += mattermost-plugin-playbooks-v2.5.1 PLUGIN_PACKAGES += mattermost-plugin-servicenow-v2.3.4 PLUGIN_PACKAGES += mattermost-plugin-zoom-v1.8.0
92b1e705225dUpdate Jira prepackaged (#34551) (#34568)
1 file changed · +1 −1
server/Makefile+1 −1 modified@@ -155,7 +155,7 @@ PLUGIN_PACKAGES ?= $(PLUGIN_PACKAGES:) PLUGIN_PACKAGES += mattermost-plugin-calls-v1.11.0 PLUGIN_PACKAGES += mattermost-plugin-github-v2.4.0 PLUGIN_PACKAGES += mattermost-plugin-gitlab-v1.10.0 -PLUGIN_PACKAGES += mattermost-plugin-jira-v4.3.0 +PLUGIN_PACKAGES += mattermost-plugin-jira-v4.4.1 PLUGIN_PACKAGES += mattermost-plugin-playbooks-v2.4.2 PLUGIN_PACKAGES += mattermost-plugin-servicenow-v2.3.4 PLUGIN_PACKAGES += mattermost-plugin-zoom-v1.8.0
7c36acb68ce3Update Jira prepackaged (#34551) (#34570)
1 file changed · +1 −1
server/Makefile+1 −1 modified@@ -145,7 +145,7 @@ PLUGIN_PACKAGES ?= $(PLUGIN_PACKAGES:) PLUGIN_PACKAGES += mattermost-plugin-calls-v1.11.0 PLUGIN_PACKAGES += mattermost-plugin-github-v2.5.0 PLUGIN_PACKAGES += mattermost-plugin-gitlab-v1.10.0 -PLUGIN_PACKAGES += mattermost-plugin-jira-v4.3.0 +PLUGIN_PACKAGES += mattermost-plugin-jira-v4.4.1 # We need to prepackage both versions of playbooks and install the correct one based on the server license. See MM-60025. PLUGIN_PACKAGES += mattermost-plugin-playbooks-v1.41.1 PLUGIN_PACKAGES += mattermost-plugin-playbooks-v2.3.0
463e0d0d3930Update Jira prepackaged (#34551) (#34567)
1 file changed · +1 −1
server/Makefile+1 −1 modified@@ -145,7 +145,7 @@ PLUGIN_PACKAGES ?= $(PLUGIN_PACKAGES:) PLUGIN_PACKAGES += mattermost-plugin-calls-v1.11.0 PLUGIN_PACKAGES += mattermost-plugin-github-v2.4.0 PLUGIN_PACKAGES += mattermost-plugin-gitlab-v1.10.0 -PLUGIN_PACKAGES += mattermost-plugin-jira-v4.3.0 +PLUGIN_PACKAGES += mattermost-plugin-jira-v4.4.1 # We need to prepackage both versions of playbooks and install the correct one based on the server license. See MM-60025. PLUGIN_PACKAGES += mattermost-plugin-playbooks-v1.41.1 PLUGIN_PACKAGES += mattermost-plugin-playbooks-v2.3.0
b57c297c6d7aUpdate Jira prepackaged (#34551)
1 file changed · +1 −1
server/Makefile+1 −1 modified@@ -155,7 +155,7 @@ PLUGIN_PACKAGES ?= $(PLUGIN_PACKAGES:) PLUGIN_PACKAGES += mattermost-plugin-calls-v1.11.0 PLUGIN_PACKAGES += mattermost-plugin-github-v2.5.0 PLUGIN_PACKAGES += mattermost-plugin-gitlab-v1.11.0 -PLUGIN_PACKAGES += mattermost-plugin-jira-v4.4.0 +PLUGIN_PACKAGES += mattermost-plugin-jira-v4.4.1 PLUGIN_PACKAGES += mattermost-plugin-playbooks-v2.6.0 PLUGIN_PACKAGES += mattermost-plugin-servicenow-v2.4.0 PLUGIN_PACKAGES += mattermost-plugin-zoom-v1.10.0
bf9a1b7e81ebfeat: Secure Jira post actions against forged requests (#1243)
2 files changed · +350 −51
server/issue.go+76 −7 modified@@ -22,6 +22,8 @@ import ( "github.com/mattermost/mattermost-plugin-jira/server/utils/types" ) +var issueKeyRegexp = regexp.MustCompile(`^[A-Z][A-Z0-9_]+-\d+$`) + const ( assigneeField = "assignee" labelsField = "labels" @@ -62,6 +64,11 @@ func makePost(userID, channelID, message string) *model.Post { } func (p *Plugin) httpShareIssuePublicly(w http.ResponseWriter, r *http.Request) (int, error) { + authenticatedUserID := strings.TrimSpace(r.Header.Get(headerMattermostUserID)) + if authenticatedUserID == "" { + return respondErr(w, http.StatusUnauthorized, errors.New("missing Mattermost-User-ID header")) + } + var requestData model.PostActionIntegrationRequest err := json.NewDecoder(r.Body).Decode(&requestData) if err != nil { @@ -71,18 +78,47 @@ func (p *Plugin) httpShareIssuePublicly(w http.ResponseWriter, r *http.Request) jiraBotID := p.getUserID() channelID := requestData.ChannelId - mattermostUserID := requestData.UserId - if mattermostUserID == "" { - return p.respondErrWithFeedback(mattermostUserID, makePost(jiraBotID, channelID, + + postID := strings.TrimSpace(requestData.PostId) + if postID == "" { + return p.respondErrWithFeedback(authenticatedUserID, makePost(jiraBotID, channelID, + "missing post id"), w, http.StatusBadRequest) + } + + originalPost, appErr := p.client.Post.GetPost(postID) + if appErr != nil || originalPost == nil { + return p.respondErrWithFeedback(authenticatedUserID, makePost(jiraBotID, channelID, + "post not found"), w, http.StatusNotFound) + } + if originalPost.UserId != jiraBotID { + return p.respondErrWithFeedback(authenticatedUserID, makePost(jiraBotID, channelID, "user not authorized"), w, http.StatusUnauthorized) } + if originalPost.ChannelId != channelID { + return p.respondErrWithFeedback(authenticatedUserID, makePost(jiraBotID, channelID, + "channel mismatch"), w, http.StatusBadRequest) + } + + if requestData.UserId != "" && requestData.UserId != authenticatedUserID { + p.client.Log.Warn("share issue payload user mismatch", + "header_user_id", authenticatedUserID, + "payload_user_id", requestData.UserId) + } + + mattermostUserID := authenticatedUserID + val := requestData.Context["issue_key"] issueKey, ok := val.(string) if !ok { return p.respondErrWithFeedback(mattermostUserID, makePost(jiraBotID, channelID, "No issue key was found in context data"), w, http.StatusInternalServerError) } + issueKey = strings.ToUpper(strings.TrimSpace(issueKey)) + if !issueKeyRegexp.MatchString(issueKey) { + return p.respondErrWithFeedback(mattermostUserID, makePost(jiraBotID, channelID, + "invalid issue key"), w, http.StatusBadRequest) + } val = requestData.Context["instance_id"] instanceID, ok := val.(string) @@ -97,7 +133,7 @@ func (p *Plugin) httpShareIssuePublicly(w http.ResponseWriter, r *http.Request) "No connection could be loaded with given params"), w, http.StatusInternalServerError) } - attachment, err := p.getIssueAsSlackAttachment(instance, connection, strings.ToUpper(issueKey), false) + attachment, err := p.getIssueAsSlackAttachment(instance, connection, issueKey, false) if err != nil { return p.respondErrWithFeedback(mattermostUserID, makePost(jiraBotID, channelID, "Could not get issue as slack attachment"), w, http.StatusInternalServerError) @@ -123,6 +159,11 @@ func (p *Plugin) httpShareIssuePublicly(w http.ResponseWriter, r *http.Request) } func (p *Plugin) httpTransitionIssuePostAction(w http.ResponseWriter, r *http.Request) (int, error) { + authenticatedUserID := strings.TrimSpace(r.Header.Get(headerMattermostUserID)) + if authenticatedUserID == "" { + return respondErr(w, http.StatusUnauthorized, errors.New("missing Mattermost-User-ID header")) + } + var requestData model.PostActionIntegrationRequest err := json.NewDecoder(r.Body).Decode(&requestData) if err != nil { @@ -133,18 +174,46 @@ func (p *Plugin) httpTransitionIssuePostAction(w http.ResponseWriter, r *http.Re jiraBotID := p.getUserID() channelID := requestData.ChannelId - mattermostUserID := requestData.UserId - if mattermostUserID == "" { - return p.respondErrWithFeedback(mattermostUserID, makePost(jiraBotID, channelID, + postID := strings.TrimSpace(requestData.PostId) + if postID == "" { + return p.respondErrWithFeedback(authenticatedUserID, makePost(jiraBotID, channelID, + "missing post id"), w, http.StatusBadRequest) + } + + originalPost, appErr := p.client.Post.GetPost(postID) + if appErr != nil || originalPost == nil { + return p.respondErrWithFeedback(authenticatedUserID, makePost(jiraBotID, channelID, + "post not found"), w, http.StatusNotFound) + } + if originalPost.UserId != jiraBotID { + return p.respondErrWithFeedback(authenticatedUserID, makePost(jiraBotID, channelID, "user not authorized"), w, http.StatusUnauthorized) } + if originalPost.ChannelId != channelID { + return p.respondErrWithFeedback(authenticatedUserID, makePost(jiraBotID, channelID, + "channel mismatch"), w, http.StatusBadRequest) + } + + if requestData.UserId != "" && requestData.UserId != authenticatedUserID { + p.client.Log.Warn("transition payload user mismatch", + "header_user_id", authenticatedUserID, + "payload_user_id", requestData.UserId) + } + + mattermostUserID := authenticatedUserID + val := requestData.Context["issue_key"] issueKey, ok := val.(string) if !ok { return p.respondErrWithFeedback(mattermostUserID, makePost(jiraBotID, channelID, "No issue key was found in context data"), w, http.StatusInternalServerError) } + issueKey = strings.ToUpper(strings.TrimSpace(issueKey)) + if !issueKeyRegexp.MatchString(issueKey) { + return p.respondErrWithFeedback(mattermostUserID, makePost(jiraBotID, channelID, + "invalid issue key"), w, http.StatusBadRequest) + } val = requestData.Context["selected_option"] toState, ok := val.(string)
server/issue_test.go+274 −44 modified@@ -185,125 +185,355 @@ func TestTransitionJiraIssue(t *testing.T) { } func TestRouteIssueTransition(t *testing.T) { + const ( + headerUserID = "user-id" + botUserID = "jira-bot" + validPostID = "valid-post" + wrongAuthorPostID = "wrong-author-post" + missingPostID = "missing-post" + wrongChannelPostID = "wrong-channel-post" + ) + api := &plugintest.API{} api.On("SendEphemeralPost", mock.AnythingOfType("string"), mock.AnythingOfType("*model.Post")).Return(&model.Post{}) - api.On("LogWarn", "ERROR: ", "Status", "401", "Error", "", "Path", "/api/v2/transition", "Method", "POST", "query", "").Return(nil) - api.On("LogWarn", "ERROR: ", "Status", "500", "Error", "", "Path", "/api/v2/transition", "Method", "POST", "query", "").Return(nil) - api.On("LogWarn", "Recovered from a panic", "url", "/api/v2/transition", "error", mock.Anything, "stack", mock.Anything).Return(nil) + api.On("LogWarn", "ERROR: ", "Status", "401", "Error", "", "Path", "/api/v2/transition", "Method", "POST", "query", "").Return(nil).Maybe() + api.On("LogWarn", "ERROR: ", "Status", "400", "Error", "", "Path", "/api/v2/transition", "Method", "POST", "query", "").Return(nil).Maybe() + api.On("LogWarn", "ERROR: ", "Status", "404", "Error", "", "Path", "/api/v2/transition", "Method", "POST", "query", "").Return(nil).Maybe() + api.On("LogWarn", "ERROR: ", "Status", "500", "Error", "", "Path", "/api/v2/transition", "Method", "POST", "query", "").Return(nil).Maybe() + api.On("LogWarn", "Recovered from a panic", "url", "/api/v2/transition", "error", mock.Anything, "stack", mock.Anything).Return(nil).Maybe() + api.On("LogWarn", "transition payload user mismatch", "header_user_id", headerUserID, "payload_user_id", "different-user").Return().Maybe() + api.On("GetPost", validPostID).Return(&model.Post{Id: validPostID, UserId: botUserID, ChannelId: "channel-id"}, nil) + api.On("GetPost", wrongAuthorPostID).Return(&model.Post{Id: wrongAuthorPostID, UserId: "not-bot"}, nil) + api.On("GetPost", missingPostID).Return(nil, (*model.AppError)(nil)) + api.On("GetPost", wrongChannelPostID).Return(&model.Post{Id: wrongChannelPostID, UserId: botUserID, ChannelId: "different-channel"}, nil) + api.On("DeleteEphemeralPost", mock.AnythingOfType("string"), mock.AnythingOfType("string")).Return(nil).Maybe() p := setupTestPlugin(api) + p.updateConfig(func(conf *config) { + conf.botUserID = botUserID + }) - for name, tt := range map[string]struct { - bb []byte + cases := map[string]struct { + header string request *model.PostActionIntegrationRequest expectedCode int }{ - "No request data": { + "Missing header": { + header: "", request: nil, expectedCode: http.StatusUnauthorized, }, - "No UserID": { + "Missing post id": { + header: headerUserID, + request: &model.PostActionIntegrationRequest{ + UserId: headerUserID, + ChannelId: "channel-id", + PostId: "", + }, + expectedCode: http.StatusBadRequest, + }, + "Post not found": { + header: headerUserID, request: &model.PostActionIntegrationRequest{ - UserId: "", + UserId: headerUserID, + ChannelId: "channel-id", + PostId: missingPostID, + Context: map[string]interface{}{ + "issue_key": "TEST-10", + "selected_option": "31", + "instance_id": testInstance1.InstanceID.String(), + }, + }, + expectedCode: http.StatusNotFound, + }, + "Post not from Jira bot": { + header: headerUserID, + request: &model.PostActionIntegrationRequest{ + UserId: headerUserID, + ChannelId: "channel-id", + PostId: wrongAuthorPostID, + Context: map[string]interface{}{ + "issue_key": "TEST-10", + "selected_option": "31", + "instance_id": testInstance1.InstanceID.String(), + }, }, expectedCode: http.StatusUnauthorized, }, - "No issueKey": { + "Channel mismatch": { + header: headerUserID, request: &model.PostActionIntegrationRequest{ - UserId: "userID", + UserId: headerUserID, + ChannelId: "channel-id", + PostId: wrongChannelPostID, + Context: map[string]interface{}{ + "issue_key": "TEST-10", + "selected_option": "31", + "instance_id": testInstance1.InstanceID.String(), + }, + }, + expectedCode: http.StatusBadRequest, + }, + "Invalid issue key": { + header: headerUserID, + request: &model.PostActionIntegrationRequest{ + UserId: headerUserID, + ChannelId: "channel-id", + PostId: validPostID, + Context: map[string]interface{}{ + "issue_key": "bad", + "selected_option": "31", + "instance_id": testInstance1.InstanceID.String(), + }, + }, + expectedCode: http.StatusBadRequest, + }, + "Missing transition option": { + header: headerUserID, + request: &model.PostActionIntegrationRequest{ + UserId: headerUserID, + ChannelId: "channel-id", + PostId: validPostID, + Context: map[string]interface{}{ + "issue_key": "TEST-10", + "instance_id": testInstance1.InstanceID.String(), + }, }, expectedCode: http.StatusInternalServerError, }, - "No selected_option": { + "Missing instance id": { + header: headerUserID, request: &model.PostActionIntegrationRequest{ - UserId: "userID", - Context: map[string]interface{}{"issueKey": "Some-Key"}, + UserId: headerUserID, + ChannelId: "channel-id", + PostId: validPostID, + Context: map[string]interface{}{ + "issue_key": "TEST-10", + "selected_option": "31", + }, }, expectedCode: http.StatusInternalServerError, }, - } { + "Payload mismatch allowed": { + header: headerUserID, + request: &model.PostActionIntegrationRequest{ + UserId: "different-user", + ChannelId: "channel-id", + PostId: validPostID, + Context: map[string]interface{}{ + "issue_key": "TEST-10", + "selected_option": "31", + "instance_id": testInstance1.InstanceID.String(), + }, + }, + expectedCode: http.StatusInternalServerError, + }, + "Happy Path": { + header: headerUserID, + request: &model.PostActionIntegrationRequest{ + UserId: headerUserID, + ChannelId: "channel-id", + PostId: validPostID, + Context: map[string]interface{}{ + "issue_key": "TEST-10", + "selected_option": "31", + "instance_id": testInstance1.InstanceID.String(), + }, + }, + expectedCode: http.StatusInternalServerError, + }, + } + + for name, tt := range cases { t.Run(name, func(t *testing.T) { - bb, err := json.Marshal(tt.request) - assert.Nil(t, err) + body := "" + if tt.request != nil { + bb, err := json.Marshal(tt.request) + assert.Nil(t, err) + body = string(bb) + } + + req := httptest.NewRequest("POST", makeAPIRoute(routeIssueTransition), strings.NewReader(body)) + if tt.header != "" { + req.Header.Set(headerMattermostUserID, tt.header) + } - request := httptest.NewRequest("POST", makeAPIRoute(routeIssueTransition), strings.NewReader(string(bb))) w := httptest.NewRecorder() - p.ServeHTTP(&plugin.Context{}, w, request) - assert.Equal(t, tt.expectedCode, w.Result().StatusCode, "no request data") + p.ServeHTTP(&plugin.Context{}, w, req) + assert.Equal(t, tt.expectedCode, w.Result().StatusCode, "status code mismatch") }) } } func TestRouteShareIssuePublicly(t *testing.T) { - validUserID := "1" + const ( + headerUserID = "1" + botUserID = "jira-bot" + validPostID = "valid-post" + wrongAuthorPostID = "wrong-author-post" + missingPostID = "missing-post" + wrongChannelPostID = "wrong-channel-post" + ) + api := &plugintest.API{} api.On("SendEphemeralPost", mock.AnythingOfType("string"), mock.AnythingOfType("*model.Post")).Return(&model.Post{}) api.On("CreatePost", mock.AnythingOfType("*model.Post")).Return(&model.Post{}, nil) - api.On("DeleteEphemeralPost", validUserID, "").Return() - api.On("LogWarn", mock.AnythingOfType("string"), mock.AnythingOfType("string"), mock.AnythingOfType("string"), mock.AnythingOfType("string"), mock.AnythingOfType("string"), mock.AnythingOfType("string"), mock.AnythingOfType("string")).Return() - api.On("LogWarn", mock.AnythingOfType("string"), mock.AnythingOfType("string"), mock.AnythingOfType("string"), mock.AnythingOfType("string"), mock.AnythingOfType("string"), mock.AnythingOfType("string"), mock.AnythingOfType("string"), mock.AnythingOfType("string"), mock.AnythingOfType("string"), mock.AnythingOfType("string"), mock.AnythingOfType("string")).Return() + api.On("DeleteEphemeralPost", mock.AnythingOfType("string"), mock.AnythingOfType("string")).Return(nil).Maybe() + api.On("GetPost", validPostID).Return(&model.Post{Id: validPostID, UserId: botUserID, ChannelId: "channel-id"}, nil) + api.On("GetPost", wrongAuthorPostID).Return(&model.Post{Id: wrongAuthorPostID, UserId: "someone-else"}, nil) + api.On("GetPost", missingPostID).Return(nil, (*model.AppError)(nil)) + api.On("GetPost", wrongChannelPostID).Return(&model.Post{Id: wrongChannelPostID, UserId: botUserID, ChannelId: "different-channel"}, nil) + api.On("LogWarn", "share issue payload user mismatch", "header_user_id", headerUserID, "payload_user_id", "someone-else").Return().Maybe() + api.On("LogWarn", "ERROR: ", "Status", "400", "Error", "", "Path", "/api/v2/share-issue-publicly", "Method", "POST", "query", "").Return(nil).Maybe() + api.On("LogWarn", "ERROR: ", "Status", "404", "Error", "", "Path", "/api/v2/share-issue-publicly", "Method", "POST", "query", "").Return(nil).Maybe() + api.On("LogWarn", "ERROR: ", "Status", "500", "Error", "", "Path", "/api/v2/share-issue-publicly", "Method", "POST", "query", "").Return(nil).Maybe() + api.On("LogWarn", "Recovered from a panic", "url", "/api/v2/share-issue-publicly", "error", mock.Anything, "stack", mock.Anything).Return(nil).Maybe() p := setupTestPlugin(api) + p.updateConfig(func(conf *config) { + conf.botUserID = botUserID + }) - for name, tt := range map[string]struct { - bb []byte + cases := map[string]struct { + header string request *model.PostActionIntegrationRequest expectedCode int }{ - "No request data": { + "Missing header": { + header: "", request: nil, expectedCode: http.StatusUnauthorized, }, - "No UserID": { + "Missing post id": { + header: headerUserID, request: &model.PostActionIntegrationRequest{ - UserId: "", + UserId: headerUserID, + ChannelId: "channel-id", + PostId: "", + }, + expectedCode: http.StatusBadRequest, + }, + "Post not found": { + header: headerUserID, + request: &model.PostActionIntegrationRequest{ + UserId: headerUserID, + ChannelId: "channel-id", + PostId: missingPostID, + Context: map[string]interface{}{ + "issue_key": "TEST-10", + "instance_id": testInstance1.InstanceID.String(), + }, + }, + expectedCode: http.StatusNotFound, + }, + "Post not from Jira bot": { + header: headerUserID, + request: &model.PostActionIntegrationRequest{ + UserId: headerUserID, + ChannelId: "channel-id", + PostId: wrongAuthorPostID, + Context: map[string]interface{}{ + "issue_key": "TEST-10", + "instance_id": testInstance1.InstanceID.String(), + }, }, expectedCode: http.StatusUnauthorized, }, - "No issueKey": { + "Channel mismatch": { + header: headerUserID, request: &model.PostActionIntegrationRequest{ - UserId: "userID", + UserId: headerUserID, + ChannelId: "channel-id", + PostId: wrongChannelPostID, + Context: map[string]interface{}{ + "issue_key": "TEST-10", + "instance_id": testInstance1.InstanceID.String(), + }, + }, + expectedCode: http.StatusBadRequest, + }, + "Missing issue key": { + header: headerUserID, + request: &model.PostActionIntegrationRequest{ + UserId: headerUserID, + ChannelId: "channel-id", + PostId: validPostID, + Context: map[string]interface{}{ + "instance_id": testInstance1.InstanceID.String(), + }, }, expectedCode: http.StatusInternalServerError, }, - "No instanceId": { + "Invalid issue key": { + header: headerUserID, + request: &model.PostActionIntegrationRequest{ + UserId: headerUserID, + ChannelId: "channel-id", + PostId: validPostID, + Context: map[string]interface{}{ + "issue_key": "bad", + "instance_id": testInstance1.InstanceID.String(), + }, + }, + expectedCode: http.StatusBadRequest, + }, + "Missing instance id": { + header: headerUserID, request: &model.PostActionIntegrationRequest{ - UserId: "userID", + UserId: headerUserID, + ChannelId: "channel-id", + PostId: validPostID, Context: map[string]interface{}{ "issue_key": "TEST-10", }, }, expectedCode: http.StatusInternalServerError, }, - "No connection": { + "Happy Path": { + header: headerUserID, request: &model.PostActionIntegrationRequest{ - UserId: "userID", + UserId: headerUserID, + ChannelId: "channel-id", + PostId: validPostID, Context: map[string]interface{}{ "issue_key": "TEST-10", - "instance_id": "id", + "instance_id": testInstance1.InstanceID.String(), }, }, - expectedCode: http.StatusInternalServerError, + expectedCode: http.StatusOK, }, - "Happy Path": { + "Payload mismatch allowed": { + header: headerUserID, request: &model.PostActionIntegrationRequest{ - UserId: validUserID, + UserId: "someone-else", + ChannelId: "channel-id", + PostId: validPostID, Context: map[string]interface{}{ "issue_key": "TEST-10", "instance_id": testInstance1.InstanceID.String(), }, }, expectedCode: http.StatusOK, }, - } { + } + + for name, tt := range cases { t.Run(name, func(t *testing.T) { - bb, err := json.Marshal(tt.request) - assert.Nil(t, err) + body := "" + if tt.request != nil { + bb, err := json.Marshal(tt.request) + assert.Nil(t, err) + body = string(bb) + } + + req := httptest.NewRequest("POST", makeAPIRoute(routeSharePublicly), strings.NewReader(body)) + if tt.header != "" { + req.Header.Set(headerMattermostUserID, tt.header) + } - request := httptest.NewRequest("POST", makeAPIRoute(routeSharePublicly), strings.NewReader(string(bb))) w := httptest.NewRecorder() - p.ServeHTTP(&plugin.Context{}, w, request) - assert.Equal(t, tt.expectedCode, w.Result().StatusCode, "no request data") + p.ServeHTTP(&plugin.Context{}, w, req) + assert.Equal(t, tt.expectedCode, w.Result().StatusCode, "status code mismatch") }) } }
Vulnerability mechanics
Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
9- github.com/advisories/GHSA-qvmc-92vg-6r35ghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2025-14273ghsaADVISORY
- github.com/mattermost/mattermost-plugin-jira/commit/bf9a1b7e81eb83304056b397c6abab3b062e14a2ghsaWEB
- github.com/mattermost/mattermost/commit/317025c411ec8c34381fdd4f137a17c63895a4f2ghsaWEB
- github.com/mattermost/mattermost/commit/463e0d0d3930782d3c975da26c991dcbfccd751cghsaWEB
- github.com/mattermost/mattermost/commit/7c36acb68ce3c69defaea540623f794c84ecba93ghsaWEB
- github.com/mattermost/mattermost/commit/92b1e705225d97ce54d9f720f2e7aa66dc2a086bghsaWEB
- github.com/mattermost/mattermost/commit/b57c297c6d7ae6812d85e32a625806ac9555deeeghsaWEB
- mattermost.com/security-updatesghsaWEB
News mentions
0No linked articles in our index yet.