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]>
This commit is contained in:
luckoneandClaude Code committed 2026-09-30 16:25:06 +08:00
1 parent f3fb37f70d
commit a049f09b97
26 files changed
+1586 -229

No files matched your search

+141 -11
View File
@@ -45,6 +45,10 @@ type notifyFixture struct {
func newNotifyFixture(t *testing.T) *notifyFixture {
t.Helper()
// 本文件的所有假接收端都跑在 127.0.0.1 上,而投递默认拒绝环回地址
// (防 SSRF 打到同机服务与云元数据)。测试显式打开这个开关;
// 守卫「默认拒绝」的行为由 notify 包的 ssrf_test.go 覆盖。
t.Setenv(notify.AllowLocalTargetsEnv, "1")
s, _, request := trafficEvidenceServer(t)
pg := s.m.pg
@@ -194,18 +198,22 @@ func (f *fakeWebhook) last(t *testing.T) map[string]any {
return f.body(t, f.count()-1)
}
// markdownText 从钉钉 markdown/actionCard 请求体里取出正文。
// markdownText 从请求体里取出正文,兼容各家的字段名差异:
// 钉钉 markdown 用 `text`、ActionCard 用 `text`、企业微信 markdown 用 `content`。
func markdownText(t *testing.T, body map[string]any) string {
t.Helper()
if md, ok := body["markdown"].(map[string]any); ok {
s, _ := md["text"].(string)
return s
for _, key := range []string{"markdown", "actionCard"} {
section, ok := body[key].(map[string]any)
if !ok {
continue
}
for _, field := range []string{"text", "content"} {
if s, ok := section[field].(string); ok && s != "" {
return s
}
}
}
if card, ok := body["actionCard"].(map[string]any); ok {
s, _ := card["text"].(string)
return s
}
t.Fatalf("请求体里没有 markdown/actionCard 正文: %v", body)
t.Fatalf("请求体里没有可识别的正文: %v", body)
return ""
}
@@ -407,14 +415,14 @@ func TestNotifyDigestBatchesMultipleFindingsIntoOneMessage(t *testing.T) {
ch := f.channel(t, chID)
// 未到期:不发。
f.n.stepDigest(ctx, ch, "")
f.n.stepDigest(ctx, ch, 50, "")
if hook.count() != 0 {
t.Fatal("汇总批次未到期就发了")
}
// 催老批次后:三条合成一条消息。
f.agePendingBatch(t, chID)
f.n.stepDigest(ctx, ch, "")
f.n.stepDigest(ctx, ch, 50, "")
if got := hook.count(); got != 1 {
t.Fatalf("三条应汇总成一条消息,实际发了 %d 条", got)
}
@@ -713,6 +721,112 @@ func TestNotifyNoDeepLinkWithoutBaseURL(t *testing.T) {
}
}
// TestNotifyDigestSegmentsAndDefersRemainder 是「静默丢失」修复的端到端证据。
//
// 汇总消息受渠道长度上限约束(企微 4096 字节),一批装不下时必须**按整条**切分:
// 装进本条的那些标记已送达,其余回到队列等下一条。曾经的实现是把整批标记
// 成功——被截掉的那些既不在消息里、也不在失败列表里,投递历史还显示成功,
// 漏洞就这么没了。
//
// 断言四件事:① 只标记了实际装下的条数 ② 其余仍是待发 ③ 被推迟的条目
// **没有消耗重试次数** ④ 再跑一轮能把剩下的发出去(不会卡死)。
func TestNotifyDigestSegmentsAndDefersRemainder(t *testing.T) {
f := newNotifyFixture(t)
hook := newFakeWebhook(t)
// 用企业微信:markdown 上限 4096 字节,是六个渠道里最紧的。
chID := f.createChannel(t, map[string]any{
"name": "分段汇总",
"kind": notify.KindWeCom,
"mode": db.NotifyModeDigest,
"config": map[string]any{"webhook": hook.URL},
})
const total = 60
// 标题取长一点,保证 60 条远超 4096 字节,必然分段。
longName := strings.Repeat("超长漏洞名称", 6)
for i := 0; i < total; i++ {
f.record(t, longName+strconv.Itoa(i+1), "high")
}
ctx := context.Background()
if _, _, err := f.pg.FanOutPendingEvents(ctx, 500); err != nil {
t.Fatal(err)
}
f.agePendingBatch(t, chID)
ch := f.channel(t, chID)
f.n.stepDigest(ctx, ch, 50, "")
if hook.count() != 1 {
t.Fatalf("应只发出一条消息,得到 %d", hook.count())
}
var sent, pending int
if err := f.pg.QueryRow(`SELECT
count(*) FILTER (WHERE state=$2),
count(*) FILTER (WHERE state=$3)
FROM notification_deliveries WHERE channel_id=$1`, chID, db.NotifyStateSent, db.NotifyStatePending).
Scan(&sent, &pending); err != nil {
t.Fatal(err)
}
if sent == 0 {
t.Fatal("应有条目被标记为已送达")
}
if pending == 0 {
t.Fatalf("一批 %d 条不可能全装进 4096 字节,应有剩余待发;sent=%d", total, sent)
}
if sent+pending != total {
t.Fatalf("条目数对不上:sent=%d pending=%d total=%d(既没送达也没待发=丢失)", sent, pending, total)
}
// 消息正文必须如实告知还有多少条没包含在本条里。
if text := markdownText(t, hook.last(t)); !strings.Contains(text, "其余") {
t.Fatalf("消息应说明还有条目未包含在本条:\n%.400s", text)
}
// 被推迟的条目不得消耗重试预算:领取时 attempts 已乐观 +1,推迟时要减回去。
var maxAttempts int
if err := f.pg.QueryRow(`SELECT COALESCE(max(attempts),0) FROM notification_deliveries
WHERE channel_id=$1 AND state=$2`, chID, db.NotifyStatePending).Scan(&maxAttempts); err != nil {
t.Fatal(err)
}
if maxAttempts > 0 {
t.Fatalf("被推迟的条目不该消耗重试次数(否则几条之后就会被判失败),得到 attempts=%d", maxAttempts)
}
// 反复跑直到收敛。断言的是**最终全部送达**且中途确实分了多轮——
// 这比「第二轮发完」更强:它证明分段不会卡死、也不会把剩余条目丢掉。
rounds := 0
for {
var undelivered int
if err := f.pg.QueryRow(`SELECT count(*) FROM notification_deliveries
WHERE channel_id=$1 AND state <> $2 AND state <> $3`, chID, db.NotifyStateSent, db.NotifyStateFailed).
Scan(&undelivered); err != nil {
t.Fatal(err)
}
if undelivered == 0 {
break
}
rounds++
if rounds > total+5 {
t.Fatalf("分段投递不收敛:跑了 %d 轮仍有 %d 条悬而未决", rounds, undelivered)
}
before := hook.count()
f.n.stepDigest(ctx, ch, 50, "")
if hook.count() == before {
t.Fatalf("第 %d 轮没有任何进展,剩余 %d 条会永久卡住", rounds, undelivered)
}
}
if rounds < 2 {
t.Fatalf("一条 4096 字节的消息装不下 %d 条长标题漏洞,应分多轮发出,实际只用了 %d 轮", total, rounds)
}
// 首轮之后的每一轮都应是**纯续发**,不存在被渠道拒绝的条目。
var failed int
if err := f.pg.QueryRow(`SELECT count(*) FROM notification_deliveries WHERE channel_id=$1 AND state=$2`,
chID, db.NotifyStateFailed).Scan(&failed); err != nil {
t.Fatal(err)
}
if failed != 0 {
t.Fatalf("假接收端始终返回成功,不该有失败条目,得到 %d", failed)
}
}
// TestNotifyBackoffTableMatchesAttemptBudget 是防漂移断言。
//
// 重试预算(db.MaxNotifyAttempts)与退避序列表(notifyBackoff)分居两个包:
@@ -763,3 +877,19 @@ func TestNotifyRateLimitDoesNotConsumeRetryBudget(t *testing.T) {
t.Fatalf("渠道 1 的桶应仍然为空,得到 %d", got)
}
}
// TestNotifyTickBudgetFitsWithinLease 是又一条防漂移断言。
//
// 单渠道每轮的投递条数上限(notifyMaxSendsPerChannelPerTick)是从租约时长倒推的:
// 一轮里串行投递的最坏耗时必须 < 租约,否则后几条还没发完租约就过期,
// 多实例部署时对端会把它们重新领走、重复发送。这三个常量分处不同位置,
// 改任意一个都可能打破关系而不会有任何报错——所以在这里钉死。
func TestNotifyTickBudgetFitsWithinLease(t *testing.T) {
worst := time.Duration(notifyMaxSendsPerChannelPerTick) * notifySendTimeout
if worst >= notifyLease {
t.Fatalf("单渠道一轮的最坏耗时 %v 不应达到或超过租约 %v"+
"(notifyMaxSendsPerChannelPerTick=%d × notifySendTimeout=%v)——"+
"改这三个常量中的任意一个都要同步检查另外两个",
worst, notifyLease, notifyMaxSendsPerChannelPerTick, notifySendTimeout)
}
}