Skip to content
Open
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions server/plugin_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,7 @@ func TestPlugin(t *testing.T) {
api.On("LogWarn", mockAnythingOfTypeBatch("string", 13)...).Return(nil)

api.On("KVGet", mock.AnythingOfType("string")).Return(make([]byte, 0), (*model.AppError)(nil))
api.On("KVSetWithOptions", mock.AnythingOfType("string"), mock.Anything, mock.Anything).Return(true, (*model.AppError)(nil))
api.On("GetDirectChannel", mockAnythingOfTypeBatch("string", 2)...).Return(
&model.Channel{}, (*model.AppError)(nil))
api.On("GetUserByUsername", "theuser").Return(&model.User{
Expand Down
72 changes: 57 additions & 15 deletions server/webhook.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,10 @@ package main

import (
"crypto/sha256"
"encoding/binary"
"encoding/hex"
"fmt"
"hash"
"net/http"
"net/url"
"strconv"
Expand All @@ -21,8 +23,9 @@ import (
)

const (
notificationDedupTTL = 30 * time.Second
webhookDedupTTL = 30 * time.Second
notificationDedupKeyFmt = "notif_dedup_%s"
channelPostDedupKeyFmt = "chan_dedup_%s"
)

const (
Expand Down Expand Up @@ -78,8 +81,13 @@ func (wh webhook) PostToChannel(p *Plugin, instanceID types.ID, channelID, fromU

if wh.headline == "" {
return nil, http.StatusBadRequest, errors.Errorf("unsupported webhook")
} else if pluginConfig.DisplaySubscriptionNameInNotifications && subscriptionName != "" {
wh.headline = fmt.Sprintf("%s\nSubscription: **%s**", wh.headline, subscriptionName)
}

// Keep the dedup identity independent of the subscription name so overlapping
// subscriptions on the same channel collapse into one post.
headline := wh.headline
if pluginConfig.DisplaySubscriptionNameInNotifications && subscriptionName != "" {
headline = fmt.Sprintf("%s\nSubscription: **%s**", headline, subscriptionName)
}

post := &model.Post{
Expand Down Expand Up @@ -119,14 +127,29 @@ func (wh webhook) PostToChannel(p *Plugin, instanceID types.ID, channelID, fromU
{
// TODO is this supposed to be themed?
Color: "#95b7d0",
Fallback: wh.headline,
Pretext: wh.headline,
Fallback: headline,
Pretext: headline,
Text: text,
Fields: wh.fields,
},
})
} else {
post.Message = wh.headline
post.Message = headline
}

// Atomically claim the dedup key before posting so concurrent webhook
// deliveries can't both pass the check and post duplicates. SetAtomic(nil)
// only writes when the key does not already exist.
dedupKey := channelPostDedupKey(instanceID, &wh, channelID)
claimed, kvErr := p.client.KV.Set(dedupKey, true, pluginapi.SetExpiry(webhookDedupTTL), pluginapi.SetAtomic(nil))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The dedup key is claimed here but never released if CreatePost fails a few lines below. If the first of several duplicate deliveries claims the key and then fails to post some deliveries are skipped and the event is lost.

Suggest releasing the claim on the failure path when claimed is true:

if err := p.client.Post.CreatePost(post); err != nil {
    if claimed {
        if delErr := p.client.KV.Delete(dedupKey); delErr != nil {
            p.client.Log.Warn("PostToChannel: failed to release dedup key after post failure", "key", dedupKey, "error", delErr.Error())
        }
    }
    return nil, http.StatusInternalServerError, err
}

PostNotifications has the same gap so a shared releaseDedupKey helper may be cleaner than duplicating this.

switch {
case kvErr != nil:
// Fail open: post rather than dropping the event.
p.client.Log.Warn("PostToChannel: failed to claim dedup key, posting anyway", "key", dedupKey, "error", kvErr.Error())
case !claimed:
// Another delivery already claimed this post.
p.client.Log.Debug("PostToChannel: another delivery already claimed this post, skipping", "key", dedupKey)
return nil, http.StatusOK, nil
}

if err := p.client.Post.CreatePost(post); err != nil {
Expand Down Expand Up @@ -224,7 +247,7 @@ func (wh *webhook) PostNotifications(p *Plugin, instanceID types.ID) ([]*model.P
// deliveries can't both pass the check and send duplicates. SetAtomic(nil)
// only writes when the key does not already exist.
dedupKey := notificationDedupKey(instance.GetID(), wh, mattermostUserID, notification.message)
claimed, kvErr := p.client.KV.Set(dedupKey, true, pluginapi.SetExpiry(notificationDedupTTL), pluginapi.SetAtomic(nil))
claimed, kvErr := p.client.KV.Set(dedupKey, true, pluginapi.SetExpiry(webhookDedupTTL), pluginapi.SetAtomic(nil))
switch {
case kvErr != nil:
// Fail open: send the notification rather than dropping it.
Expand Down Expand Up @@ -255,15 +278,34 @@ func newWebhook(jwh *JiraWebhook, eventType string, format string, args ...inter
}
}

// writeDedupToken writes s into h prefixed with its length, so concatenated
// tokens can't collide due to separator characters appearing inside values.
func writeDedupToken(h hash.Hash, s string) {
var length [8]byte
binary.BigEndian.PutUint64(length[:], uint64(len(s)))
h.Write(length[:]) //nolint:errcheck // hash.Hash.Write never returns an error
h.Write([]byte(s)) //nolint:errcheck // hash.Hash.Write never returns an error
}

func notificationDedupKey(instanceID types.ID, wh *webhook, recipientID types.ID, message string) string {
raw := fmt.Sprintf("%s_%s_%s_%s",
string(instanceID),
wh.Issue.Key,
string(recipientID),
message,
)
hash := sha256.Sum256([]byte(raw))
return fmt.Sprintf(notificationDedupKeyFmt, hex.EncodeToString(hash[:]))
h := sha256.New()
for _, token := range []string{string(instanceID), wh.Issue.Key, string(recipientID), message} {
writeDedupToken(h, token)
}
return fmt.Sprintf(notificationDedupKeyFmt, hex.EncodeToString(h.Sum(nil)))
}

func channelPostDedupKey(instanceID types.ID, wh *webhook, channelID string) string {
h := sha256.New()
for _, token := range []string{string(instanceID), wh.Issue.Key, channelID, wh.headline, wh.text} {
writeDedupToken(h, token)
}
for _, f := range wh.fields {
writeDedupToken(h, f.Title)
writeDedupToken(h, fmt.Sprintf("%v", f.Value))
writeDedupToken(h, fmt.Sprintf("%t", bool(f.Short)))
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
return fmt.Sprintf(channelPostDedupKeyFmt, hex.EncodeToString(h.Sum(nil)))
}

func (p *Plugin) GetWebhookURL(jiraURL string, teamID, channelID string) (subURL, legacyURL string, err error) {
Expand Down
1 change: 1 addition & 0 deletions server/webhook_http_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -645,6 +645,7 @@ func TestWebhookHTTP(t *testing.T) {
} else {
api.On("KVGet", mock.AnythingOfType("string")).Return(nil, (*model.AppError)(nil))
}
api.On("KVSetWithOptions", mock.AnythingOfType("string"), mock.Anything, mock.Anything).Return(true, nil)

api.On("LogDebug", mockAnythingOfTypeBatch("string", 11)...).Return(nil)
api.On("LogWarn", mockAnythingOfTypeBatch("string", 10)...).Return(nil)
Expand Down
110 changes: 110 additions & 0 deletions server/webhook_parser_misc_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
"testing"

jira "github.com/andygrunwald/go-jira"
"github.com/mattermost/mattermost/server/public/model"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

Expand Down Expand Up @@ -336,4 +337,113 @@ func TestNotificationDedupKey(t *testing.T) {
notificationDedupKey(instanceID, wh, "user-abc", "Actor **assigned** you to PROJ-1"),
notificationDedupKey(instanceID, wh, "user-abc", "Actor **commented** on PROJ-1"))
})

t.Run("values containing separator-like characters do not collide", func(t *testing.T) {
// A naive "_"-joined encoding would make ("user-abc", "extra_hello")
// collide with ("user-abc_extra", "hello"). The length-prefixed encoding
// must keep these distinct.
wh := makeWebhook("PROJ-1")
assert.NotEqual(t,
notificationDedupKey(instanceID, wh, "user-abc", "extra_hello"),
notificationDedupKey(instanceID, wh, "user-abc_extra", "hello"))

// Same idea across the issue key/recipient boundary.
assert.NotEqual(t,
notificationDedupKey(instanceID, makeWebhook("PROJ-1_user"), "abc", "hello"),
notificationDedupKey(instanceID, makeWebhook("PROJ-1"), "user_abc", "hello"))
})
}

func TestChannelPostDedupKey(t *testing.T) {
makeWebhook := func(issueKey, headline, text string, fields []*model.SlackAttachmentField) *webhook {
return &webhook{
JiraWebhook: &JiraWebhook{
Issue: jira.Issue{Key: issueKey},
},
headline: headline,
text: text,
fields: fields,
}
}

const instanceID = types.ID("https://jira.example.com")

t.Run("same instance, issue, channel and content produce the same key", func(t *testing.T) {
wh1 := makeWebhook("PROJ-1", "Actor **commented** on PROJ-1", "some comment", nil)
wh2 := makeWebhook("PROJ-1", "Actor **commented** on PROJ-1", "some comment", nil)
assert.Equal(t,
channelPostDedupKey(instanceID, wh1, "channel-abc"),
channelPostDedupKey(instanceID, wh2, "channel-abc"))
})

t.Run("different channels produce different keys", func(t *testing.T) {
wh := makeWebhook("PROJ-1", "Actor **commented** on PROJ-1", "some comment", nil)
assert.NotEqual(t,
channelPostDedupKey(instanceID, wh, "channel-abc"),
channelPostDedupKey(instanceID, wh, "channel-xyz"))
})

t.Run("different instances produce different keys", func(t *testing.T) {
wh := makeWebhook("PROJ-1", "Actor **commented** on PROJ-1", "some comment", nil)
assert.NotEqual(t,
channelPostDedupKey(types.ID("https://jira-a.example.com"), wh, "channel-abc"),
channelPostDedupKey(types.ID("https://jira-b.example.com"), wh, "channel-abc"))
})

t.Run("different issues produce different keys", func(t *testing.T) {
assert.NotEqual(t,
channelPostDedupKey(instanceID, makeWebhook("PROJ-1", "headline", "text", nil), "channel-abc"),
channelPostDedupKey(instanceID, makeWebhook("PROJ-2", "headline", "text", nil), "channel-abc"))
})

t.Run("different headlines produce different keys", func(t *testing.T) {
assert.NotEqual(t,
channelPostDedupKey(instanceID, makeWebhook("PROJ-1", "Actor **commented** on PROJ-1", "text", nil), "channel-abc"),
channelPostDedupKey(instanceID, makeWebhook("PROJ-1", "Actor **updated** PROJ-1", "text", nil), "channel-abc"))
})

t.Run("different text produce different keys", func(t *testing.T) {
assert.NotEqual(t,
channelPostDedupKey(instanceID, makeWebhook("PROJ-1", "headline", "first comment", nil), "channel-abc"),
channelPostDedupKey(instanceID, makeWebhook("PROJ-1", "headline", "second comment", nil), "channel-abc"))
})

t.Run("different fields produce different keys", func(t *testing.T) {
wh1 := makeWebhook("PROJ-1", "headline", "text", []*model.SlackAttachmentField{{Title: "Priority", Value: "High"}})
wh2 := makeWebhook("PROJ-1", "headline", "text", []*model.SlackAttachmentField{{Title: "Priority", Value: "Low"}})
assert.NotEqual(t,
channelPostDedupKey(instanceID, wh1, "channel-abc"),
channelPostDedupKey(instanceID, wh2, "channel-abc"))
})

t.Run("different field Short flags produce different keys", func(t *testing.T) {
wh1 := makeWebhook("PROJ-1", "headline", "text", []*model.SlackAttachmentField{{Title: "Priority", Value: "High", Short: true}})
wh2 := makeWebhook("PROJ-1", "headline", "text", []*model.SlackAttachmentField{{Title: "Priority", Value: "High", Short: false}})
assert.NotEqual(t,
channelPostDedupKey(instanceID, wh1, "channel-abc"),
channelPostDedupKey(instanceID, wh2, "channel-abc"))
})

t.Run("values containing separator-like characters do not collide", func(t *testing.T) {
// A naive "_"-joined encoding would make ("A_B", "C") collide with ("A", "B_C").
// The length-prefixed encoding must keep these distinct.
wh1 := makeWebhook("PROJ-1", "A_B", "C", nil)
wh2 := makeWebhook("PROJ-1", "A", "B_C", nil)
assert.NotEqual(t,
channelPostDedupKey(instanceID, wh1, "channel-abc"),
channelPostDedupKey(instanceID, wh2, "channel-abc"))

// Same idea across the channelID/headline boundary.
wh3 := makeWebhook("PROJ-1", "B", "text", nil)
assert.NotEqual(t,
channelPostDedupKey(instanceID, wh3, "channel-abc_extra"),
channelPostDedupKey(instanceID, makeWebhook("PROJ-1", "extra_B", "text", nil), "channel-abc"))

// And across a field's title/value boundary.
wh4 := makeWebhook("PROJ-1", "headline", "text", []*model.SlackAttachmentField{{Title: "A=B", Value: "C"}})
wh5 := makeWebhook("PROJ-1", "headline", "text", []*model.SlackAttachmentField{{Title: "A", Value: "B=C"}})
assert.NotEqual(t,
channelPostDedupKey(instanceID, wh4, "channel-abc"),
channelPostDedupKey(instanceID, wh5, "channel-abc"))
})
}
116 changes: 116 additions & 0 deletions server/webhook_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
// Copyright (c) 2017-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.

package main

import (
"sync"
"sync/atomic"
"testing"
"time"

jira "github.com/andygrunwald/go-jira"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/plugin/plugintest"
"github.com/mattermost/mattermost/server/public/plugin/plugintest/mock"
"github.com/mattermost/mattermost/server/public/pluginapi"
"github.com/stretchr/testify/require"
)

func newTestChannelWebhook() *webhook {
return &webhook{
JiraWebhook: &JiraWebhook{
Issue: jira.Issue{ID: "10001", Key: "PROJ-1"},
},
headline: "Actor **commented** on PROJ-1",
}
}

// isDedupClaimOptions matches the KV options PostToChannel's dedup claim must
// use: an atomic write (no old value expected, since the key shouldn't yet
// exist) with the shared webhook dedup TTL.
func isDedupClaimOptions(opts model.PluginKVSetOptions) bool {
return opts.Atomic && opts.OldValue == nil && opts.ExpireInSeconds == int64(webhookDedupTTL/time.Second)
}

func TestPostToChannelDeduplicatesConcurrentDeliveries(t *testing.T) {
t.Run("concurrent deliveries for the same channel post only once", func(t *testing.T) {
api := &plugintest.API{}

var kvWinners atomic.Int32
api.On("KVSetWithOptions", mock.AnythingOfType("string"), mock.Anything, mock.MatchedBy(isDedupClaimOptions)).Return(func(string, []byte, model.PluginKVSetOptions) (bool, *model.AppError) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This stub matches the key with mock.AnythingOfType("string") and decides its return by call count so it cannot observe the dedup. I verified this by patching channelPostDedupKey to return a unique key on every call and still passes all three subtests in this test.

A map based fake makes them real tho

var mu sync.Mutex
claimed := map[string]bool{}
api.On("KVSetWithOptions", mock.AnythingOfType("string"), mock.Anything, mock.MatchedBy(isDedupClaimOptions)).
    Return(func(key string, _ []byte, _ model.PluginKVSetOptions) (bool, *model.AppError) {
        mu.Lock()
        defer mu.Unlock()
        if claimed[key] {
            return false, nil
        }
        claimed[key] = true
        return true, nil
    })

if kvWinners.Add(1) == 1 {
return true, nil
}
return false, nil
})
api.On("CreatePost", mock.AnythingOfType("*model.Post")).Return(&model.Post{Id: "post1"}, nil).Once()
api.On("LogDebug", mockAnythingOfTypeBatch("string", 3)...).Return()

p := &Plugin{}
p.SetAPI(api)
p.client = pluginapi.NewClient(api, p.Driver)

const callers = 25
var wg sync.WaitGroup
wg.Add(callers)
start := make(chan struct{})
for i := 0; i < callers; i++ {
go func() {
defer wg.Done()
<-start
_, _, err := newTestChannelWebhook().PostToChannel(p, "instance-1", "channel-1", "bot-user-id", "")
require.NoError(t, err)
}()
}
close(start)
wg.Wait()

api.AssertExpectations(t)
})

t.Run("overlapping subscriptions on the same channel still post only once", func(t *testing.T) {
// Two subscriptions on the same channel produce identical webhook content
// but different subscription names; the dedup key must ignore the name.
api := &plugintest.API{}
var kvWinners atomic.Int32
api.On("KVSetWithOptions", mock.AnythingOfType("string"), mock.Anything, mock.MatchedBy(isDedupClaimOptions)).Return(func(string, []byte, model.PluginKVSetOptions) (bool, *model.AppError) {
if kvWinners.Add(1) == 1 {
return true, nil
}
return false, nil
}).Twice()
api.On("CreatePost", mock.AnythingOfType("*model.Post")).Return(&model.Post{Id: "post1"}, nil).Once()
api.On("LogDebug", mockAnythingOfTypeBatch("string", 3)...).Return().Once()

p := &Plugin{}
p.SetAPI(api)
p.client = pluginapi.NewClient(api, p.Driver)

_, _, err := newTestChannelWebhook().PostToChannel(p, "instance-1", "channel-1", "bot-user-id", "subscription-a")
require.NoError(t, err)

post, _, err := newTestChannelWebhook().PostToChannel(p, "instance-1", "channel-1", "bot-user-id", "subscription-b")
require.NoError(t, err)
require.Nil(t, post)

api.AssertExpectations(t)
})

t.Run("different channels each get their own post", func(t *testing.T) {
api := &plugintest.API{}
api.On("KVSetWithOptions", mock.AnythingOfType("string"), mock.Anything, mock.MatchedBy(isDedupClaimOptions)).Return(true, (*model.AppError)(nil)).Twice()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same issue as the stub in the first subtest: this always returns true regardless of the key, so this test passes even if channelID were dropped from channelPostDedupKey entirely. The map-based fake plus require.Len(t, claimed, 2) is what actually asserts "different channels each get their own post".

api.On("CreatePost", mock.AnythingOfType("*model.Post")).Return(&model.Post{Id: "post1"}, nil).Twice()

p := &Plugin{}
p.SetAPI(api)
p.client = pluginapi.NewClient(api, p.Driver)

_, _, err := newTestChannelWebhook().PostToChannel(p, "instance-1", "channel-1", "bot-user-id", "")
require.NoError(t, err)
_, _, err = newTestChannelWebhook().PostToChannel(p, "instance-1", "channel-2", "bot-user-id", "")
require.NoError(t, err)

api.AssertExpectations(t)
})
}
Loading