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