Files
BaaRai 542582e3bc notify: pack Feishu digests against the serialized request body
A Feishu digest sends every item as its own card element. The packing
counted the text of each line only, not the element around it (about 52
bytes of JSON per item, plus whatever the encoding escapes) nor the rest
of the body beyond a fixed 320-byte reserve. A digest of 500 short lines
therefore left as a single request of 41846 bytes, while Feishu rejects
a request body over 20 KB as a whole.

feishuCard now adds whole items while the request body, measured as it
is serialized, stays within feishuMaxCardBytes: each item element by its
JSON encoding, the card around the items (header, intro, platform link)
by encoding it for the number of items kept, and what Send adds around
the card (msg_type, timestamp, sign) as feishuEnvelopeBytes, which the
new test pins to the bytes on the wire.

Unchanged: the first item of a digest is always kept and its text is
truncated to the same budget, single pushes are built as before, and
the other channels still pack with packItemCount.
2026-11-10 16:28:30 +01:00

336 lines
12 KiB
Go

package notify
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"unicode/utf8"
)
// This file covers the "pack whole items" fix: when a digest message exceeds the
// channel's length limit, it must be truncated **item by item** and honestly
// report how many items did not fit, so the caller marks only the ones actually
// delivered.
//
// The previous approach rendered the whole thing, truncated it, and then marked
// the whole batch as delivered: the second half of the message vanished into
// thin air while the delivery history showed all succeeded — vulnerabilities
// were simply lost, and nowhere could you find out.
func TestMarkdownBodyPacksWholeItemsWithinByteLimit(t *testing.T) {
// 200 digest items are guaranteed to far exceed WeCom's 4096 bytes.
m := batchMsg(200)
body, kept := markdownBody(m, weComMarkdownLimit)
if len(body) > weComMarkdownLimit {
t.Fatalf("body of %d bytes exceeds the limit of %d", len(body), weComMarkdownLimit)
}
if !utf8.ValidString(body) {
t.Fatal("body is not valid UTF-8")
}
if kept <= 0 || kept >= len(m.Items) {
t.Fatalf("only a part should fit (0 < kept < %d), got %d", len(m.Items), kept)
}
// The header must honestly state how many items this message contains and how
// many remain — otherwise readers take the number in the header for the whole
// batch.
if !strings.Contains(body, "remaining") || !strings.Contains(body, "next message") {
t.Fatalf("the header should state how many items are not included in this message:\n%s", body[:minInt(400, len(body))])
}
// Only the first kept items should be included.
for i := 0; i < kept; i++ {
if !strings.Contains(body, "vuln"+itoa(i+1)) {
t.Fatalf("item %d should be in this message:\n%s", i+1, body)
}
}
if strings.Contains(body, "vuln"+itoa(kept+1)) {
t.Fatalf("item %d should not appear (it belongs to the next batch)", kept+1)
}
}
func TestMarkdownBodyKeepsEverythingWhenUnderLimit(t *testing.T) {
m := batchMsg(3)
body, kept := markdownBody(m, 0) // 0 = unlimited
if kept != len(m.Items) {
t.Fatalf("everything should be kept when length is unlimited, got kept=%d", kept)
}
if strings.Contains(body, "remaining") {
t.Fatalf("no truncation notice should appear when nothing was truncated:\n%s", body)
}
}
func TestMarkdownBodyAlwaysKeepsAtLeastOneItem(t *testing.T) {
// When the budget is too small to fit even one item, still send one (with
// final truncation as the backstop). Otherwise a single oversized
// vulnerability would permanently jam the whole batch in place: never fitting
// on every claim, never sent.
m := batchMsg(5)
_, kept := markdownBody(m, 50)
if kept != 1 {
t.Fatalf("at least 1 item should be kept, got %d", kept)
}
}
func TestMarkdownBodySingleReturnsOne(t *testing.T) {
_, kept := markdownBody(singleMsg(), 4096)
if kept != 1 {
t.Fatalf("a single message should report 1 delivered, got %d", kept)
}
// An empty message has no items to deliver.
if _, k := markdownBody(Message{}, 4096); k != 0 {
t.Fatalf("an empty message should report 0 items, got %d", k)
}
}
func TestTelegramPackingUsesRuneBudget(t *testing.T) {
m := batchMsg(200)
text, kept := telegramHTML(m)
// Telegram limits by **character count**; measuring in bytes would cram a
// CJK message down to a third.
if n := utf8.RuneCountInString(text); n > telegramTextLimit {
t.Fatalf("body of %d characters exceeds the limit of %d", n, telegramTextLimit)
}
if kept <= 0 || kept >= len(m.Items) {
t.Fatalf("only a part should fit, got %d", kept)
}
if !strings.Contains(text, "next message") {
t.Fatalf("should state that more items are not included:\n%.300s", text)
}
}
func TestFeishuPackingReportsKept(t *testing.T) {
m := batchMsg(2000)
_, kept := feishuCard(m)
if kept <= 0 || kept >= len(m.Items) {
t.Fatalf("the card should only fit a part, got %d", kept)
}
}
// TestFeishuDigestBodiesStayWithinCardLimit covers digests of many short items.
//
// Every digest line is sent as its own card element, which costs about 52 bytes
// of JSON around the text plus whatever the encoding escapes. The packing used
// to count the text only, so a digest of several hundred short lines left as one
// request of more than twice Feishu's 20 KB limit, and Feishu rejects such a
// request as a whole.
//
// The digest is sent the way the delivery engine does it: the items a message
// did not hold go into the next one. The bodies measured are the real ones sent
// on the wire, signature included. No item may be lost on the way: across the
// messages, every item appears exactly once and in order.
func TestFeishuDigestBodiesStayWithinCardLimit(t *testing.T) {
const total = 500
short := batchMsg(total)
for i := range short.Items {
short.Items[i].Assets = nil
}
// The JSON encoding escapes quotes, angle brackets and ampersands, and the
// backslashes the lark_md escaping put in before it: these titles take
// several times their length once serialized.
escaped := batchMsg(total)
for i := range escaped.Items {
escaped.Items[i].Name = `<a href="x">R&D</a> vuln` + itoa(i+1)
}
cases := []struct {
name string
m Message
}{
{"short lines without assets", short},
{"short lines with one asset", batchMsg(total)},
{"lines that grow when serialized", escaped},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
var raw []byte
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
raw, _ = io.ReadAll(r.Body)
_, _ = io.WriteString(w, `{"code":0,"msg":"success"}`)
}))
defer srv.Close()
cfg := map[string]any{"webhook": srv.URL, "secret": "SECtest123"}
m := tc.m
seen, messages := 0, 0
for len(m.Items) > 0 {
kept, err := (feishuChannel{}).Send(context.Background(), cfg, m)
if err != nil {
t.Fatalf("message %d: delivery failed: %v", messages+1, err)
}
messages++
if len(raw) > feishuMaxCardBytes {
t.Fatalf("message %d: request body of %d bytes exceeds the limit of %d (%d of %d items left to send)",
messages, len(raw), feishuMaxCardBytes, len(m.Items), total)
}
var body struct {
Card json.RawMessage `json:"card"`
}
var card struct {
Elements []struct {
Tag string `json:"tag"`
Text struct {
Content string `json:"content"`
} `json:"text"`
} `json:"elements"`
}
if err := json.Unmarshal(raw, &body); err != nil {
t.Fatalf("message %d: request body is not valid JSON: %v", messages, err)
}
if err := json.Unmarshal(body.Card, &card); err != nil {
t.Fatalf("message %d: card is not valid JSON: %v", messages, err)
}
// The packing counts what Send adds around the card as a constant.
if got := len(raw) - len(body.Card); got != feishuEnvelopeBytes {
t.Fatalf("message %d: %d bytes around the card, feishuEnvelopeBytes says %d", messages, got, feishuEnvelopeBytes)
}
// Element 0 is the intro; the button, when present, is not a div.
lines := 0
for _, el := range card.Elements[1:] {
if el.Tag != "div" {
continue
}
lines++
if want := "vuln" + itoa(seen+lines) + "**"; !strings.Contains(el.Text.Content, want) {
t.Fatalf("message %d: line %d should be item %d (%q), got %q", messages, lines, seen+lines, want, el.Text.Content)
}
}
if kept <= 0 || kept != lines {
t.Fatalf("message %d: %d items reported as delivered, the body holds %d", messages, kept, lines)
}
t.Logf("message %d: %d items, %d bytes", messages, kept, len(raw))
seen += kept
m.Items = m.Items[kept:]
}
if seen != total {
t.Fatalf("%d items went out across %d messages, want %d", seen, messages, total)
}
if messages < 2 {
t.Fatalf("%d items should not fit one message", total)
}
})
}
}
func TestWebhookAndEmailReportAllItems(t *testing.T) {
// These two channels do not truncate the body; the whole batch counts as
// delivered.
m := batchMsg(7)
if n := len(m.Items); n != 7 {
t.Fatal("precondition does not hold")
}
// Confirm indirectly via the renderer's return value: markdownBody(0) keeps
// everything when unlimited.
if _, k := markdownBody(m, 0); k != len(m.Items) {
t.Fatalf("everything should be used when length is unlimited, got %d", k)
}
}
// TestMarkdownEscapesUntrustedContent is the regression test for "untrusted
// content must not change message structure". Titles and summaries come from
// model output (the model reads the tested target's responses); asset names come
// from the tested target's URLs.
func TestMarkdownEscapesUntrustedContent(t *testing.T) {
cases := []struct {
name string
item Item
must []string // must appear in the result (escaped form)
wrong []string // must not appear in the result (unescaped form)
}{
{
name: "newline + external link in the title",
item: Item{
Severity: "high",
Name: "SQL injection on login page\n[URGENT: click to verify your account](http://attacker.tld)",
},
// The newline must be collapsed (otherwise it can forge a new list
// item / quote block); the square brackets and parentheses must be
// escaped (otherwise it is a clickable external link).
must: []string{`\[URGENT: click to verify your account\]`, `\(http://attacker.tld\)`},
wrong: []string{"\n[URGENT", "\n\n[URGENT"},
},
{
name: "image beacon in the title",
item: Item{
Severity: "high",
Name: "Vuln ![](http://attacker.tld/beacon)",
},
must: []string{`\!`, `\(http://attacker.tld/beacon\)`},
wrong: []string{"![]("},
},
{
name: "emphasis and quote in an asset name",
item: Item{
Severity: "high",
Name: "plain title",
Assets: []string{"a.com/*inject*>quote"},
},
must: []string{`\*inject\*`, `\>`},
wrong: []string{"*inject*"},
},
{
name: "backticks and pipe in the summary",
item: Item{
Severity: "high",
Name: "title",
Summary: "`code` | table",
},
must: []string{"\\`code\\`", `\|`},
wrong: []string{"`code`"},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
m := Message{Items: []Item{tc.item}}
// writeItem in single mode is the rendering path shared by all three
// markdown channels.
var b strings.Builder
writeItem(&b, tc.item, "", true, m.limits())
got := b.String()
for _, want := range tc.must {
if !strings.Contains(got, want) {
t.Errorf("missing escaped form %q:\n%s", want, got)
}
}
for _, bad := range tc.wrong {
if strings.Contains(got, bad) {
t.Errorf("unescaped form %q appeared (can be used to inject structure or external links):\n%s", bad, got)
}
}
_ = m
})
}
}
// TestMarkdownEscapeBackslashFirst locks the escape order: the backslash must be
// handled first, otherwise the backslashes added later get another layer and the
// output ends up with double backslashes.
func TestMarkdownEscapeBackslashFirst(t *testing.T) {
if got := markdownEscape(`a\b*c`); got != `a\\b\*c` {
t.Fatalf("wrong escape order, got %q", got)
}
}
// TestTelegramTitleHasNoMarkdownEscapes locks a specific regression: markdown
// escapes must not leak into Telegram's HTML output (an escape was once added to
// the shared title function, and Telegram messages ended up showing visible
// backslashes like `\(1\)`).
func TestTelegramTitleHasNoMarkdownEscapes(t *testing.T) {
m := Message{Items: []Item{{Severity: "high", Name: "alert(1) *important*"}}}
text, _ := telegramHTML(m)
if strings.Contains(text, `\(`) || strings.Contains(text, `\*`) {
t.Fatalf("markdown backslash escapes appeared in the Telegram body:\n%s", text)
}
}
func minInt(a, b int) int {
if a < b {
return a
}
return b
}