Moderate severityNVD Advisory· Published Feb 9, 2024· Updated Aug 21, 2024
Missing authorization allows users to access arbitrary security levels on Jira through webhooks (Jira Plugin)
CVE-2024-24774
Description
Mattermost Jira Plugin handling subscriptions fails to check the security level of an incoming issue or limit it based on the user who created the subscription resulting in registered users on Jira being able to create webhooks that give them access to all Jira issues.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
github.com/mattermost/mattermost-plugin-jiraGo | < 4.0.0-rc1 | 4.0.0-rc1 |
Affected products
1- Range: 0
Patches
15f5e084d169b[MM-44185] Add conditions for subscription validation (#858)
22 files changed · +828 −45
go.mod+1 −1 modified@@ -17,6 +17,7 @@ require ( github.com/rbriski/atlassian-jwt v0.0.0-20180307182949-7bb4ae273058 github.com/rudderlabs/analytics-go v3.3.2+incompatible github.com/stretchr/testify v1.8.0 + github.com/trivago/tgo v1.0.1 golang.org/x/oauth2 v0.0.0-20211104180415-d3ed0bb246c8 golang.org/x/text v0.3.7 ) @@ -75,7 +76,6 @@ require ( github.com/tidwall/match v1.1.1 // indirect github.com/tidwall/pretty v1.2.0 // indirect github.com/tinylib/msgp v1.1.6 // indirect - github.com/trivago/tgo v1.0.1 // indirect github.com/ulikunitz/xz v0.5.10 // indirect github.com/vmihailenco/msgpack/v5 v5.3.5 // indirect github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect
Makefile+0 −1 modified@@ -81,7 +81,6 @@ endif ## Ensures NPM dependencies are installed without having to run this all the time. webapp/.npminstall: ifneq ($(HAS_WEBAPP),) - git config --global url."ssh://git@".insteadOf git:// cd webapp && $(NPM) install touch $@ endif
plugin.json+8 −0 modified@@ -76,6 +76,14 @@ "placeholder": "", "default": "" }, + { + "key": "SecurityLevelEmptyForJiraSubscriptions", + "display_name": "Default Subscription Security Level to Empty:", + "type": "bool", + "help_text": "Subscriptions will only include issues that have a security level assigned if the appropriate security level has been included as a filter", + "placeholder": "", + "default": true + }, { "key": "JiraAdminAdditionalHelpText", "display_name": "Additional Help Text to be shown with Jira Help:",
server/issue.go+7 −6 modified@@ -22,12 +22,13 @@ import ( ) const ( - labelsField = "labels" - statusField = "status" - reporterField = "reporter" - priorityField = "priority" - descriptionField = "description" - resolutionField = "resolution" + labelsField = "labels" + statusField = "status" + reporterField = "reporter" + priorityField = "priority" + descriptionField = "description" + resolutionField = "resolution" + securityLevelField = "security" ) func makePost(userID, channelID, message string) *model.Post {
server/issue_test.go+23 −0 modified@@ -16,6 +16,7 @@ import ( "github.com/mattermost/mattermost-server/v6/plugin/plugintest/mock" "github.com/pkg/errors" "github.com/stretchr/testify/assert" + "github.com/trivago/tgo/tcontainer" "github.com/mattermost/mattermost-plugin-jira/server/utils/kvstore" ) @@ -85,6 +86,28 @@ func (client testClient) AddComment(issueKey string, comment *jira.Comment) (*ji return nil, nil } +func (client testClient) GetCreateMetaInfo(api plugin.API, options *jira.GetQueryOptions) (*jira.CreateMetaInfo, error) { + return &jira.CreateMetaInfo{ + Projects: []*jira.MetaProject{ + { + IssueTypes: []*jira.MetaIssueType{ + { + Fields: tcontainer.MarshalMap{ + "security": tcontainer.MarshalMap{ + "allowedValues": []interface{}{ + tcontainer.MarshalMap{ + "id": "10001", + }, + }, + }, + }, + }, + }, + }, + }, + }, nil +} + func TestTransitionJiraIssue(t *testing.T) { api := &plugintest.API{} api.On("SendEphemeralPost", mock.AnythingOfType("string"), mock.AnythingOfType("*model.Post")).Return(&model.Post{})
server/plugin.go+3 −0 modified@@ -70,6 +70,9 @@ type externalConfig struct { // Additional Help Text to be shown in the output of '/jira help' command JiraAdminAdditionalHelpText string + // When enabled, a subscription without security level rules will filter out an issue that has a security level assigned + SecurityLevelEmptyForJiraSubscriptions bool + // Hide issue descriptions and comments in Webhook and Subscription messages HideDecriptionComment bool
server/subscribe.go+115 −18 modified@@ -15,6 +15,8 @@ import ( "github.com/gorilla/mux" "github.com/pkg/errors" + "github.com/trivago/tgo/tcontainer" + "github.com/mattermost/mattermost-server/v6/model" "github.com/mattermost/mattermost-plugin-jira/server/utils" @@ -140,37 +142,61 @@ func (p *Plugin) matchesSubsciptionFilters(wh *webhook, filters SubscriptionFilt return false } - if filters.IssueTypes.Len() != 0 && !filters.IssueTypes.ContainsAny(wh.JiraWebhook.Issue.Fields.Type.ID) { + issue := &wh.JiraWebhook.Issue + + if filters.IssueTypes.Len() != 0 && !filters.IssueTypes.ContainsAny(issue.Fields.Type.ID) { return false } - if filters.Projects.Len() != 0 && !filters.Projects.ContainsAny(wh.JiraWebhook.Issue.Fields.Project.Key) { + if filters.Projects.Len() != 0 && !filters.Projects.ContainsAny(issue.Fields.Project.Key) { return false } - validFilter := true - + containsSecurityLevelFilter := false + useEmptySecurityLevel := p.getConfig().SecurityLevelEmptyForJiraSubscriptions for _, field := range filters.Fields { + inclusion := field.Inclusion + // Broken filter, values must be provided - if field.Inclusion == "" || (field.Values.Len() == 0 && field.Inclusion != FilterEmpty) { - validFilter = false - break + if inclusion == "" || (field.Values.Len() == 0 && inclusion != FilterEmpty) { + return false + } + + if field.Key == securityLevelField { + containsSecurityLevelFilter = true + if inclusion == FilterExcludeAny && useEmptySecurityLevel { + inclusion = FilterEmpty + } } - value := getIssueFieldValue(&wh.JiraWebhook.Issue, field.Key) - containsAny := value.ContainsAny(field.Values.Elems()...) - containsAll := value.ContainsAll(field.Values.Elems()...) + value := getIssueFieldValue(issue, field.Key) + if !isValidFieldInclusion(field, value, inclusion) { + return false + } + } - if (field.Inclusion == FilterIncludeAny && !containsAny) || - (field.Inclusion == FilterIncludeAll && !containsAll) || - (field.Inclusion == FilterExcludeAny && containsAny) || - (field.Inclusion == FilterEmpty && value.Len() > 0) { - validFilter = false - break + if !containsSecurityLevelFilter && useEmptySecurityLevel { + securityLevel := getIssueFieldValue(issue, securityLevelField) + if securityLevel.Len() > 0 { + return false } } - return validFilter + return true +} + +func isValidFieldInclusion(field FieldFilter, value StringSet, inclusion string) bool { + containsAny := value.ContainsAny(field.Values.Elems()...) + containsAll := value.ContainsAll(field.Values.Elems()...) + + if (inclusion == FilterIncludeAny && !containsAny) || + (inclusion == FilterIncludeAll && !containsAll) || + (inclusion == FilterExcludeAny && containsAny) || + (inclusion == FilterEmpty && value.Len() > 0) { + return false + } + + return true } func (p *Plugin) getChannelsSubscribed(wh *webhook, instanceID types.ID) ([]ChannelSubscription, error) { @@ -298,6 +324,37 @@ func (p *Plugin) validateSubscription(instanceID types.ID, subscription *Channel return errors.New("please provide a project identifier") } + projectKey := subscription.Filters.Projects.Elems()[0] + + var securityLevels StringSet + useEmptySecurityLevel := p.getConfig().SecurityLevelEmptyForJiraSubscriptions + for _, field := range subscription.Filters.Fields { + if field.Key != securityLevelField { + continue + } + + if field.Inclusion == FilterEmpty { + continue + } + + if field.Inclusion == FilterExcludeAny && useEmptySecurityLevel { + return errors.New("security level does not allow for an \"Exclude\" clause") + } + + if securityLevels == nil { + securityLevelsArray, err := p.getSecurityLevelsForProject(client, projectKey) + if err != nil { + return errors.Wrap(err, "failed to get security levels for project") + } + + securityLevels = NewStringSet(securityLevelsArray...) + } + + if !securityLevels.ContainsAll(field.Values.Elems()...) { + return errors.New("invalid access to security level") + } + } + channelID := subscription.ChannelID subs, err := p.getSubscriptionsForChannel(instanceID, channelID) if err != nil { @@ -310,7 +367,6 @@ func (p *Plugin) validateSubscription(instanceID types.ID, subscription *Channel } } - projectKey := subscription.Filters.Projects.Elems()[0] _, err = client.GetProject(projectKey) if err != nil { return errors.WithMessagef(err, "failed to get project %q", projectKey) @@ -319,6 +375,47 @@ func (p *Plugin) validateSubscription(instanceID types.ID, subscription *Channel return nil } +func (p *Plugin) getSecurityLevelsForProject(client Client, projectKey string) ([]string, error) { + createMeta, err := client.GetCreateMetaInfo(p.API, &jira.GetQueryOptions{ + Expand: "projects.issuetypes.fields", + ProjectKeys: projectKey, + }) + if err != nil { + return nil, errors.Wrap(err, "error fetching user security levels") + } + + if len(createMeta.Projects) == 0 || len(createMeta.Projects[0].IssueTypes) == 0 { + return nil, errors.Wrapf(err, "no project found for project key %s", projectKey) + } + + securityLevels1, err := createMeta.Projects[0].IssueTypes[0].Fields.MarshalMap(securityLevelField) + if err != nil { + return nil, errors.Wrap(err, "error parsing user security levels") + } + + allowed, ok := securityLevels1["allowedValues"].([]interface{}) + if !ok { + return nil, errors.New("error parsing user security levels: failed to type assertion on array") + } + + out := []string{} + for _, level := range allowed { + value, ok := level.(tcontainer.MarshalMap) + if !ok { + return nil, errors.New("error parsing user security levels: failed to type assertion on map") + } + + id, ok := value["id"].(string) + if !ok { + return nil, errors.New("error parsing user security levels: failed to type assertion on string") + } + + out = append(out, id) + } + + return out, nil +} + func (p *Plugin) editChannelSubscription(instanceID types.ID, modifiedSubscription *ChannelSubscription, client Client) error { subKey := keyWithInstanceID(instanceID, JiraSubscriptionsKey) return p.client.KV.SetAtomicWithRetries(subKey, func(initialBytes []byte) (interface{}, error) {
server/subscribe_test.go+275 −3 modified@@ -20,6 +20,199 @@ import ( "github.com/stretchr/testify/assert" ) +func TestValidateSubscription(t *testing.T) { + p := &Plugin{} + + p.instanceStore = p.getMockInstanceStoreKV(0) + + api := &plugintest.API{} + p.SetAPI(api) + + for name, tc := range map[string]struct { + subscription *ChannelSubscription + errorMessage string + disableSecurityConfig bool + }{ + "no event selected": { + subscription: &ChannelSubscription{ + ID: "id", + Name: "name", + ChannelID: "channelid", + InstanceID: "instance_id", + Filters: SubscriptionFilters{ + Events: NewStringSet(), + Projects: NewStringSet("project"), + IssueTypes: NewStringSet("10001"), + }, + }, + errorMessage: "please provide at least one event type", + }, + "no project selected": { + subscription: &ChannelSubscription{ + ID: "id", + Name: "name", + ChannelID: "channelid", + InstanceID: "instance_id", + Filters: SubscriptionFilters{ + Events: NewStringSet("issue_created"), + Projects: NewStringSet(), + IssueTypes: NewStringSet("10001"), + }, + }, + errorMessage: "please provide a project identifier", + }, + "no issue type selected": { + subscription: &ChannelSubscription{ + ID: "id", + Name: "name", + ChannelID: "channelid", + InstanceID: "instance_id", + Filters: SubscriptionFilters{ + Events: NewStringSet("issue_created"), + Projects: NewStringSet("project"), + IssueTypes: NewStringSet(), + }, + }, + errorMessage: "please provide at least one issue type", + }, + "valid subscription": { + subscription: &ChannelSubscription{ + ID: "id", + Name: "name", + ChannelID: "channelid", + InstanceID: "instance_id", + Filters: SubscriptionFilters{ + Events: NewStringSet("issue_created"), + Projects: NewStringSet("project"), + IssueTypes: NewStringSet("10001"), + }, + }, + errorMessage: "", + }, + "valid subscription with security level": { + subscription: &ChannelSubscription{ + ID: "id", + Name: "name", + ChannelID: "channelid", + InstanceID: "instance_id", + Filters: SubscriptionFilters{ + Events: NewStringSet("issue_created"), + Projects: NewStringSet("TEST"), + IssueTypes: NewStringSet("10001"), + Fields: []FieldFilter{ + { + Key: "security", + Inclusion: FilterIncludeAll, + Values: NewStringSet("10001"), + }, + }, + }, + }, + errorMessage: "", + }, + "invalid 'Exclude' of security level": { + subscription: &ChannelSubscription{ + ID: "id", + Name: "name", + ChannelID: "channelid", + InstanceID: "instance_id", + Filters: SubscriptionFilters{ + Events: NewStringSet("issue_created"), + Projects: NewStringSet("TEST"), + IssueTypes: NewStringSet("10001"), + Fields: []FieldFilter{ + { + Key: "security", + Inclusion: FilterExcludeAny, + Values: NewStringSet("10001"), + }, + }, + }, + }, + errorMessage: "security level does not allow for an \"Exclude\" clause", + }, + "security config disabled, valid 'Exclude' of security level": { + subscription: &ChannelSubscription{ + ID: "id", + Name: "name", + ChannelID: "channelid", + InstanceID: "instance_id", + Filters: SubscriptionFilters{ + Events: NewStringSet("issue_created"), + Projects: NewStringSet("TEST"), + IssueTypes: NewStringSet("10001"), + Fields: []FieldFilter{ + { + Key: "security", + Inclusion: FilterExcludeAny, + Values: NewStringSet("10001"), + }, + }, + }, + }, + disableSecurityConfig: true, + errorMessage: "", + }, + "invalid access to security level": { + subscription: &ChannelSubscription{ + ID: "id", + Name: "name", + ChannelID: "channelid", + InstanceID: "instance_id", + Filters: SubscriptionFilters{ + Events: NewStringSet("issue_created"), + Projects: NewStringSet("TEST"), + IssueTypes: NewStringSet("10001"), + Fields: []FieldFilter{ + { + Key: "security", + Inclusion: FilterIncludeAll, + Values: NewStringSet("10002"), + }, + }, + }, + }, + errorMessage: "invalid access to security level", + }, + "user does not have read access to the project": { + subscription: &ChannelSubscription{ + ID: "id", + Name: "name", + ChannelID: "channelid", + InstanceID: "instance_id", + Filters: SubscriptionFilters{ + Events: NewStringSet("issue_created"), + Projects: NewStringSet(nonExistantProjectKey), + IssueTypes: NewStringSet("10001"), + }, + }, + errorMessage: "failed to get project \"FP\": Project FP not found", + }, + } { + t.Run(name, func(t *testing.T) { + api := &plugintest.API{} + p.SetAPI(api) + p.client = pluginapi.NewClient(p.API, p.Driver) + + api.On("KVGet", testSubKey).Return(nil, nil) + + p.updateConfig(func(conf *config) { + conf.SecurityLevelEmptyForJiraSubscriptions = !tc.disableSecurityConfig + }) + + client := testClient{} + err := p.validateSubscription(testInstance1.InstanceID, tc.subscription, client) + + if tc.errorMessage == "" { + require.NoError(t, err) + } else { + require.Error(t, err) + require.Equal(t, tc.errorMessage, err.Error()) + } + }) + } +} + func TestListChannelSubscriptions(t *testing.T) { p := &Plugin{} p.updateConfig(func(conf *config) { @@ -278,9 +471,10 @@ func TestGetChannelsSubscribed(t *testing.T) { p.instanceStore = p.getMockInstanceStoreKV(0) for name, tc := range map[string]struct { - WebhookTestData string - Subs *Subscriptions - ChannelSubscriptions []ChannelSubscription + WebhookTestData string + Subs *Subscriptions + ChannelSubscriptions []ChannelSubscription + disableSecurityConfig bool }{ "no filters selected": { WebhookTestData: "webhook-issue-created.json", @@ -1360,12 +1554,90 @@ func TestGetChannelsSubscribed(t *testing.T) { }), ChannelSubscriptions: []ChannelSubscription{{ChannelID: "sampleChannelId"}}, }, + "no security level provided in subscription, but security level is present in issue": { + WebhookTestData: "webhook-issue-created-with-security-level.json", + Subs: withExistingChannelSubscriptions([]ChannelSubscription{ + { + ID: "rg86cd65efdjdjezgisgxaitzh", + ChannelID: "sampleChannelId", + Filters: SubscriptionFilters{ + Events: NewStringSet("event_created"), + Projects: NewStringSet("TES"), + IssueTypes: NewStringSet("10001"), + Fields: []FieldFilter{}, + }, + }, + }), + ChannelSubscriptions: []ChannelSubscription{}, + }, + "security config disabled, no security level provided in subscription, but security level is present in issue": { + WebhookTestData: "webhook-issue-created-with-security-level.json", + Subs: withExistingChannelSubscriptions([]ChannelSubscription{ + { + ID: "rg86cd65efdjdjezgisgxaitzh", + ChannelID: "sampleChannelId", + Filters: SubscriptionFilters{ + Events: NewStringSet("event_created"), + Projects: NewStringSet("TES"), + IssueTypes: NewStringSet("10001"), + Fields: []FieldFilter{}, + }, + }, + }), + ChannelSubscriptions: []ChannelSubscription{{ChannelID: "sampleChannelId"}}, + disableSecurityConfig: true, + }, + "security level provided in subscription, but different security level is present in issue": { + WebhookTestData: "webhook-issue-created-with-security-level.json", + Subs: withExistingChannelSubscriptions([]ChannelSubscription{ + { + ID: "rg86cd65efdjdjezgisgxaitzh", + ChannelID: "sampleChannelId", + Filters: SubscriptionFilters{ + Events: NewStringSet("event_created"), + Projects: NewStringSet("TES"), + IssueTypes: NewStringSet("10001"), + Fields: []FieldFilter{ + { + Key: "security", + Inclusion: FilterIncludeAll, + Values: NewStringSet("10002"), + }, + }, + }, + }, + }), + ChannelSubscriptions: []ChannelSubscription{}, + }, + "security level provided in subscription, and same security level is present in issue": { + WebhookTestData: "webhook-issue-created-with-security-level.json", + Subs: withExistingChannelSubscriptions([]ChannelSubscription{ + { + ID: "rg86cd65efdjdjezgisgxaitzh", + ChannelID: "sampleChannelId", + Filters: SubscriptionFilters{ + Events: NewStringSet("event_created"), + Projects: NewStringSet("TES"), + IssueTypes: NewStringSet("10001"), + Fields: []FieldFilter{ + { + Key: "security", + Inclusion: FilterIncludeAll, + Values: NewStringSet("10001"), + }, + }, + }, + }, + }), + ChannelSubscriptions: []ChannelSubscription{{ChannelID: "sampleChannelId"}}, + }, } { t.Run(name, func(t *testing.T) { api := &plugintest.API{} p.updateConfig(func(conf *config) { conf.Secret = someSecret + conf.SecurityLevelEmptyForJiraSubscriptions = !tc.disableSecurityConfig }) p.SetAPI(api)
server/testdata/webhook-issue-created-with-security-level.json+238 −0 added@@ -0,0 +1,238 @@ +{ + "timestamp": 1550286113023, + "webhookEvent": "jira:issue_created", + "issue_event_type_name": "issue_created", + "user": { + "self": "https://some-instance-test.atlassian.net/rest/api/2/user?accountId=5c5f880629be9642ba529340", + "name": "admin", + "key": "admin", + "accountId": "5c5f880629be9642ba529340", + "emailAddress": "some-instance-test@gmail.com", + "avatarUrls": { + "48x48": "https://avatar-cdn.atlassian.com/d991bc281c0c0ecb0bbb2db3979ddaff?s=48&d=https%3A%2F%2Fsecure.gravatar.com%2Favatar%2Fd991bc281c0c0ecb0bbb2db3979ddaff%3Fd%3Dmm%26s%3D48%26noRedirect%3Dtrue", + "24x24": "https://avatar-cdn.atlassian.com/d991bc281c0c0ecb0bbb2db3979ddaff?s=24&d=https%3A%2F%2Fsecure.gravatar.com%2Favatar%2Fd991bc281c0c0ecb0bbb2db3979ddaff%3Fd%3Dmm%26s%3D24%26noRedirect%3Dtrue", + "16x16": "https://avatar-cdn.atlassian.com/d991bc281c0c0ecb0bbb2db3979ddaff?s=16&d=https%3A%2F%2Fsecure.gravatar.com%2Favatar%2Fd991bc281c0c0ecb0bbb2db3979ddaff%3Fd%3Dmm%26s%3D16%26noRedirect%3Dtrue", + "32x32": "https://avatar-cdn.atlassian.com/d991bc281c0c0ecb0bbb2db3979ddaff?s=32&d=https%3A%2F%2Fsecure.gravatar.com%2Favatar%2Fd991bc281c0c0ecb0bbb2db3979ddaff%3Fd%3Dmm%26s%3D32%26noRedirect%3Dtrue" + }, + "displayName": "Test User", + "active": true, + "timeZone": "America/Los_Angeles" + }, + "issue": { + "id": "10040", + "self": "https://some-instance-test.atlassian.net/rest/api/2/issue/10040", + "key": "TES-41", + "fields": { + "issuetype": { + "self": "https://some-instance-test.atlassian.net/rest/api/2/issuetype/10001", + "id": "10001", + "description": "Stories track functionality or features expressed as user goals.", + "iconUrl": "https://some-instance-test.atlassian.net/secure/viewavatar?size=xsmall&avatarId=10315&avatarType=issuetype", + "name": "Story", + "subtask": false, + "avatarId": 10315 + }, + "timespent": null, + "customfield_10030": null, + "project": { + "self": "https://some-instance-test.atlassian.net/rest/api/2/project/10000", + "id": "10000", + "key": "TES", + "name": "test1", + "projectTypeKey": "software", + "avatarUrls": { + "48x48": "https://some-instance-test.atlassian.net/secure/projectavatar?avatarId=10324", + "24x24": "https://some-instance-test.atlassian.net/secure/projectavatar?size=small&avatarId=10324", + "16x16": "https://some-instance-test.atlassian.net/secure/projectavatar?size=xsmall&avatarId=10324", + "32x32": "https://some-instance-test.atlassian.net/secure/projectavatar?size=medium&avatarId=10324" + } + }, + "fixVersions": [], + "aggregatetimespent": null, + "resolution": null, + "customfield_10027": null, + "resolutiondate": null, + "workratio": -1, + "lastViewed": null, + "watches": { + "self": "https://some-instance-test.atlassian.net/rest/api/2/issue/TES-41/watchers", + "watchCount": 0, + "isWatching": true + }, + "created": "2019-02-15T19:01:52.971-0800", + "customfield_10020": null, + "customfield_10021": null, + "customfield_10022": "0|i00067:", + "priority": { + "self": "https://some-instance-test.atlassian.net/rest/api/2/priority/2", + "iconUrl": "https://some-instance-test.atlassian.net/images/icons/priorities/high.svg", + "name": "High", + "id": "2" + }, + "customfield_10023": null, + "customfield_10024": [], + "customfield_10025": null, + "customfield_10026": null, + "labels": [ + "test-label" + ], + "customfield_10016": null, + "customfield_10017": null, + "customfield_10018": { + "hasEpicLinkFieldDependency": false, + "showField": false, + "nonEditableReason": { + "reason": "PLUGIN_LICENSE_ERROR", + "message": "Portfolio for Jira must be licensed for the Parent Link to be available." + } + }, + "customfield_10019": null, + "aggregatetimeoriginalestimate": null, + "timeestimate": null, + "versions": [], + "issuelinks": [], + "assignee": null, + "updated": "2019-02-15T19:01:52.971-0800", + "status": { + "self": "https://some-instance-test.atlassian.net/rest/api/2/status/10001", + "description": "", + "iconUrl": "https://some-instance-test.atlassian.net/", + "name": "To Do", + "id": "10001", + "statusCategory": { + "self": "https://some-instance-test.atlassian.net/rest/api/2/statuscategory/2", + "id": 2, + "key": "new", + "colorName": "blue-gray", + "name": "New" + } + }, + "components": [ + { + "self": "https://some-instance-test.atlassian.net/rest/api/2/component/10000", + "id": "10000", + "name": "COMP-1", + "description": "Component-1" + } + ], + "timeoriginalestimate": null, + "description": "Unit test description, not that long", + "customfield_10010": null, + "customfield_10014": null, + "customfield_10015": null, + "timetracking": {}, + "customfield_10005": null, + "customfield_10006": null, + "security": "10001", + "customfield_10007": null, + "customfield_10008": null, + "attachment": [], + "customfield_10009": null, + "aggregatetimeestimate": null, + "summary": "Unit test summary", + "creator": { + "self": "https://some-instance-test.atlassian.net/rest/api/2/user?accountId=5c5f880629be9642ba529340", + "name": "admin", + "key": "admin", + "accountId": "5c5f880629be9642ba529340", + "emailAddress": "some-instance-test@gmail.com", + "avatarUrls": { + "48x48": "https://avatar-cdn.atlassian.com/d991bc281c0c0ecb0bbb2db3979ddaff?s=48&d=https%3A%2F%2Fsecure.gravatar.com%2Favatar%2Fd991bc281c0c0ecb0bbb2db3979ddaff%3Fd%3Dmm%26s%3D48%26noRedirect%3Dtrue", + "24x24": "https://avatar-cdn.atlassian.com/d991bc281c0c0ecb0bbb2db3979ddaff?s=24&d=https%3A%2F%2Fsecure.gravatar.com%2Favatar%2Fd991bc281c0c0ecb0bbb2db3979ddaff%3Fd%3Dmm%26s%3D24%26noRedirect%3Dtrue", + "16x16": "https://avatar-cdn.atlassian.com/d991bc281c0c0ecb0bbb2db3979ddaff?s=16&d=https%3A%2F%2Fsecure.gravatar.com%2Favatar%2Fd991bc281c0c0ecb0bbb2db3979ddaff%3Fd%3Dmm%26s%3D16%26noRedirect%3Dtrue", + "32x32": "https://avatar-cdn.atlassian.com/d991bc281c0c0ecb0bbb2db3979ddaff?s=32&d=https%3A%2F%2Fsecure.gravatar.com%2Favatar%2Fd991bc281c0c0ecb0bbb2db3979ddaff%3Fd%3Dmm%26s%3D32%26noRedirect%3Dtrue" + }, + "displayName": "Test User", + "active": true, + "timeZone": "America/Los_Angeles" + }, + "subtasks": [], + "reporter": { + "self": "https://some-instance-test.atlassian.net/rest/api/2/user?accountId=5c5f880629be9642ba529340", + "name": "admin", + "key": "admin", + "accountId": "5c5f880629be9642ba529340", + "emailAddress": "some-instance-test@gmail.com", + "avatarUrls": { + "48x48": "https://avatar-cdn.atlassian.com/d991bc281c0c0ecb0bbb2db3979ddaff?s=48&d=https%3A%2F%2Fsecure.gravatar.com%2Favatar%2Fd991bc281c0c0ecb0bbb2db3979ddaff%3Fd%3Dmm%26s%3D48%26noRedirect%3Dtrue", + "24x24": "https://avatar-cdn.atlassian.com/d991bc281c0c0ecb0bbb2db3979ddaff?s=24&d=https%3A%2F%2Fsecure.gravatar.com%2Favatar%2Fd991bc281c0c0ecb0bbb2db3979ddaff%3Fd%3Dmm%26s%3D24%26noRedirect%3Dtrue", + "16x16": "https://avatar-cdn.atlassian.com/d991bc281c0c0ecb0bbb2db3979ddaff?s=16&d=https%3A%2F%2Fsecure.gravatar.com%2Favatar%2Fd991bc281c0c0ecb0bbb2db3979ddaff%3Fd%3Dmm%26s%3D16%26noRedirect%3Dtrue", + "32x32": "https://avatar-cdn.atlassian.com/d991bc281c0c0ecb0bbb2db3979ddaff?s=32&d=https%3A%2F%2Fsecure.gravatar.com%2Favatar%2Fd991bc281c0c0ecb0bbb2db3979ddaff%3Fd%3Dmm%26s%3D32%26noRedirect%3Dtrue" + }, + "displayName": "Test User", + "active": true, + "timeZone": "America/Los_Angeles" + }, + "customfield_10000": "{}", + "aggregateprogress": { + "progress": 0, + "total": 0 + }, + "customfield_10001": null, + "customfield_10002": null, + "customfield_10003": null, + "customfield_10004": null, + "environment": null, + "duedate": null, + "progress": { + "progress": 0, + "total": 0 + }, + "votes": { + "self": "https://some-instance-test.atlassian.net/rest/api/2/issue/TES-41/votes", + "votes": 0, + "hasVoted": false + } + } + }, + "changelog": { + "id": "10222", + "items": [ + { + "field": "description", + "fieldtype": "jira", + "fieldId": "description", + "from": null, + "fromString": null, + "to": null, + "toString": "Unit test description, not that long" + }, + { + "field": "priority", + "fieldtype": "jira", + "fieldId": "priority", + "from": null, + "fromString": null, + "to": "2", + "toString": "High" + }, + { + "field": "reporter", + "fieldtype": "jira", + "fieldId": "reporter", + "from": null, + "fromString": null, + "to": "admin", + "toString": "Test User" + }, + { + "field": "Status", + "fieldtype": "jira", + "fieldId": "status", + "from": null, + "fromString": null, + "to": "10001", + "toString": "To Do" + }, + { + "field": "summary", + "fieldtype": "jira", + "fieldId": "summary", + "from": null, + "fromString": null, + "to": null, + "toString": "Unit test summary" + } + ] + } +}
server/user.go+5 −2 modified@@ -190,10 +190,13 @@ func (p *Plugin) UpdateUserDefaults(mattermostUserID, instanceID types.ID, proje } func (p *Plugin) httpGetSettingsInfo(w http.ResponseWriter, r *http.Request) (int, error) { + conf := p.getConfig() return respondJSON(w, struct { - UIEnabled bool `json:"ui_enabled"` + UIEnabled bool `json:"ui_enabled"` + SecurityLevelEmptyForJiraSubscriptions bool `json:"security_level_empty_for_jira_subscriptions"` }{ - UIEnabled: p.getConfig().EnableJiraUI, + UIEnabled: conf.EnableJiraUI, + SecurityLevelEmptyForJiraSubscriptions: conf.SecurityLevelEmptyForJiraSubscriptions, }) }
webapp/src/components/modals/channel_subscriptions/channel_subscription_filters.test.tsx+1 −0 modified@@ -58,6 +58,7 @@ describe('components/ChannelSubscriptionFilters', () => { removeValidate: jest.fn(), onChange: jest.fn(), instanceID: 'https://something.atlassian.net', + securityLevelEmptyForJiraSubscriptions: true, }; test('should match snapshot', () => {
webapp/src/components/modals/channel_subscriptions/channel_subscription_filters.tsx+3 −1 modified@@ -16,6 +16,7 @@ export type Props = { removeValidate: (isValid: () => boolean) => void; onChange: (f: FilterValue[]) => void; instanceID: string; + securityLevelEmptyForJiraSubscriptions: boolean; }; type State = { @@ -32,7 +33,7 @@ export default class ChannelSubscriptionFilters extends React.PureComponent<Prop const index = newValues.findIndex((f) => f === oldValue); if (index === -1) { - newValues.push({inclusion: FilterFieldInclusion.INCLUDE_ANY, values: [], ...newValue}); + newValues.push({...newValue, inclusion: FilterFieldInclusion.INCLUDE_ANY, values: []}); this.setState({showCreateRow: false}); } else { newValues.splice(index, 1, newValue); @@ -104,6 +105,7 @@ export default class ChannelSubscriptionFilters extends React.PureComponent<Prop addValidate={this.props.addValidate} removeValidate={this.props.removeValidate} instanceID={this.props.instanceID} + securityLevelEmptyForJiraSubscriptions={this.props.securityLevelEmptyForJiraSubscriptions} /> </div> );
webapp/src/components/modals/channel_subscriptions/channel_subscription_filter.test.tsx+58 −0 modified@@ -31,6 +31,7 @@ describe('components/ChannelSubscriptionFilter', () => { onChange: jest.fn(), removeFilter: jest.fn(), instanceID: 'https://something.atlassian.net', + securityLevelEmptyForJiraSubscriptions: true, }; test('should match snapshot', () => { @@ -116,4 +117,61 @@ describe('components/ChannelSubscriptionFilter', () => { result = wrapper.instance().checkFieldConflictError(); expect(result).toEqual('FieldName does not exist for issue type(s): Task.'); }); + + test('checkInclusionError should return an error string when there is an invalid inclusion value', () => { + const props: Props = { + ...baseProps, + field: { + ...baseProps.field, + schema: { + ...baseProps.field.schema, + type: 'securitylevel', + }, + }, + }; + const wrapper = shallow<ChannelSubscriptionFilter>( + <ChannelSubscriptionFilter {...props}/> + ); + + let isValid; + isValid = wrapper.instance().isValid(); + expect(isValid).toBe(true); + + wrapper.setProps({ + ...props, + value: { + inclusion: FilterFieldInclusion.EMPTY, + key: 'securitylevel', + values: [], + }, + }); + + isValid = wrapper.instance().isValid(); + expect(isValid).toBe(true); + + wrapper.setProps({ + ...props, + value: { + inclusion: FilterFieldInclusion.INCLUDE_ANY, + key: 'securitylevel', + values: [], + }, + }); + + isValid = wrapper.instance().isValid(); + expect(isValid).toBe(true); + + wrapper.setProps({ + ...props, + value: { + inclusion: FilterFieldInclusion.EXCLUDE_ANY, + key: 'securitylevel', + values: [], + }, + }); + + isValid = wrapper.instance().isValid(); + expect(isValid).toBe(false); + expect(wrapper.find('.error-text').text()).toEqual('Security level inclusion cannot be "Exclude Any". Note that the default value is now "Empty".'); + }); });
webapp/src/components/modals/channel_subscriptions/channel_subscription_filter.tsx+29 −4 modified@@ -5,7 +5,7 @@ import {Theme} from 'mattermost-redux/types/preferences'; import ReactSelectSetting from 'components/react_select_setting'; import JiraEpicSelector from 'components/data_selectors/jira_epic_selector'; -import {isEpicLinkField, isMultiSelectField, isLabelField} from 'utils/jira_issue_metadata'; +import {isEpicLinkField, isMultiSelectField, isLabelField, isSecurityLevelField} from 'utils/jira_issue_metadata'; import {FilterField, FilterValue, ReactSelectOption, IssueMetadata, IssueType, FilterFieldInclusion} from 'types/model'; import ConfirmModal from 'components/confirm_modal'; import JiraAutoCompleteSelector from 'components/data_selectors/jira_autocomplete_selector'; @@ -22,6 +22,7 @@ export type Props = { addValidate: (isValid: () => boolean) => void; removeValidate: (isValid: () => boolean) => void; instanceID: string; + securityLevelEmptyForJiraSubscriptions: boolean; }; export type State = { @@ -103,7 +104,13 @@ export default class ChannelSubscriptionFilter extends React.PureComponent<Props }; isValid = (): boolean => { - const error = this.checkFieldConflictError(); + let error = this.checkFieldConflictError(); + if (error) { + this.setState({error}); + return false; + } + + error = this.checkInclusionError(); if (error) { this.setState({error}); return false; @@ -112,6 +119,16 @@ export default class ChannelSubscriptionFilter extends React.PureComponent<Props return true; } + checkInclusionError = (): string | null => { + const inclusion = this.props.value && this.props.value.inclusion; + + if (isSecurityLevelField(this.props.field) && inclusion === FilterFieldInclusion.EXCLUDE_ANY && this.props.securityLevelEmptyForJiraSubscriptions) { + return 'Security level inclusion cannot be "Exclude Any". Note that the default value is now "Empty".'; + } + + return null; + } + checkFieldConflictError = (): string | null => { const conflictIssueTypes = this.getConflictingIssueTypes().map((it) => it.name); if (conflictIssueTypes.length) { @@ -167,13 +184,21 @@ export default class ChannelSubscriptionFilter extends React.PureComponent<Props })); let chosenFieldType = null; - const inclusionSelectOptions: ReactSelectOption[] = [ + let inclusionSelectOptions: ReactSelectOption[] = [ {label: 'Include', value: FilterFieldInclusion.INCLUDE_ANY}, {label: 'Include All', value: FilterFieldInclusion.INCLUDE_ALL}, {label: 'Exclude', value: FilterFieldInclusion.EXCLUDE_ANY}, {label: 'Empty', value: FilterFieldInclusion.EMPTY}, ]; + if (isSecurityLevelField(field) && value.inclusion !== FilterFieldInclusion.EXCLUDE_ANY && this.props.securityLevelEmptyForJiraSubscriptions) { + inclusionSelectOptions = [ + {label: 'Include', value: FilterFieldInclusion.INCLUDE_ANY}, + {label: 'Include All', value: FilterFieldInclusion.INCLUDE_ALL}, + {label: 'Empty', value: FilterFieldInclusion.EMPTY}, + ]; + } + if (!isMultiSelectField(field)) { const includeAllIndex = inclusionSelectOptions.findIndex((opt) => opt.value === FilterFieldInclusion.INCLUDE_ALL); inclusionSelectOptions.splice(includeAllIndex, 1); @@ -296,7 +321,7 @@ export default class ChannelSubscriptionFilter extends React.PureComponent<Props className='help-text error-text' style={style.conflictingError} > - {this.checkFieldConflictError()} + {this.state.error} </div> </div> <div className='col-md-11 col-sm-12'>
webapp/src/components/modals/channel_subscriptions/edit_channel_subscription.test.tsx+5 −4 modified@@ -5,16 +5,16 @@ import React from 'react'; import {shallow} from 'enzyme'; import Preferences from 'mattermost-redux/constants/preferences'; +import {Channel} from 'mattermost-redux/types/channels'; -import cloudProjectMetadata from 'testdata/cloud-get-jira-project-metadata.json'; import cloudIssueMetadata from 'testdata/cloud-get-create-issue-metadata-for-project.json'; import serverProjectMetadata from 'testdata/server-get-jira-project-metadata.json'; import serverIssueMetadata from 'testdata/server-get-create-issue-metadata-for-project-many-fields.json'; import testChannel from 'testdata/channel.json'; import {IssueMetadata, ProjectMetadata, FilterFieldInclusion} from 'types/model'; -import EditChannelSubscription from './edit_channel_subscription'; +import EditChannelSubscription, {Props} from './edit_channel_subscription'; describe('components/EditChannelSubscription', () => { const baseActions = { @@ -71,15 +71,16 @@ describe('components/EditChannelSubscription', () => { instance_id: 'https://something.atlassian.net', }; - const baseProps = { + const baseProps: Props = { ...baseActions, - channel: testChannel, + channel: testChannel as unknown as Channel, theme: Preferences.THEMES.default, finishEditSubscription: jest.fn(), channelSubscriptions: [channelSubscriptionForCloud], close: jest.fn(), selectedSubscription: channelSubscriptionForCloud, creatingSubscription: false, + securityLevelEmptyForJiraSubscriptions: true, }; const baseState = {
webapp/src/components/modals/channel_subscriptions/edit_channel_subscription.tsx+19 −1 modified@@ -19,6 +19,7 @@ import { getConflictingFields, generateJQLStringFromSubscriptionFilters, getIssueTypes, + filterValueIsSecurityField, } from 'utils/jira_issue_metadata'; import {ChannelSubscription, ChannelSubscriptionFilters as ChannelSubscriptionFiltersModel, ReactSelectOption, FilterValue, IssueMetadata} from 'types/model'; @@ -172,6 +173,14 @@ export default class EditChannelSubscription extends PureComponent<Props, State> this.setState({conflictingError: null}); } + shouldShowEmptySecurityLevelMessage = (): boolean => { + if (!this.props.securityLevelEmptyForJiraSubscriptions) { + return false; + } + + return !this.state.filters.fields.some(filterValueIsSecurityField); + } + handleIssueChange = (id: keyof ChannelSubscriptionFiltersModel, value: string[] | null) => { const finalValue = value || []; const filters = {...this.state.filters, issue_types: finalValue}; @@ -392,14 +401,23 @@ export default class EditChannelSubscription extends PureComponent<Props, State> addValidate={this.validator.addComponent} removeValidate={this.validator.removeComponent} instanceID={this.state.instanceID} + securityLevelEmptyForJiraSubscriptions={this.props.securityLevelEmptyForJiraSubscriptions} /> <div> <label className='control-label margin-bottom'> {'Approximate JQL Output'} </label> <div style={getBaseStyles(this.props.theme).codeBlock}> - <span>{generateJQLStringFromSubscriptionFilters(this.state.jiraIssueMetadata, filterFields, this.state.filters)}</span> + <span>{generateJQLStringFromSubscriptionFilters(this.state.jiraIssueMetadata, filterFields, this.state.filters, this.props.securityLevelEmptyForJiraSubscriptions)}</span> </div> + {this.shouldShowEmptySecurityLevelMessage() && ( + <div> + <span> + <strong>{'Note'}</strong> + {' that since you have not selected a security level filter, the subscription will only allow issues that have no security level assigned.'} + </span> + </div> + )} </div> </React.Fragment> );
webapp/src/components/modals/channel_subscriptions/index.ts+4 −1 modified@@ -25,7 +25,7 @@ import { getChannelIdWithSettingsOpen, getInstalledInstances, getUserConnectedInstances, - getDefaultUserInstanceID, + getPluginSettings, } from 'selectors'; import ChannelSubscriptionsModal from './channel_subscriptions'; @@ -44,13 +44,16 @@ const mapStateToProps = (state) => { const installedInstances = getInstalledInstances(state); const connectedInstances = getUserConnectedInstances(state); + const pluginSettings = getPluginSettings(state); + const securityLevelEmptyForJiraSubscriptions = pluginSettings.security_level_empty_for_jira_subscriptions; return { omitDisplayName, channelSubscriptions, channel, installedInstances, connectedInstances, + securityLevelEmptyForJiraSubscriptions, }; };
webapp/src/components/modals/channel_subscriptions/shared_props.ts+1 −0 modified@@ -22,4 +22,5 @@ export type SharedProps = { getConnected: () => Promise<GetConnectedResponse>; close: () => void; sendEphemeralPost: (message: string) => void; + securityLevelEmptyForJiraSubscriptions: boolean; };
webapp/src/components/modals/channel_subscriptions/__snapshots__/channel_subscription_filters.test.tsx.snap+1 −0 modified@@ -155,6 +155,7 @@ exports[`components/ChannelSubscriptionFilters should match snapshot 1`] = ` onChange={[Function]} removeFilter={[Function]} removeValidate={[MockFunction]} + securityLevelEmptyForJiraSubscriptions={true} theme={Object {}} value={ Object {
webapp/src/components/modals/channel_subscriptions/__snapshots__/edit_channel_subscription.test.tsx.snap+11 −1 modified@@ -3544,6 +3544,7 @@ exports[`components/EditChannelSubscription should match snapshot after fetching } onChange={[Function]} removeValidate={[Function]} + securityLevelEmptyForJiraSubscriptions={true} theme={ Object { "awayIndicator": "#ffbc42", @@ -3611,13 +3612,22 @@ exports[`components/EditChannelSubscription should match snapshot after fetching "background": "rgba(61,60,64,0.08)", "borderRadius": "4px", "fontSize": "13px", + "marginBottom": "8px", "marginTop": "8px", "padding": "10px 12px", } } > <span> - Project = KT AND IssueType IN (Bug) AND "MJK - Radio Buttons" IN (1) AND affectedVersion IN (d) AND "Epic Link" IN (IDT-24) + Project = KT AND IssueType IN (Bug) AND "MJK - Radio Buttons" IN (1) AND affectedVersion IN (d) AND "Epic Link" IN (IDT-24) AND "Security Level" IS EMPTY + </span> + </div> + <div> + <span> + <strong> + Note + </strong> + that since you have not selected a security level filter, the subscription will only allow issues that have no security level assigned. </span> </div> </div>
webapp/src/utils/jira_issue_metadata.tsx+20 −2 modified@@ -8,12 +8,14 @@ import { IssueType, JiraField, FilterField, + FilterValue, SelectField, StringArrayField, IssueTypeIdentifier, ChannelSubscriptionFilters, FilterFieldInclusion, JiraFieldCustomTypeEnums, + JiraFieldTypeEnums, } from 'types/model'; type FieldWithInfo = JiraField & { @@ -276,6 +278,14 @@ export function isTextField(field: JiraField | FilterField): boolean { return field.schema.type === 'string'; } +export function isSecurityLevelField(field: JiraField | FilterField): boolean { + return field.schema.type === 'securitylevel'; +} + +export function filterValueIsSecurityField(value: FilterValue): boolean { + return value.key === JiraFieldTypeEnums.SECURITY; +} + // Some Jira fields have special names for JQL function getFieldNameForJQL(field: FilterField) { switch (field.key) { @@ -296,7 +306,7 @@ function quoteGuard(s: string) { return s; } -export function generateJQLStringFromSubscriptionFilters(issueMetadata: IssueMetadata, fields: FilterField[], filters: ChannelSubscriptionFilters) { +export function generateJQLStringFromSubscriptionFilters(issueMetadata: IssueMetadata, fields: FilterField[], filters: ChannelSubscriptionFilters, securityLevelEmptyForJiraSubscriptions: boolean) { const projectJQL = `Project = ${quoteGuard(filters.projects[0]) || '?'}`; let issueTypeValueString = '?'; @@ -313,7 +323,7 @@ export function generateJQLStringFromSubscriptionFilters(issueMetadata: IssueMet } const issueTypesJQL = `IssueType IN ${issueTypeValueString}`; - const filterFieldsJQL = filters.fields.map(({key, inclusion, values}): string => { + let filterFieldsJQL = filters.fields.map(({key, inclusion, values}): string => { const field = fields.find((f) => f.key === key); if (!field) { // broken filter @@ -354,5 +364,13 @@ export function generateJQLStringFromSubscriptionFilters(issueMetadata: IssueMet return `${quoteGuard(fieldName)} ${inclusionString} ${valueString}`; }).join(' AND '); + const shouldShowEmptySecurityLevel = securityLevelEmptyForJiraSubscriptions && !filters.fields.some(filterValueIsSecurityField); + if (shouldShowEmptySecurityLevel) { + if (filterFieldsJQL.length) { + filterFieldsJQL += ' AND '; + } + filterFieldsJQL += '"Security Level" IS EMPTY'; + } + return [projectJQL, issueTypesJQL, filterFieldsJQL].filter(Boolean).join(' AND '); }
webapp/src/utils/styles.ts+1 −0 modified@@ -12,6 +12,7 @@ export const getBaseStyles = (theme: Theme) => { background: changeOpacity(theme.centerChannelColor, 0.08), borderRadius: '4px', marginTop: '8px', + marginBottom: '8px', fontSize: '13px', }), };
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
5News mentions
0No linked articles in our index yet.