mirror of
https://github.com/RuoJi6/ARTEX.git
synced 2026-10-28 01:03:50 +08:00
对推送功能做了一轮安全审计,每条都先复现、再修、再补回归测试。 以下按严重程度排列。 【严重】掩码可被「改目标地址、保留凭据」绕过 目标地址与凭据是两套独立字段,而配置合并对「未提及的键」保留原值。于是 只改地址、对凭据避而不谈,服务端就会把库里的真凭据发到任意地址。已实测 复现四个渠道:通用 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]>
337 lines
11 KiB
Go
337 lines
11 KiB
Go
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"})
|
||
}
|