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

332 lines
13 KiB
Go
Raw 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 (
"errors"
"strings"
"testing"
)
func TestMaskedValueHidesBodyButKeepsTailHint(t *testing.T) {
const secret = "https://oapi.dingtalk.com/robot/send?access_token=abcdef123456"
got := MaskedValue(secret)
if strings.Contains(got, "abcdef123456") {
t.Fatalf("掩码值泄露了完整凭据: %q", got)
}
if strings.Contains(got, "oapi.dingtalk.com") {
t.Fatalf("掩码值不应暴露地址主体: %q", got)
}
// 末 6 位要保留,用户才能认出是哪个机器人。
if !strings.HasSuffix(got, "123456") {
t.Fatalf("应保留末 6 位作为辨识提示: %q", got)
}
if !IsMasked(got) {
t.Fatalf("掩码值必须能被 IsMasked 识别: %q", got)
}
}
func TestMaskedValueShortSecretGivesNoHint(t *testing.T) {
// 短凭据如果也暴露末 6 位,等于把整个凭据暴露出去。
for _, s := range []string{"abc", "abcdef", ""} {
got := MaskedValue(s)
if got != MaskedPrefix {
t.Fatalf("长度 %d 的凭据不应给出尾部提示,得到 %q", len(s), got)
}
if s != "" && strings.Contains(got, s) {
t.Fatalf("掩码值包含了原值: %q", got)
}
}
}
func TestMaskConfigMasksOnlySecrets(t *testing.T) {
cfg := map[string]any{
"webhook": "https://example.com/hook?token=SECRETVALUE",
"secret": "SECtest123456",
"port": float64(587),
"host": "smtp.example.com",
}
masked := MaskConfig(KindDingTalk, cfg)
for _, k := range []string{"webhook", "secret"} {
s, _ := masked[k].(string)
if !IsMasked(s) {
t.Errorf("%s 应被掩码,得到 %q", k, s)
}
}
// 非凭据字段必须原样保留,否则 UI 无法展示。
if masked["port"] != float64(587) {
t.Errorf("非凭据字段 port 不应改动: %v", masked["port"])
}
}
func TestMaskConfigUnknownKindReturnsEmpty(t *testing.T) {
// 渠道类型无法识别时,宁可让 UI 显示空配置,也不要把可能含凭据的原始内容吐回去。
got := MaskConfig("nope", map[string]any{"webhook": "https://x/y?token=LEAK"})
if len(got) != 0 {
t.Fatalf("未知渠道类型应返回空配置,得到 %v", got)
}
}
func TestMaskConfigDoesNotMutateInput(t *testing.T) {
// 掩码是展示层行为,不能反过来把库里的真值改掉。
cfg := map[string]any{"webhook": "https://example.com/hook", "secret": "SECtest123456"}
_ = MaskConfig(KindDingTalk, cfg)
if IsMasked(cfg["secret"].(string)) {
t.Fatal("MaskConfig 修改了入参,会导致真实凭据被掩码值覆盖")
}
}
func TestMergeConfigKeepsStoredOnMaskedIncoming(t *testing.T) {
stored := map[string]any{"webhook": "https://real/hook", "secret": "REALSECRET", "method": "POST"}
// 用户只改了 method,浏览器提交的是掩码值+新 method。
incoming := map[string]any{
"webhook": MaskedValue("https://real/hook"),
"secret": MaskedValue("REALSECRET"),
"method": "PUT",
}
got := MergeConfig(stored, incoming)
if got["webhook"] != "https://real/hook" || got["secret"] != "REALSECRET" {
t.Fatalf("掩码字段应保留库中原值,得到 %v", got)
}
if got["method"] != "PUT" {
t.Fatalf("被修改的字段应生效,得到 %v", got["method"])
}
}
func TestMergeConfigEmptyStringClears(t *testing.T) {
stored := map[string]any{"webhook": "https://real/hook", "secret": "REALSECRET"}
got := MergeConfig(stored, map[string]any{"secret": ""})
if _, ok := got["secret"]; ok {
t.Fatalf("空串应清空该字段,得到 %v", got)
}
// 没提到的字段保留(局部更新语义)。
if got["webhook"] != "https://real/hook" {
t.Fatalf("未提及的字段应保留,得到 %v", got)
}
}
func TestMergeConfigKeepsUnmentionedStoredKeys(t *testing.T) {
stored := map[string]any{"host": "smtp.example.com", "port": float64(587), "password": "pw"}
got := MergeConfig(stored, map[string]any{"port": float64(465)})
if got["host"] != "smtp.example.com" || got["password"] != "pw" {
t.Fatalf("未提及的字段应保留,得到 %v", got)
}
if got["port"] != float64(465) {
t.Fatalf("已提及的字段应更新,得到 %v", got["port"])
}
}
// TestPrepareConfigUpdateBlocksDestinationSwap 是本包最重要的一条安全不变量:
// **改目标地址不能把旧凭据带过去**。
//
// 这些用例用的正是攻击形状的输入(只改地址、对凭据避而不谈),
// 而不是「防御逻辑的正确输入」——只测后者的话,防御没生效也照样全绿。
func TestPrepareConfigUpdateBlocksDestinationSwap(t *testing.T) {
cases := []struct {
name string
kind string
stored map[string]any
incoming map[string]any
// wantMissing 是预期被点名的凭据键。
wantMissing string
}{
{
name: "通用 Webhook 改地址想沿用 Authorization 头",
kind: KindWebhook,
stored: map[string]any{
"url": "https://legit.example.com/hook",
"headers": map[string]any{"Authorization": "Bearer REAL-TOKEN"},
},
incoming: map[string]any{"url": "https://attacker.tld/c"},
wantMissing: "headers",
},
{
name: "Telegram 改 base_url 想把 Bot Token 发到自己的端点",
kind: KindTelegram,
stored: map[string]any{"bot_token": "123456:REAL", "chat_id": "1", "base_url": "https://api.telegram.org"},
incoming: map[string]any{"base_url": "https://attacker.tld"},
wantMissing: "bot_token",
},
{
name: "邮件改 SMTP 主机想交出密码",
kind: KindEmail,
stored: map[string]any{"host": "smtp.corp.com", "port": 587, "password": "REALPW", "from": "[email protected]", "to": []any{"[email protected]"}},
incoming: map[string]any{"host": "smtp.attacker.tld"},
wantMissing: "password",
},
{
name: "邮件关掉 TLS 也必须重新表态密码",
kind: KindEmail,
stored: map[string]any{"host": "smtp.corp.com", "port": 587, "tls": false, "password": "REALPW", "from": "[email protected]", "to": []any{"[email protected]"}},
incoming: map[string]any{"tls": true},
wantMissing: "password",
},
{
// 掩码值 = 「沿用旧凭据」,在地址变更的语境下同样必须拒绝。
name: "回传掩码凭据 + 新地址",
kind: KindTelegram,
stored: map[string]any{"bot_token": "123456:REAL", "chat_id": "1", "base_url": "https://api.telegram.org"},
incoming: map[string]any{"base_url": "https://attacker.tld", "bot_token": MaskedValue("123456:REAL")},
wantMissing: "bot_token",
},
{
name: "钉钉改 Webhook 想沿用加签密钥",
kind: KindDingTalk,
stored: map[string]any{"webhook": "https://oapi.dingtalk.com/robot/send?access_token=OLD", "secret": "REALSEC"},
incoming: map[string]any{"webhook": "https://attacker.tld/hook"},
wantMissing: "secret",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
merged, err := PrepareConfigUpdate(tc.kind, tc.stored, tc.incoming)
if err == nil {
t.Fatalf("改地址却未重新表态凭据,应当被拒绝;得到配置 %v", merged)
}
var target *ErrDestinationChangedWithoutCredentials
if !errors.As(err, &target) {
t.Fatalf("应返回专门的错误类型以便接口给出可操作提示,得到 %T: %v", err, err)
}
found := false
for _, m := range target.Missing {
if m == tc.wantMissing {
found = true
}
}
if !found {
t.Fatalf("应点名缺失的凭据键 %q,得到 %v", tc.wantMissing, target.Missing)
}
// 错误信息要能指导操作者怎么修。
if !strings.Contains(err.Error(), tc.wantMissing) {
t.Errorf("错误信息应提到 %q: %v", tc.wantMissing, err)
}
})
}
}
// TestPrepareConfigUpdateAllowsLegitimateEdits 反向用例:正常的编辑不能被误拦,
// 否则这个防护会因为「太烦」而被绕过或删掉。
func TestPrepareConfigUpdateAllowsLegitimateEdits(t *testing.T) {
cases := []struct {
name string
kind string
stored map[string]any
incoming map[string]any
}{
{
name: "只改名字(配置原样回传)",
kind: KindWebhook,
stored: map[string]any{"url": "https://legit.example.com/hook", "headers": map[string]any{"Authorization": "Bearer REAL"}},
incoming: map[string]any{"url": MaskedValue("https://legit.example.com/hook")},
},
{
name: "只改请求方法,地址与凭据都不动",
kind: KindWebhook,
stored: map[string]any{"url": "https://legit.example.com/hook", "method": "POST"},
incoming: map[string]any{"method": "PUT"},
},
{
name: "换地址并**同时**给新凭据",
kind: KindWebhook,
stored: map[string]any{"url": "https://old.example.com/hook", "headers": map[string]any{"Authorization": "Bearer OLD"}},
incoming: map[string]any{"url": "https://new.example.com/hook", "headers": map[string]any{"Authorization": "Bearer NEW"}},
},
{
name: "换地址并显式声明不再需要凭据",
kind: KindWebhook,
stored: map[string]any{"url": "https://old.example.com/hook", "headers": map[string]any{"Authorization": "Bearer OLD"}},
incoming: map[string]any{"url": "https://new.example.com/hook", "headers": ""},
},
{
name: "Telegram 改 chat_id(不是目的地)",
kind: KindTelegram,
stored: map[string]any{"bot_token": "t", "chat_id": "1", "base_url": "https://api.telegram.org"},
incoming: map[string]any{"chat_id": "-100200"},
},
{
name: "邮件改收件人(不是目的地)",
kind: KindEmail,
stored: map[string]any{"host": "smtp.corp.com", "port": 587, "password": "PW", "from": "[email protected]", "to": []any{"[email protected]"}},
incoming: map[string]any{"to": []any{"[email protected]"}},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
merged, err := PrepareConfigUpdate(tc.kind, tc.stored, tc.incoming)
if err != nil {
t.Fatalf("合法编辑被误拦: %v", err)
}
if merged == nil {
t.Fatal("应返回合并结果")
}
})
}
}
// TestPrepareConfigUpdatePortTypeTolerance 覆盖一个容易误判的细节:
// 前端提交的端口是 JSON number(float64),库里读回来也是 float64,
// 但两个值的类型可能不同(如 int vs float64)。用 == 比较会把「没改」判成「改了」,
// 从而对只改了名字的用户弹出「请重新填写密码」——假警报会让人不再信任这个防护。
func TestPrepareConfigUpdatePortTypeTolerance(t *testing.T) {
stored := map[string]any{"host": "smtp.corp.com", "port": float64(587), "password": "PW"}
// 同一个端口,以 int 形式提交。
if _, err := PrepareConfigUpdate(KindEmail, stored, map[string]any{"port": 587}); err != nil {
t.Fatalf("端口值相同(仅类型不同)不应被判为地址变更: %v", err)
}
// 真的换了端口则必须拦。
if _, err := PrepareConfigUpdate(KindEmail, stored, map[string]any{"port": 25}); err == nil {
t.Fatal("端口变更应被拦下")
}
}
func TestDestinationKeysDeclaredForEveryKind(t *testing.T) {
// 与 SecretKeys 同理:渠道若忘记声明目的地键,PrepareConfigUpdate 就保护不到它。
for kind, ch := range registry {
if len(ch.DestinationKeys()) == 0 {
t.Errorf("渠道 %s 未声明目的地键,改地址带出凭据的防护对它无效", kind)
}
if len(ch.SecretKeys()) == 0 {
t.Errorf("渠道 %s 未声明凭据键", kind)
}
}
}
func TestSecretKeysDeclaredForEveryKind(t *testing.T) {
// 编译器已经强制每个渠道实现 SecretKeys,这里再确认一遍「没有渠道在掩码上
// 交白卷」——返回空切片的渠道意味着它的凭据会明文回显到浏览器。
expect := map[string]bool{
KindDingTalk: true, KindFeishu: true, KindWeCom: true,
KindWebhook: true, KindTelegram: true, KindEmail: true,
}
for kind, ch := range registry {
if !expect[kind] {
t.Errorf("渠道 %s 未在测试中登记掩码预期", kind)
continue
}
if len(ch.SecretKeys()) == 0 {
t.Errorf("渠道 %s 未声明任何凭据字段,其配置会明文回显", kind)
}
}
}
// TestPrepareConfigUpdateRejectsMaskedInContainer 覆盖审计指出的一处口子:
// 把掩码哨兵塞进**非字符串**结构(如 webhook.headers 是个对象)时,
// MergeConfig 只认「字符串且带前缀」为掩码,于是字面量 "__masked__" 会被当成
// 真实头值存进库——后续鉴权静默失效,且没有任何报错。
func TestPrepareConfigUpdateRejectsMaskedInContainer(t *testing.T) {
stored := map[string]any{
"url": "https://legit.example.com/hook",
"headers": map[string]any{"Authorization": "Bearer REAL"},
}
// 对象内部夹带掩码哨兵。
incoming := map[string]any{
"headers": map[string]any{"Authorization": MaskedPrefix},
}
if _, err := PrepareConfigUpdate(KindWebhook, stored, incoming); err == nil {
t.Fatal("结构体内部夹带掩码哨兵应被拒绝(否则会把字面量存进库)")
}
// 整体提交对象(真实新值)照常接受。
ok := map[string]any{"headers": map[string]any{"Authorization": "Bearer NEW"}}
if _, err := PrepareConfigUpdate(KindWebhook, stored, ok); err != nil {
t.Fatalf("正常提交新请求头不应被拦: %v", err)
}
}