Files
ARTEX/notify/webhook_template_test.go
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

107 lines
4.5 KiB
Go

package notify
import (
"context"
"reflect"
"sort"
"strings"
"testing"
)
// 本文件锁住通用 Webhook 模板的**能力边界**。
//
// 这是全包唯一「用户提供的字符串会被当代码求值」的地方,所以要明确它能做什么、
// 不能做什么,并用测试把这些性质固定下来——否则将来有人顺手给模板上下文加个
// 方法、或在 FuncMap 里加个 readFile,能力面就静默扩大了,而 diff 看起来
// 只是一个无害的小函数。
// TestTemplateContextHasNoMethods 是最重要的一条。
//
// text/template 会调用导出方法({{.Foo}} 既能取字段也能调方法)。所以模板上下文
// 只要能接触到**任何**带导出方法的类型,就等于把那些方法暴露给了模板作者。
// 本功能的上下文刻意全是纯数据(只有导出字段、零方法)。
//
// 若这条失败:说明有人给 webhookTemplateData / webhookItem 加了方法。
// 在决定放行之前,先想清楚那个方法能不能被模板用来读取不希望暴露的东西。
func TestTemplateContextHasNoMethods(t *testing.T) {
for _, v := range []any{webhookTemplateData{}, webhookItem{}} {
typ := reflect.TypeOf(v)
if n := typ.NumMethod(); n != 0 {
var names []string
for i := 0; i < n; i++ {
names = append(names, typ.Method(i).Name)
}
t.Fatalf("%s 暴露了 %d 个方法(%s):text/template 可以调用它们,"+
"等于把这些方法的能力开放给了模板作者", typ.Name(), n, strings.Join(names, ", "))
}
}
}
// TestTemplateFuncsAreMinimal 锁住暴露给模板的函数集合。
//
// FuncMap 里每多一个函数就多一项能力。当前只有 json / jsons,作用是把值序列化
// 成 JSON 片段——不能读文件、不能发请求、不能执行命令。
func TestTemplateFuncsAreMinimal(t *testing.T) {
var got []string
for name := range webhookTemplateFuncs {
got = append(got, name)
}
sort.Strings(got)
want := []string{"json", "jsons"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("模板函数集合变了:得到 %v,期望 %v。新增函数前请确认它不会扩大能力面"+
"(不能读写文件、不能发起网络请求、不能执行命令)", got, want)
}
}
// TestTemplateCannotReachUnknownData 覆盖模板里的越界访问:
// 访问不存在的东西必须失败,而不是回显点什么;且失败信息不得带出内部数据。
func TestTemplateCannotReachUnknownData(t *testing.T) {
_, err := renderWebhookBody(`{"x": {{.Environment}}, "y": {{.Env}}}`, singleMsg())
if err == nil {
t.Fatal("访问不存在的字段应报错")
}
// 错误里不能出现模板上下文里的真实内容(漏洞标题/摘要)。
for _, leak := range []string{"SQL注入", "参数 id"} {
if strings.Contains(err.Error(), leak) {
t.Errorf("模板错误泄露了消息内容 %q: %v", leak, err)
}
}
}
// TestTemplateRenderFailsPermanently 模板写错属于配置错误,重试不会自愈。
// 若被判成可重试,一条坏模板会让每次投递都白跑三轮退避。
func TestTemplateRenderFailsPermanently(t *testing.T) {
cfg := map[string]any{
"url": "https://example.com/hook",
"body_template": `{{.Items.`,
}
if err := (webhookChannel{}).Validate(cfg); err == nil {
t.Fatal("模板语法错误应在保存时就被拦下")
}
// 即便绕过校验直接投递,也必须判永久失败而不是反复重试。
_, err := (webhookChannel{}).Send(context.Background(), cfg, singleMsg())
if err == nil || !IsPermanent(err) {
t.Fatalf("坏模板应判永久失败,得到 %v", err)
}
}
// TestTemplateCanOnlyProduceJSON 覆盖「模板渲染结果必须是合法 JSON」这条约束。
// 它顺带挡住了「用模板生成纯文本去触发别的协议」这类用法。
func TestTemplateCanOnlyProduceJSON(t *testing.T) {
// 合法模板能过。
ok := map[string]any{"url": "https://example.com/hook", "body_template": `{"t":{{json .Title}}}`}
if err := (webhookChannel{}).Validate(ok); err != nil {
t.Fatalf("合法模板应通过校验: %v", err)
}
// 渲染出非 JSON 时必须拒绝(而不是原样发出去)。
bad := map[string]any{"url": "http://127.0.0.1:1/hook", "body_template": `not json {{.Count}}`}
_, err := (webhookChannel{}).Send(context.Background(), bad, singleMsg())
if err == nil || !IsPermanent(err) {
t.Fatalf("渲染出非 JSON 应判永久失败,得到 %v", err)
}
if !strings.Contains(err.Error(), "合法 JSON") {
t.Errorf("错误信息应说明是 JSON 问题,得到 %v", err)
}
}