fix(notify): 安全审计修复 —— 掩码绕过、SSRF、静默丢失等 12 项
对推送功能做了一轮安全审计,每条都先复现、再修、再补回归测试。 以下按严重程度排列。 【严重】掩码可被「改目标地址、保留凭据」绕过 目标地址与凭据是两套独立字段,而配置合并对「未提及的键」保留原值。于是 只改地址、对凭据避而不谈,服务端就会把库里的真凭据发到任意地址。已实测 复现四个渠道:通用 Webhook 的 Authorization 头、Telegram 的 Bot Token (进请求路径)、邮件的 SMTP 密码(STARTTLS 后交给对端)、钉钉/飞书的加签 密钥(该项不可利用,一并列出仅为说明面)。完全静默、不依赖重定向,所以 上一轮加的「拒绝跨主机跳转」挡不住它。 修法:新增 Channel.DestinationKeys() 声明「消息发往哪」,PrepareConfigUpdate 在地址变化时要求对**每一个**凭据字段显式表态(给新值,或显式留空表示不再 需要)——原样回传掩码值等于「沿用旧凭据」,正是攻击形状,一并拒绝。 刻意不做「自动丢弃凭据」:对可选字段(如 headers)那会变成「鉴权静默没了 但接口返回成功」,比报错更难排查。 【中】markdown 系三渠道对不可信内容零转义 钉钉/企微/飞书此前完全不转义(Telegram 与邮件都做了),而标题与摘要来自 模型输出、资产 url 来自被测目标。一条标题里的 `[文字](http://evil)` 会在 工程师的钉钉里变成可点击外链;`` 会被客户端拉取, 泄露阅读者 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:
1 parent
f3fb37f70d
commit
a049f09b97
26 files changed
+1586
-229
No files matched your search
+141
-11
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user