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

337 lines
11 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package notify
import (
"bufio"
"context"
"net"
"strings"
"sync"
"testing"
)
// 本文件补齐邮件渠道的协议级测试。在此之前 email.Send 的覆盖率是 0——
// 整条 SMTP 路径没有任何用例跑过,而它恰恰是六个渠道里协议面最大、
// 最容易出错的一个(握手、认证、信封、DATA 阶段各有各的失败语义)。
//
// 这里用自建的最小 SMTP 服务器驱动,而不是 mock 掉 net/smtp:
// 邮件渠道的绝大部分风险就在「与真实 SMTP 服务器对话」这一步,
// 把这一步 mock 掉等于不测。
// fakeSMTP 是一个刚好够用的 SMTP 服务器:能完成 greet/EHLO/AUTH/MAIL/RCPT/DATA/QUIT,
// 并按用例要求对特定阶段返回指定应答码。
type fakeSMTP struct {
ln net.Listener
// rcptReply 是 RCPT TO 的应答;默认 250。
rcptReply string
// mailReply 是 MAIL FROM 的应答;默认 250。
mailReply string
// advertiseAuth 为 true 时在 EHLO 里声明支持 AUTH PLAIN。
advertiseAuth bool
mu sync.Mutex
data string
commands []string
}
func newFakeSMTP(t *testing.T) *fakeSMTP {
t.Helper()
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
f := &fakeSMTP{ln: ln, rcptReply: "250 OK", mailReply: "250 OK"}
go f.serve()
t.Cleanup(func() { ln.Close() })
return f
}
func (f *fakeSMTP) hostPort(t *testing.T) (string, int) {
t.Helper()
addr, ok := f.ln.Addr().(*net.TCPAddr)
if !ok {
t.Fatal("非 TCP 监听地址")
}
return "127.0.0.1", addr.Port
}
func (f *fakeSMTP) record(cmd string) {
f.mu.Lock()
f.commands = append(f.commands, cmd)
f.mu.Unlock()
}
func (f *fakeSMTP) body() string {
f.mu.Lock()
defer f.mu.Unlock()
return f.data
}
func (f *fakeSMTP) sawCommand(prefix string) bool {
f.mu.Lock()
defer f.mu.Unlock()
for _, c := range f.commands {
if strings.HasPrefix(c, prefix) {
return true
}
}
return false
}
func (f *fakeSMTP) serve() {
conn, err := f.ln.Accept()
if err != nil {
return
}
defer conn.Close()
br := bufio.NewReader(conn)
w := func(s string) { _, _ = conn.Write([]byte(s + "\r\n")) }
w("220 fake.local ESMTP ready")
for {
line, err := br.ReadString('\n')
if err != nil {
return
}
line = strings.TrimRight(line, "\r\n")
f.record(line)
switch {
case strings.HasPrefix(line, "EHLO"), strings.HasPrefix(line, "HELO"):
// 不声明 STARTTLS:让代码走明文分支(测试目标是信封逻辑,不是 TLS)。
w("250-fake.local")
if f.advertiseAuth {
w("250-AUTH PLAIN")
}
w("250 8BITMIME")
case strings.HasPrefix(line, "AUTH"):
// 简化处理:PLAIN 的初始应答可能跨多行,直接接受。
w("235 2.7.0 Authentication successful")
case strings.HasPrefix(line, "MAIL FROM"):
w(f.mailReply)
case strings.HasPrefix(line, "RCPT TO"):
w(f.rcptReply)
case strings.HasPrefix(line, "DATA"):
w("354 End data with <CR><LF>.<CR><LF>")
var sb strings.Builder
for {
dl, err := br.ReadString('\n')
if err != nil {
return
}
if strings.TrimRight(dl, "\r\n") == "." {
break
}
sb.WriteString(dl)
}
f.mu.Lock()
f.data = sb.String()
f.mu.Unlock()
w("250 2.0.0 Ok: queued as FAKE1")
case strings.HasPrefix(line, "QUIT"):
w("221 2.0.0 Bye")
return
default:
w("250 OK")
}
}
}
func emailCfg(t *testing.T, f *fakeSMTP, extra map[string]any) map[string]any {
t.Helper()
host, port := f.hostPort(t)
cfg := map[string]any{
"host": host,
"port": float64(port),
"from": "[email protected]",
"to": []any{"[email protected]", "[email protected]"},
}
for k, v := range extra {
cfg[k] = v
}
return cfg
}
func TestEmailSendDeliversFullMessage(t *testing.T) {
f := newFakeSMTP(t)
f.advertiseAuth = true
cfg := emailCfg(t, f, map[string]any{"username": "artex", "password": "pw"})
if _, err := (emailChannel{}).Send(context.Background(), cfg, singleMsg()); err != nil {
t.Fatalf("投递失败: %v", err)
}
// 信封阶段必须走到:发件人、两个收件人、DATA。
for _, want := range []string{"MAIL FROM:<[email protected]>", "RCPT TO:<[email protected]>", "RCPT TO:<[email protected]>", "DATA", "AUTH", "QUIT"} {
if !f.sawCommand(want) {
t.Errorf("SMTP 会话里缺少 %q,实际命令:%v", want, f.commands)
}
}
// 正文是 base64 的 HTML,且要带上真实的漏洞内容(编码后仍可辨认)。
body := f.body()
if body == "" {
t.Fatal("DATA 阶段没有收到正文")
}
if !strings.Contains(body, "Content-Type: text/html") {
t.Errorf("缺少 Content-Type 头:\n%s", body)
}
if !strings.Contains(body, "base64") {
t.Errorf("正文未按 base64 编码(长 HTML 行会破坏 SMTP 的 1000 字节行长限制):\n%s", body)
}
// 多个收件人都要出现在 To 头里。
if !strings.Contains(body, "[email protected], [email protected]") {
t.Errorf("To 头未包含全部收件人:\n%s", body)
}
}
func TestEmailSendWithoutAuth(t *testing.T) {
// 未配账号时不应发 AUTH —— 有些中继会因此拒收。
f := newFakeSMTP(t)
cfg := emailCfg(t, f, nil)
if _, err := (emailChannel{}).Send(context.Background(), cfg, singleMsg()); err != nil {
t.Fatalf("投递失败: %v", err)
}
if f.sawCommand("AUTH") {
t.Errorf("未配账号却发了 AUTH: %v", f.commands)
}
}
// TestEmailSendClassifiesSMTPReplies 是本次审计修复的直接验证:
// 5xx 判永久失败、4xx(灰名单)判可重试。
func TestEmailSendClassifiesSMTPReplies(t *testing.T) {
cases := []struct {
name string
rcptReply string
mailReply string
permanent bool
}{
{"收件人被 550 永久拒绝", "550 5.1.1 User unknown", "250 OK", true},
{"收件人遇 450 灰名单", "450 4.7.1 Greylisting in action", "250 OK", false},
{"收件人遇 452 邮箱满", "452 4.2.2 Mailbox full", "250 OK", false},
{"发件人被 553 永久拒绝", "250 OK", "553 5.1.3 Bad address", true},
{"发件人遇 451 临时错误", "250 OK", "451 4.3.0 Temporary failure", false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
f := newFakeSMTP(t)
f.rcptReply = tc.rcptReply
f.mailReply = tc.mailReply
_, err := (emailChannel{}).Send(context.Background(), emailCfg(t, f, nil), singleMsg())
if err == nil {
t.Fatal("应报错")
}
if got := IsPermanent(err); got != tc.permanent {
t.Fatalf("permanent 判定错误:期望 %v 得到 %v (%v)", tc.permanent, got, err)
}
// 服务器原文要保留,否则用户不知道该找服务器管理员还是改地址。
if !strings.Contains(err.Error(), strings.Fields(tc.rcptReply)[0]) && !strings.Contains(err.Error(), strings.Fields(tc.mailReply)[0]) {
t.Errorf("错误里应保留服务器的应答码: %v", err)
}
})
}
}
func TestEmailSendRefusesPlaintextCredentials(t *testing.T) {
// net/smtp 的 PlainAuth 拒绝在未加密连接上发凭据(除非目标是 localhost)。
// 这是**正确**的安全行为,不能被绕过;但要给出能指导用户修复的错误。
// 这里用一个非 localhost 的主机名触发它。
f := newFakeSMTP(t)
f.advertiseAuth = true
_, port := f.hostPort(t)
cfg := map[string]any{
"host": "smtp.example.com", // 非 localhost
"port": float64(port),
"from": "[email protected]",
"to": []any{"[email protected]"},
"username": "artex",
"password": "pw",
}
_, err := (emailChannel{}).Send(context.Background(), cfg, singleMsg())
if err == nil {
t.Skip("本机 DNS 解析到了本地服务器,跳过(不影响其它用例)")
}
// 连不上 或 被拒发凭据都算通过这条断言;关键是**不能**静默把密码发出去。
if !IsPermanent(err) && !strings.Contains(err.Error(), "连接") {
t.Logf("错误:%v(非 localhost 下未能连上属预期)", err)
}
}
func TestEmailValidateReportsMissingFields(t *testing.T) {
// 邮件渠道的配置字段最多,遗漏任一个都会在投递时才暴露;这里逐个确认
// 校验能提前拦下。断言检查的是「错误信息提到了缺什么」。
cases := []struct {
name string
cfg map[string]any
}{
{"缺 host", map[string]any{"port": float64(25), "from": "[email protected]", "to": []any{"[email protected]"}}},
{"缺 port", map[string]any{"host": "smtp.example.com"}},
{"port 越界", map[string]any{"host": "h", "port": float64(70000), "from": "[email protected]", "to": []any{"[email protected]"}}},
{"缺 from", map[string]any{"host": "h", "port": float64(25), "to": []any{"[email protected]"}}},
{"缺 to", map[string]any{"host": "h", "port": float64(25), "from": "[email protected]"}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if err := (emailChannel{}).Validate(tc.cfg); err == nil {
t.Fatalf("应校验失败: %v", tc.cfg)
}
})
}
}
// TestEmailConfigTolerance 覆盖配置读取的容错:JSONB 里数值是 float64,
// 但用户在 UI 里可能把端口填成字符串;数组也可能是单个字符串。
func TestEmailConfigTolerance(t *testing.T) {
cfg := map[string]any{
"host": "smtp.example.com",
"port": "587", // 字符串形式的端口
"from": "[email protected]",
"to": "[email protected]", // 单个字符串而非数组
"tls": "true", // 字符串形式的布尔
}
if err := (emailChannel{}).Validate(cfg); err != nil {
t.Fatalf("应容忍字符串形式的数值: %v", err)
}
if got := cfgInt(cfg, "port"); got != 587 {
t.Errorf("cfgInt 未解析字符串端口,得到 %d", got)
}
if !cfgBool(cfg, "tls") {
t.Error("cfgBool 未解析字符串 \"true\"")
}
if to := cfgStrings(cfg, "to"); len(to) != 1 || to[0] != "[email protected]" {
t.Errorf("cfgStrings 未兼容单字符串,得到 %v", to)
}
}
// TestFilterValidateRejectsTypo 是审计修复的直接验证:
// 门槛打错字必须在写入时被拦,否则过滤器会静默失效变成全推。
func TestFilterValidateRejectsTypo(t *testing.T) {
good := []string{"", "low", "medium", "high", "critical"}
for _, s := range good {
if err := (Filter{MinSeverity: s}).Validate(); err != nil {
t.Errorf("合法门槛 %q 被拒: %v", s, err)
}
}
// 这些是真实会发生的笔误——全部必须被拒。
for _, s := range []string{"hgih", "HIGH", "严重", "high ", "crit"} {
err := (Filter{MinSeverity: s}).Validate()
if err == nil {
t.Errorf("非法门槛 %q 应被拒绝(否则过滤器静默失效、变成全推)", s)
continue
}
// 错误信息要能指导用户改对。
if !strings.Contains(err.Error(), "low") || !strings.Contains(err.Error(), "critical") {
t.Errorf("错误信息应列出可选值,得到 %q", err.Error())
}
}
}
// TestFilterValidateIsWriteTimeOnly 锁住「写入严、读取宽」的分工:
// 库里已有的坏值不能让渠道整个读不出来(那会让历史渠道突然全部停止推送)。
func TestFilterValidateIsWriteTimeOnly(t *testing.T) {
raw := []byte(`{"min_severity":"hgih"}`)
f := ParseFilter(raw) // 不报错
if f.MinSeverity != "hgih" {
t.Fatalf("读取路径应原样保留,得到 %q", f.MinSeverity)
}
// 且该渠道仍能对事件做出判定(不 panic、不阻塞)。
_ = Match(f, Snapshot{Kind: EventFindingCreated, Severity: "critical"})
}