mirror of
https://github.com/RuoJi6/ARTEX.git
synced 2026-10-27 17:03:49 +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]>
237 lines
9.1 KiB
Go
237 lines
9.1 KiB
Go
package notify
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"fmt"
|
||
"io"
|
||
"net/http"
|
||
"net/http/httptest"
|
||
"net/url"
|
||
"strings"
|
||
"testing"
|
||
)
|
||
|
||
// 本文件是不变量测试:**任何**从渠道实现里冒出来的错误文本都不得携带凭据。
|
||
//
|
||
// 为什么单独拉一个文件:最初的渠道用例只覆盖了成功路径与平台业务错误,
|
||
// 完全没看传输层失败。而恰恰是传输层错误(连接被拒/DNS 失败/超时)最危险——
|
||
// http.Client.Do 返回的 *url.Error 会把**完整 URL** 打进错误文本,而本功能
|
||
// 这几家的凭据就在 URL 里。凭据顺着这条串流到了四个出口:
|
||
//
|
||
// notification_deliveries.last_error → 明文落库
|
||
// GET /api/notify/deliveries 响应 → 绕过渠道配置的掩码,回显给浏览器
|
||
// 服务端日志 → 常被外发留存
|
||
// 测试发送接口的 502 响应 → 直接弹在前端
|
||
//
|
||
// 所以这里不只测一个函数,而是逐个渠道真发一次必然失败的请求,断言错误文本
|
||
// 里找不到那个凭据。
|
||
|
||
// credentialCases 覆盖所有「凭据在 URL 里」的渠道形态:
|
||
// 钉钉/企微在 query,飞书在路径末段,Telegram 在路径中段。
|
||
var credentialCases = []struct {
|
||
name string
|
||
ch Channel
|
||
cfg map[string]any
|
||
secret string
|
||
}{
|
||
{
|
||
name: "钉钉 access_token 在 query",
|
||
ch: dingTalkChannel{},
|
||
cfg: map[string]any{"webhook": "http://127.0.0.1:1/robot/send?access_token=" + leakProbeToken},
|
||
secret: leakProbeToken,
|
||
},
|
||
{
|
||
name: "企业微信 key 在 query",
|
||
ch: weComChannel{},
|
||
cfg: map[string]any{"webhook": "http://127.0.0.1:1/cgi-bin/webhook/send?key=" + leakProbeToken},
|
||
secret: leakProbeToken,
|
||
},
|
||
{
|
||
name: "飞书 hook id 在路径末段",
|
||
ch: feishuChannel{},
|
||
cfg: map[string]any{"webhook": "http://127.0.0.1:1/open-apis/bot/v2/hook/" + leakProbeToken},
|
||
secret: leakProbeToken,
|
||
},
|
||
{
|
||
name: "Telegram bot token 在路径中段",
|
||
ch: telegramChannel{},
|
||
cfg: map[string]any{"bot_token": leakProbeToken, "chat_id": "1", "base_url": "http://127.0.0.1:1"},
|
||
secret: leakProbeToken,
|
||
},
|
||
{
|
||
name: "钉钉加签密钥",
|
||
ch: dingTalkChannel{},
|
||
cfg: map[string]any{"webhook": "http://127.0.0.1:1/robot/send", "secret": leakProbeToken},
|
||
secret: leakProbeToken,
|
||
},
|
||
}
|
||
|
||
// leakProbeToken 是一个绝不可能是真实凭据的哨兵值,用于在错误文本里搜它。
|
||
const leakProbeToken = "LEAKPROBE0123456789abcdef"
|
||
|
||
// TestChannelErrorsNeverLeakCredentials 是核心不变量。
|
||
func TestChannelErrorsNeverLeakCredentials(t *testing.T) {
|
||
for _, tc := range credentialCases {
|
||
t.Run(tc.name, func(t *testing.T) {
|
||
// 必然失败的对端:127.0.0.1:1 无人监听,走的是连接被拒这条路径。
|
||
_, err := tc.ch.Send(context.Background(), tc.cfg, Message{
|
||
Items: []Item{{FindingID: 1, Severity: "high", Name: "泄露探针"}},
|
||
})
|
||
if err == nil {
|
||
t.Fatal("对不可达地址应报错")
|
||
}
|
||
assertNoSecret(t, err.Error(), tc.secret)
|
||
})
|
||
}
|
||
}
|
||
|
||
// TestChannelErrorsNeverLeakCredentialsInPermanentPath 覆盖永久失败分支:
|
||
// URL 校验失败、平台业务错误等也会把错误文本外传,同样不能带凭据。
|
||
func TestChannelErrorsNeverLeakCredentialsInPermanentPath(t *testing.T) {
|
||
cases := []struct {
|
||
name string
|
||
ch Channel
|
||
cfg map[string]any
|
||
}{
|
||
// 地址里带凭据但格式非法 → 触发 validateHTTPURL / url.Parse 分支。
|
||
{"钉钉地址非法", dingTalkChannel{}, map[string]any{"webhook": "file:///" + leakProbeToken}},
|
||
{"企微地址非法", weComChannel{}, map[string]any{"webhook": "gopher://" + leakProbeToken}},
|
||
{"飞书地址非法", feishuChannel{}, map[string]any{"webhook": "ftp://" + leakProbeToken + "/hook"}},
|
||
{"Telegram API 地址非法", telegramChannel{}, map[string]any{"bot_token": "tok", "chat_id": "1", "base_url": "file://" + leakProbeToken}},
|
||
{"通用 Webhook 地址非法", webhookChannel{}, map[string]any{"url": "javascript:" + leakProbeToken}},
|
||
}
|
||
for _, tc := range cases {
|
||
t.Run(tc.name, func(t *testing.T) {
|
||
_, err := tc.ch.Send(context.Background(), tc.cfg, Message{Items: []Item{{Severity: "high"}}})
|
||
if err == nil {
|
||
t.Fatal("非法配置应报错")
|
||
}
|
||
assertNoSecret(t, err.Error(), leakProbeToken)
|
||
})
|
||
}
|
||
}
|
||
|
||
func assertNoSecret(t *testing.T, text, secret string) {
|
||
t.Helper()
|
||
if strings.Contains(text, secret) {
|
||
t.Fatalf("错误文本泄露了凭据 %q:\n %s", secret, text)
|
||
}
|
||
}
|
||
|
||
func TestRedactRequestTargetKeepsOnlySchemeAndHost(t *testing.T) {
|
||
cases := map[string]string{
|
||
"https://oapi.dingtalk.com/robot/send?access_token=S1": "https://oapi.dingtalk.com/…",
|
||
"https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=S2": "https://qyapi.weixin.qq.com/…",
|
||
"https://open.feishu.cn/open-apis/bot/v2/hook/S3": "https://open.feishu.cn/…",
|
||
"https://api.telegram.org/botS4/sendMessage": "https://api.telegram.org/…",
|
||
"http://10.0.0.5:8080/hook": "http://10.0.0.5:8080/…",
|
||
}
|
||
for in, want := range cases {
|
||
got := redactRequestTarget(in)
|
||
if got != want {
|
||
t.Errorf("redactRequestTarget(%q) = %q,期望 %q", in, got, want)
|
||
}
|
||
// 脱敏结果本身不得再含有原地址的任何路径/查询片段。
|
||
if parts := strings.SplitN(in, "://", 2); len(parts) == 2 {
|
||
if hostAndRest := strings.SplitN(parts[1], "/", 2); len(hostAndRest) == 2 && hostAndRest[1] != "" {
|
||
if strings.Contains(got, hostAndRest[1]) {
|
||
t.Errorf("脱敏后仍含路径/查询片段 %q: %q", hostAndRest[1], got)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
// 不可解析的输入绝不回显原串。
|
||
for _, bad := range []string{"", "://", "not a url", "http://"} {
|
||
if got := redactRequestTarget(bad); strings.Contains(got, bad) && bad != "" {
|
||
t.Errorf("不可解析输入 %q 被回显为 %q", bad, got)
|
||
}
|
||
}
|
||
}
|
||
|
||
// TestRedactTransportErrorStripsURL 直接盯住 *url.Error 这个具体类型:
|
||
// 它是 http.Client.Do 的返回类型,也是泄露的第一现场。
|
||
func TestRedactTransportErrorStripsURL(t *testing.T) {
|
||
inner := errors.New("dial tcp 127.0.0.1:1: connect: connection refused")
|
||
uerr := &url.Error{
|
||
Op: "Post",
|
||
URL: "https://api.telegram.org/bot" + leakProbeToken + "/sendMessage",
|
||
Err: inner,
|
||
}
|
||
got := redactTransportError(uerr)
|
||
assertNoSecret(t, got, leakProbeToken)
|
||
if !strings.Contains(got, "api.telegram.org") {
|
||
t.Errorf("应保留 host 以便排查,得到 %q", got)
|
||
}
|
||
if !strings.Contains(got, "connection refused") {
|
||
t.Errorf("应保留底层原因以便排查,得到 %q", got)
|
||
}
|
||
// Op 也要保留(POST 还是 GET 对排查有意义)。
|
||
if !strings.Contains(got, "Post") {
|
||
t.Errorf("应保留操作名,得到 %q", got)
|
||
}
|
||
}
|
||
|
||
// TestRedactURLsInTextHandlesFallback 兜底路径:非 *url.Error 的自定义错误
|
||
// (如重定向策略返回的错误)里的地址同样要被摘掉。
|
||
func TestRedactURLsInTextHandlesFallback(t *testing.T) {
|
||
in := fmt.Sprintf("拒绝跨主机重定向(a.example → http://b.example/bot%s/send)", leakProbeToken)
|
||
got := redactURLsInText(in)
|
||
assertNoSecret(t, got, leakProbeToken)
|
||
if !strings.Contains(got, "http://b.example/…") {
|
||
t.Errorf("应把地址替换为脱敏形态,得到 %q", got)
|
||
}
|
||
// 不含地址的文本原样保留。
|
||
if plain := "dial tcp: connection refused"; redactURLsInText(plain) != plain {
|
||
t.Error("不含地址的文本不应被改动")
|
||
}
|
||
}
|
||
|
||
// TestCrossHostRedirectRefused 覆盖「凭据在 URL 里 + 跟随跨主机跳转 = 交出凭据」。
|
||
// httptest 的两个服务监听在 127.0.0.1 的不同端口,端口不同即 Host 不同,
|
||
// 正好构成跨主机跳转。
|
||
func TestCrossHostRedirectRefused(t *testing.T) {
|
||
var hit bool
|
||
target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||
hit = true
|
||
_, _ = io.WriteString(w, `{"errcode":0,"errmsg":"ok"}`)
|
||
}))
|
||
defer target.Close()
|
||
|
||
redirector := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
http.Redirect(w, r, target.URL+"/robot/send?access_token="+leakProbeToken, http.StatusTemporaryRedirect)
|
||
}))
|
||
defer redirector.Close()
|
||
|
||
_, err := (dingTalkChannel{}).Send(context.Background(),
|
||
map[string]any{"webhook": redirector.URL + "/robot/send?access_token=" + leakProbeToken},
|
||
Message{Items: []Item{{Severity: "high"}}})
|
||
if err == nil {
|
||
t.Fatal("跨主机重定向应被拒绝")
|
||
}
|
||
if hit {
|
||
t.Fatal("跳转目标被访问了——凭据已随重定向外泄")
|
||
}
|
||
assertNoSecret(t, err.Error(), leakProbeToken)
|
||
}
|
||
|
||
// TestSameHostRedirectAllowed 反向用例:同主机跳转(如末尾补斜杠)必须仍然可用,
|
||
// 否则会把正常工作流一起挡掉。
|
||
func TestSameHostRedirectAllowed(t *testing.T) {
|
||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
if r.URL.Path == "/robot/send" {
|
||
// 同主机、同端口的跳转。
|
||
http.Redirect(w, r, "/robot/send/", http.StatusTemporaryRedirect)
|
||
return
|
||
}
|
||
_, _ = io.WriteString(w, `{"errcode":0,"errmsg":"ok"}`)
|
||
}))
|
||
defer srv.Close()
|
||
|
||
if _, err := (dingTalkChannel{}).Send(context.Background(),
|
||
map[string]any{"webhook": srv.URL + "/robot/send"},
|
||
Message{Items: []Item{{Severity: "high"}}}); err != nil {
|
||
t.Fatalf("同主机重定向不应被拒绝: %v", err)
|
||
}
|
||
}
|