Files
luckoneandClaude Code a049f09b97 fix(notify): 安全审计修复 —— 掩码绕过、SSRF、静默丢失等 12 项
对推送功能做了一轮安全审计,每条都先复现、再修、再补回归测试。
以下按严重程度排列。

【严重】掩码可被「改目标地址、保留凭据」绕过
目标地址与凭据是两套独立字段,而配置合并对「未提及的键」保留原值。于是
只改地址、对凭据避而不谈,服务端就会把库里的真凭据发到任意地址。已实测
复现四个渠道:通用 Webhook 的 Authorization 头、Telegram 的 Bot Token
(进请求路径)、邮件的 SMTP 密码(STARTTLS 后交给对端)、钉钉/飞书的加签
密钥(该项不可利用,一并列出仅为说明面)。完全静默、不依赖重定向,所以
上一轮加的「拒绝跨主机跳转」挡不住它。

修法:新增 Channel.DestinationKeys() 声明「消息发往哪」,PrepareConfigUpdate
在地址变化时要求对**每一个**凭据字段显式表态(给新值,或显式留空表示不再
需要)——原样回传掩码值等于「沿用旧凭据」,正是攻击形状,一并拒绝。
刻意不做「自动丢弃凭据」:对可选字段(如 headers)那会变成「鉴权静默没了
但接口返回成功」,比报错更难排查。

【中】markdown 系三渠道对不可信内容零转义
钉钉/企微/飞书此前完全不转义(Telegram 与邮件都做了),而标题与摘要来自
模型输出、资产 url 来自被测目标。一条标题里的 `[文字](http://evil)` 会在
工程师的钉钉里变成可点击外链;`![](http://evil/beacon)` 会被客户端拉取,
泄露阅读者 IP 并暴露「这条已被看过」。现统一单行化 + markdown 元字符转义。

修的过程中还纠了自己一个设计错误:转义一度被放进 markdownTitle,而它被
markdown 正文、Telegram 的 HTML、飞书卡片、Webhook 的 JSON 与邮件主题四种
语境共用,markdown 的反斜杠泄漏到了 Telegram 消息里(用户会看到 `\(1\)`)。
转义必须发生在各自的渲染出口——测试直接抓到了这个回归。

【中】投递地址的 SSRF 面
此前只校验 scheme 与 host,云元数据 169.254.169.254、环回、内网一律可投递;
而失败响应体前 200 字节会进 last_error 并被投递历史接口回显,构成半盲读
原语。现在在**拨号阶段**设防(最终生效点,同时覆盖 DNS 重绑定与同主机
重定向)。环回/链路本地需显式 ARTEX_NOTIFY_ALLOW_LOCAL=1 才放行(本机
SMTP 中继是合法配置);RFC1918 私网**刻意放行**——内网自建 Mattermost /
SMTP 很常见,做到那个程度会把正常部署一起废掉,这条取舍写进了测试断言。

【中】汇总消息被截断后整批标记已送达 = 静默丢失
渠道都有长度上限(企微 4096 字节最紧)。此前渲染完整篇再截断、然后整批
标记成功:被截掉的那些既不在消息里、也不在失败列表里,投递历史还显示
成功。现改为按**整条**打包,只标记真正装下的,其余回队续发,消息头部
如实写明还有多少条在下一条。被推迟的条目**不消耗重试次数**(领取时乐观
+1 的那次减回去),否则 500 条的积压会在第三段就把尾部判成失败。

【中】其余修复
- min_severity 打错字让过滤器静默失效(未知级别序数为 0 → rank>=0 恒真,
  用户以为「仅高危」实际全推)。改为写入时校验取值。
- rate_per_min=0(不限流)不可达:文档/UI/令牌桶都按 0=不限流解释,唯独
  写库那层 `if <=0 取默认值` 把它改成 20 或 100。默认值改到接口层填。
- digest 渠道完全绕过令牌桶(allow 被扣却没人用)。
- 失败处置按整批 max(attempts) 判断,让老投递连坐同批的新投递。
- 汇总批次里快照无法解析的投递被静默标记成功。
- url.Parse 失败时错误文本仍带完整地址(上一轮只修了 http.Client.Do 那侧;
  且当时的用例全走 scheme 分支、没覆盖解析失败路径,是假保证)。
- 单渠道每轮投递条数可能超出租约时长(多实例下重复发送)。加了防漂移
  断言,写的时候立刻发现原取值 6 恰好用满租约、零余量,已改 5。
- Telegram 截断可能切断 HTML 实体,导致整条被拒收。
- 邮件把 SMTP 4xx(灰名单等临时失败)判成永久失败,最该重试的场景从不重试。
- 提交嵌套结构时会把掩码字面量真的写进库,导致鉴权静默失效。

测试:notify 包从 82% 提到 86%,新增 SSRF 守卫、掩码绕过(用攻击形状的
输入而非「防御逻辑的正确输入」)、分段投递收敛、markdown 转义、SMTP 4xx/5xx
分类等回归用例;端到端补了一条「60 条长标题跨多轮发完且不卡死」的收敛测试。

Co-Authored-By: Claude Code <[email protected]>
2026-09-30 16:25:06 +08:00

151 lines
5.3 KiB
Go

package notify
import (
"strings"
"testing"
"unicode/utf8"
)
func TestTruncateBytesKeepsValidUTF8(t *testing.T) {
// 这是本包最要紧的一条不变量。企微按**字节**限长,中文 3 字节/字,
// 任何按字节硬切的实现都会把汉字切成半个,产出非法 UTF-8 而被平台拒收。
// 用长度互质的多种中英混排输入去撞每一个可能的切点。
inputs := []string{
"中文测试内容",
"混合 mixed 内容 content",
"a中b文c测d试e",
"🔴🟠🟡🔵", // 4 字节 emoji,切错更明显
strings.Repeat("漏洞", 100),
}
for _, in := range inputs {
for max := 1; max <= len(in)+2; max++ {
got := TruncateBytes(in, max)
if !utf8.ValidString(got) {
t.Fatalf("输入 %q max=%d: 产出非法 UTF-8 %q", in, max, got)
}
if len(got) > max {
t.Fatalf("输入 %q max=%d: 结果 %d 字节超出上限", in, max, len(got))
}
// 未被截断时不得改动内容。
if len(in) <= max && got != in {
t.Fatalf("输入 %q max=%d: 未超限却改动了内容 -> %q", in, max, got)
}
}
}
}
func TestTruncateBytesZeroMeansUnlimited(t *testing.T) {
long := strings.Repeat("x", 10000)
if got := TruncateBytes(long, 0); got != long {
t.Fatal("max=0 应表示不限制")
}
if got := TruncateBytes(long, -5); got != long {
t.Fatal("max<0 应表示不限制")
}
}
func TestTruncateBytesEllipsisBudget(t *testing.T) {
// max 小于省略号本身时,不能因为追加省略号而反过来超限。
got := TruncateBytes("abcdefgh", 1)
if len(got) > 1 {
t.Fatalf("max=1 时结果 %q 长度 %d 超限", got, len(got))
}
// 正常情况应带省略号。
if got := TruncateBytes("abcdefgh", 5); !strings.HasSuffix(got, ellipsis) {
t.Fatalf("期望带省略号,得到 %q", got)
}
}
func TestTruncateRunesCountsCharactersNotBytes(t *testing.T) {
// 与 TruncateBytes 的口径差异必须保留:Telegram 按字符限长,
// 用字节口径会把中文消息切到只剩三分之一。
s := "一二三四五六七八九十"
got := TruncateRunes(s, 5)
if n := utf8.RuneCountInString(got); n != 5 {
t.Fatalf("期望 5 个字符,得到 %d 个 (%q)", n, got)
}
// 同样的字符串按字节口径应明显更短。
if utf8.RuneCountInString(TruncateBytes(s, 5)) >= 5 {
t.Fatal("字节口径不应产出与字符口径相同的字符数")
}
}
func TestOneLineCollapsesWhitespace(t *testing.T) {
got := OneLine("第一行\n\n第二行\t带制表 多空格", 0)
if strings.ContainsAny(got, "\n\t") {
t.Fatalf("应折叠所有空白,得到 %q", got)
}
if strings.Contains(got, " ") {
t.Fatalf("不应保留连续空格,得到 %q", got)
}
// 截断后仍须可读且合法。
got = OneLine("一二三四五六七八九十", 4)
if n := utf8.RuneCountInString(got); n != 4 {
t.Fatalf("期望 4 字符,得到 %d (%q)", n, got)
}
}
func TestTruncateHTMLNeverCutsTagInHalf(t *testing.T) {
// 直接截断 HTML 会切出 `<a href="htt` 这种残片,平台会拒收整条消息。
s := `<b>标题</b>正文正文正文<a href="https://example.com/very/long/path">查看详情</a>`
for max := 1; max <= utf8.RuneCountInString(s)+2; max++ {
got := TruncateHTML(s, max)
if n := utf8.RuneCountInString(got); max > 0 && n > max {
t.Fatalf("max=%d: 结果 %d 字符超限", max, n)
}
// 尾部不能有未闭合的 `<`(即最后一段里出现 `<` 却无 `>`)。
if lt := strings.LastIndex(got, "<"); lt >= 0 && !strings.Contains(got[lt:], ">") {
t.Fatalf("max=%d: 尾部标签被切断 -> %q", max, got)
}
}
}
func TestAssetLineOmitsExcess(t *testing.T) {
if got := assetLine(nil, 3); got != "" {
t.Fatalf("无资产应返回空串,得到 %q", got)
}
if got := assetLine([]string{"a", "b"}, 3); got != "a、b" {
t.Fatalf("未超限应全列,得到 %q", got)
}
// 超出上限时必须标注总数,否则读者不知道还有多少资产没列出来。
got := assetLine([]string{"a", "b", "c", "d", "e"}, 2)
if !strings.Contains(got, "等 5 个") {
t.Fatalf("应标注总数 5,得到 %q", got)
}
}
func TestSeverityAndStatusLabels(t *testing.T) {
if AtLeast("", "low") {
t.Fatal("空级别序数为 0,应被任何门槛挡住")
}
if !AtLeast("critical", "") {
t.Fatal("空门槛应放行")
}
if got := StatusLabel("fixed"); got != "已修复" {
t.Fatalf("未知状态映射,得到 %q", got)
}
// 未知状态原样回显,不臆造标签。
if got := StatusLabel("weird_status"); got != "weird_status" {
t.Fatalf("未知状态应原样回显,得到 %q", got)
}
}
// TestTruncateHTMLNeverCutsEntity 覆盖审计指出的一处遗漏:截断不只要避开
// 半截标签,还要避开被切断的 HTML 实体。
//
// `&amp;` 被切成 `&amp` 之后,一个只认实体的解析器可能拒收**整条**消息——
// 而超长汇总消息本来就常见,代价太大。
func TestTruncateHTMLNeverCutsEntity(t *testing.T) {
s := "aaaa&amp;bbbb&lt;cccc&quot;dddd"
for max := 1; max <= utf8.RuneCountInString(s)+2; max++ {
got := TruncateHTML(s, max)
// 尾部不得出现「有 & 但没有对应 ;」的实体残片。
if amp := strings.LastIndex(got, "&"); amp >= 0 && !strings.Contains(got[amp:], ";") {
t.Fatalf("max=%d: 尾部留下实体残片 %q", max, got[amp:])
}
if strings.Contains(got, "&amp\x00") {
t.Fatalf("max=%d: 出现畸形实体", max)
}
}
}