CVE-2022-25891 (Go, published 2022-07-15T20:15Z)
Description: value: The package github.com/containrrr/shoutrrr/pkg/util before 0.6.0 are vulnerable to Denial of Service (DoS) via the util.PartitionMessage function. Exploiting this vulnerability is possible by sending exactly 2000, 4000, or 6000 characters messages.
Severity: nan (CVSS v3 base score: 7.5)
Weakness: NVD-CWE-noinfo \u2014 Insufficient Information
There is insufficient information about the issue to classify it; details are unkown or unspecified.
Repository: https://github.com/containrrr/shoutrrr
Files changed:
pkg/services/discord/discord.go
pkg/services/discord/discord_json.go
pkg/services/discord/discord_test.go
pkg/util/partition_message.go
pkg/util/partition_message_test.go
Commit message: fix(discord): message size fixes (#242)

* fix(discord): handle empty messages without panic
* fix: off by one error when partitioning messages
* test(discord): add tests for Send failures
* fix(discord): typo in meta message

Co-authored-by: Justin Steven <justin@justinsteven.com>

Vulnerable code:
	"net/http"
	"net/url"
func (service *Service) Send(message string, params *types.Params) error {

		return doSend([]byte(message), postURL)
	items, omitted := CreateItemsFromPlain(message, service.config.SplitLines)
	return service.sendItems(items, params, omitted)
	if err != nil {
		return fmt.Errorf("failed to send discord notification: %v", err)
	}

	return nil
	"time"
	Embeds   []embedItem `json:"embeds"`
	Username string      `json:"username,omitempty"`
	AvatarURL string     `json:"avatar_url,omitempty"`
	embeds[0].Title = title
	if omitted > 0 {
		embeds[0].Footer = &embedFooter{
			Text: fmt.Sprintf("... (%v character(s) where omitted)", omitted),
		//When("given a blank message", func() {
		//	It("should return an error", func() {
		//		_, err := CreatePayloadFromItems("", false)
		//		Expect(err).To(HaveOccurred())
		//	})
		//})
		var err error
			config := Config{
				WebhookID: "1",
				Token:     "dummyToken",
			}
			serviceURL := config.GetURL()
			service := Service{}
			err = service.Initialize(serviceURL, logger)
			Expect(err).NotTo(HaveOccurred())
			setupResponder(&config, 204, "")

			err = service.Send("Message", nil)
			Expect(err).NotTo(HaveOccurred())

		// If no suitable split point is found, use the chunkSize
		chunkEnd := chunkOffset + limits.ChunkSize
		// ... and start next chunk directly after this one
		nextChunkStart := chunkEnd
	"strings"
	Describe("creating a json payload", func() {
		limits := types.MessageLimit{
			ChunkSize:      2000,
			TotalChunkSize: 6000,
			ChunkCount:     10,
		}
		When("given a message that exceeds the max length", func() {
			When("not splitting by lines", func() {
				It("should return a payload with chunked messages", func() {
					items, _ := testPartitionMessage(42, limits, 100)
					Expect(len(items[0].Text)).To(Equal(1994))
					Expect(len(items[1].Text)).To(Equal(1999))
					Expect(len(items[2].Text)).To(Equal(205))
				})
				It("omit characters above total max", func() {
					items, _ := testPartitionMessage(62, limits, 100)
					Expect(len(items[0].Text)).To(Equal(1994))
					Expect(len(items[1].Text)).To(Equal(1999))
					Expect(len(items[2].Text)).To(Equal(1999))
					Expect(len(items[3].Text)).To(Equal(5))
			When("splitting by lines", func() {
				It("should return a payload with chunked messages", func() {
					items, omitted := testMessageItemsFromLines(18, limits, 2)
					Expect(len(items[0].Text)).To(Equal(200))
					Expect(len(items[8].Text)).To(Equal(200))
					Expect(omitted).To(Equal(0))
				})
				It("omit characters above total max", func() {
					items, omitted := testMessageItemsFromLines(19, limits, 2)
					Expect(len(items[0].Text)).To(Equal(200))
					Expect(len(items[8].Text)).To(Equal(200))
					Expect(omitted).To(Equal(100))
				})
				It("should trim characters above chunk size", func() {
					hundreds := 42
					repeat := 21
					items, omitted := testMessageItemsFromLines(hundreds, limits, repeat)
					Expect(len(items[0].Text)).To(Equal(limits.ChunkSize))
					Expect(len(items[1].Text)).To(Equal(limits.ChunkSize))
					// Trimmed characters do not count towards the total omitted count
					Expect(omitted).To(Equal(0))
				})
				It("omit characters above total chunk size", func() {
					hundreds := 100
					repeat := 20
					items, omitted := testMessageItemsFromLines(hundreds, limits, repeat)
					Expect(len(items[0].Text)).To(Equal(limits.ChunkSize))
					Expect(len(items[1].Text)).To(Equal(limits.ChunkSize))
					Expect(len(items[2].Text)).To(Equal(limits.ChunkSize))
					maxRunes := hundreds * 100
					expectedOmitted := maxRunes - limits.TotalChunkSize
					Expect(omitted).To(Equal(expectedOmitted))
				})

Fixed code:
	"net/http"
	"net/url"

func (service *Service) Send(message string, params *types.Params) (err error) {
		err = doSend([]byte(message), postURL)
	} else {
		items, omitted := CreateItemsFromPlain(message, service.config.SplitLines)
		err = service.sendItems(items, params, omitted)
	if err != nil {
		err = fmt.Errorf("failed to send discord notification: %v", err)
	}

	return
	return err
	"time"

	Embeds    []embedItem `json:"embeds"`
	Username  string      `json:"username,omitempty"`
	AvatarURL string      `json:"avatar_url,omitempty"`
	if len(items) < 1 {
		return WebhookPayload{}, fmt.Errorf("message is empty")
	}

	// This should not happen, but it's better to leave the index check before dereferencing the array
	if len(embeds) > 0 {
		embeds[0].Title = title

		if omitted > 0 {
			embeds[0].Footer = &embedFooter{
				Text: fmt.Sprintf("... (%v character(s) were omitted)", omitted),
			}
	"fmt"
		When("given a blank message", func() {
			When("split lines is enabled", func() {
				It("should return an error", func() {
					items, omitted := CreateItemsFromPlain("", true)
					Expect(items).To(BeEmpty())
					_, err := CreatePayloadFromItems(items, "title", dummyColors, omitted)
					Expect(err).To(HaveOccurred())
				})
			})
			When("split lines is disabled", func() {
				It("should return an error", func() {
					items, omitted := CreateItemsFromPlain("", false)
					Expect(items).To(BeEmpty())
					_, err := CreatePayloadFromItems(items, "title", dummyColors, omitted)
					Expect(err).To(HaveOccurred())
				})
			})
		})
		var dummyConfig = Config{
			WebhookID: "1",
			Token:     "dummyToken",
		}
		var service Service
			service = Service{}
			if err := service.Initialize(dummyConfig.GetURL(), logger); err != nil {
				panic(fmt.Errorf("service initialization failed: %v", err))
			}
			setupResponder(&dummyConfig, 204, "")
			Expect(service.Send("Message", nil)).To(Succeed())
		})
		It("should report an error if the server response is not OK", func() {
			setupResponder(&dummyConfig, 400, "")
			Expect(service.Initialize(dummyConfig.GetURL(), logger)).To(Succeed())
			Expect(service.Send("Message", nil)).NotTo(Succeed())
		})
		It("should report an error if the message is empty", func() {
			setupResponder(&dummyConfig, 204, "")
			Expect(service.Initialize(dummyConfig.GetURL(), logger)).To(Succeed())
			Expect(service.Send("", nil)).NotTo(Succeed())
		})
		When("using a custom json payload", func() {
			It("should report an error if the server response is not OK", func() {
				config := dummyConfig
				config.JSON = true
				setupResponder(&config, 400, "")
				Expect(service.Initialize(config.GetURL(), logger)).To(Succeed())
				Expect(service.Send("Message", nil)).NotTo(Succeed())
			})
	if len(input) == 0 {
		// If the message is empty, return an empty array
		omitted = 0
		return
	}

		// If no suitable split point is found, start next chunk at chunkSize from chunk start
		nextChunkStart := chunkOffset + limits.ChunkSize
		// ... and set the chunk end to the rune before the next chunk
		chunkEnd := nextChunkStart - 1
	"strings"

	limits := types.MessageLimit{
		ChunkSize:      2000,
		TotalChunkSize: 6000,
		ChunkCount:     10,
	}
	When("given a message that exceeds the max length", func() {
		When("not splitting by lines", func() {
			It("should return a payload with chunked messages", func() {
				items, _ := testPartitionMessage(42, limits, 100)
				Expect(len(items[0].Text)).To(Equal(1994))
				Expect(len(items[1].Text)).To(Equal(1999))
				Expect(len(items[2].Text)).To(Equal(205))
			})
			It("omit characters above total max", func() {
				items, _ := testPartitionMessage(62, limits, 100)
				Expect(len(items[0].Text)).To(Equal(1994))
				Expect(len(items[1].Text)).To(Equal(1999))
				Expect(len(items[2].Text)).To(Equal(1999))
				Expect(len(items[3].Text)).To(Equal(5))
			})
			It("should handle messages with a size modulus of chunksize", func() {
				items, _ := testPartitionMessage(20, limits, 100)
				Expect(len(items[0].Text)).To(Equal(1994))
				Expect(len(items[1].Text)).To(Equal(5))

				items, _ = testPartitionMessage(40, limits, 100)
				Expect(len(items[0].Text)).To(Equal(1994))
				Expect(len(items[1].Text)).To(Equal(1999))
				Expect(len(items[2].Text)).To(Equal(5))
			})
			When("the message is empty", func() {
				It("should return no items", func() {
					items, _ := testPartitionMessage(0, limits, 100)
					Expect(items).To(BeEmpty())
		})
		When("splitting by lines", func() {
			It("should return a payload with chunked messages", func() {
				items, omitted := testMessageItemsFromLines(18, limits, 2)
				Expect(len(items[0].Text)).To(Equal(200))
				Expect(len(items[8].Text)).To(Equal(200))
				Expect(omitted).To(Equal(0))
			})
			It("omit characters above total max", func() {
				items, omitted := testMessageItemsFromLines(19, limits, 2)
				Expect(len(items[0].Text)).To(Equal(200))
				Expect(len(items[8].Text)).To(Equal(200))
				Expect(omitted).To(Equal(100))
			})
			It("should trim characters above chunk size", func() {
				hundreds := 42
				repeat := 21
				items, omitted := testMessageItemsFromLines(hundreds, limits, repeat)
				Expect(len(items[0].Text)).To(Equal(limits.ChunkSize))
				Expect(len(items[1].Text)).To(Equal(limits.ChunkSize))
				// Trimmed characters do not count towards the total omitted count
				Expect(omitted).To(Equal(0))
			})
			It("omit characters above total chunk size", func() {
				hundreds := 100
				repeat := 20
				items, omitted := testMessageItemsFromLines(hundreds, limits, repeat)
				Expect(len(items[0].Text)).To(Equal(limits.ChunkSize))
				Expect(len(items[1].Text)).To(Equal(limits.ChunkSize))
				Expect(len(items[2].Text)).To(Equal(limits.ChunkSize))
				maxRunes := hundreds * 100
				expectedOmitted := maxRunes - limits.TotalChunkSize
				Expect(omitted).To(Equal(expectedOmitted))