Files
ARTEX/notify/webhook_template_test.go
T
luckoneandClaude Code f3fb37f70d feat(notify): 漏洞发现支持推送到钉钉/飞书/企业微信等六种渠道
新增「系统 → 通知推送」页,把扫描发现的漏洞推送到钉钉、飞书、企业微信、
通用 Webhook、Telegram、邮件。同一类型可配任意多个机器人实例,每个实例独立
设启停、限流与过滤规则(最低级别 / 任务资产范围 / 漏洞类型关键词 / 是否接收
处置状态变更)。推送时机分实时与汇总两种模式,「高危实时、其余汇总」建两个
渠道即可表达,策略不写死在代码里。带投递历史与失败重发。

三个关键设计:

1. 写漏洞的事务只做一次盲 INSERT。notification_events 由 RecordFindingTx
   在同一事务内写入,保证「漏洞落库」与「推送任务存在」原子一致;这条 INSERT
   刻意不读渠道表、不跑用户的过滤规则,否则一条配错的过滤条件就能污染甚至
   中止事务,让高危漏洞存不进库。为把失败隔离在这一条语句上(PostgreSQL 中
   事务内任一语句报错会让整个事务作废、连 COMMIT 都失败),它被 SAVEPOINT
   包住,失败时只记日志。

2. 投递用租约领取而非长事务。FOR UPDATE SKIP LOCKED 领取后把行置为 sending、
   把 next_attempt_at 推到未来作为租约,提交后再做网络投递,因此投递期间不
   持有数据库锁;崩溃留下的 sending 行会被下一轮重新领取,自愈且不会无限重试。

3. 限流不消耗重试预算。先按渠道令牌桶算出本轮还能发几条,再按这个数量领取;
   顺序反过来会让被限流挡下的投递白计一次尝试,三次预算被等待耗光后落入失败。

审计并修复的四个问题:

- 推送凭据经错误信息外泄:http.Client.Do 返回的 *url.Error 会把完整 URL 打进
  错误文本,而钉钉 access_token、企业微信 key、飞书 hook id、Telegram
  /bot<token>/ 都就在 URL 里。该串流入 last_error(明文落库)、投递历史接口
  (绕过渠道掩码)、服务端日志与前端错误提示。现统一脱敏为 scheme://host
  + 底层原因,并拒绝跨主机重定向。
- 最低级别门槛打错字会让过滤器静默失效:未知级别序数为 0,判定退化成
  rank >= 0 恒真,用户以为限制「仅高危」实际全推。现于写入路径校验取值。
- 汇总批次无上限:一次领取把全部待发行读进内存、消息被渠道长度上限截掉大半
  且静默丢失。现单批上限 500 条,超出部分留库成为下一批。
- 邮件渠道把 SMTP 4xx(灰名单等临时失败)判成永久失败,导致这类最该重试的
  场景从不重试。现按应答码首位区分 4xx/5xx。

测试:notify 包 82% 覆盖(含用自建最小 SMTP 服务器驱动的完整邮件协议会话、
两种签名算法对 OpenSSL 基准值的比对、UTF-8 边界截断、以及模板能力边界的
反射断言);db 与 server 侧共 29 个用例跑真实 PostgreSQL,其中端到端用例
真发 HTTP 到假接收端并断言消息内容与投递状态。

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)
}
}