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]>
150 lines
5.7 KiB
Go
150 lines
5.7 KiB
Go
package notify
|
||
|
||
import (
|
||
"context"
|
||
"io"
|
||
"net"
|
||
"net/http"
|
||
"net/http/httptest"
|
||
"net/url"
|
||
"os"
|
||
"strings"
|
||
"testing"
|
||
)
|
||
|
||
// 本文件覆盖两条相关的加固:
|
||
// ① 投递地址不得把服务端当跳板打内网 / 云元数据(SSRF)
|
||
// ② 地址校验的错误信息不得带出地址里的凭据
|
||
//
|
||
// 关于测试环境:本包大量用例用 127.0.0.1 上的 httptest 假接收端,守卫默认会拦下
|
||
// 它们。所以 TestMain 里统一打开 AllowLocalTargetsEnv,而下面每个 SSRF 用例都
|
||
// 显式把它清掉,以断言**默认拒绝**的行为。
|
||
|
||
func TestMain(m *testing.M) {
|
||
// 让常规用例能连本地的假接收端;SSRF 用例会自己临时清空。
|
||
_ = os.Setenv(AllowLocalTargetsEnv, "1")
|
||
os.Exit(m.Run())
|
||
}
|
||
|
||
// TestDialGuardRejectsLoopbackByDefault 是 SSRF 防护的核心断言:
|
||
// 默认配置下,投递到环回地址必须被**连接层**拒绝。
|
||
func TestDialGuardRejectsLoopbackByDefault(t *testing.T) {
|
||
var hit bool
|
||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||
hit = true
|
||
_, _ = io.WriteString(w, `{"errcode":0}`)
|
||
}))
|
||
defer srv.Close()
|
||
|
||
t.Setenv(AllowLocalTargetsEnv, "") // 关掉逃生口 = 默认行为
|
||
_, err := (dingTalkChannel{}).Send(context.Background(),
|
||
map[string]any{"webhook": srv.URL + "/robot/send"}, Message{Items: []Item{{Severity: "high"}}})
|
||
if err == nil {
|
||
t.Fatal("默认不应允许投递到环回地址")
|
||
}
|
||
if hit {
|
||
t.Fatal("请求已经打到了本机服务——守卫没生效")
|
||
}
|
||
// 错误信息要能指导用户怎么放开(本机 SMTP 中继是合法配置)。
|
||
if !strings.Contains(err.Error(), AllowLocalTargetsEnv) {
|
||
t.Errorf("拒绝信息应说明如何显式放开: %v", err)
|
||
}
|
||
}
|
||
|
||
// TestDialGuardAllowsLoopbackWhenOptedIn 反向用例:显式打开后必须能用,
|
||
// 否则本机 postfix / 内网中继这类合法部署会被一刀切废掉。
|
||
func TestDialGuardAllowsLoopbackWhenOptedIn(t *testing.T) {
|
||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||
_, _ = io.WriteString(w, `{"errcode":0,"errmsg":"ok"}`)
|
||
}))
|
||
defer srv.Close()
|
||
|
||
t.Setenv(AllowLocalTargetsEnv, "1")
|
||
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)
|
||
}
|
||
}
|
||
|
||
func TestIsBlockedDialIP(t *testing.T) {
|
||
blocked := []string{
|
||
"127.0.0.1", "127.1.2.3", "::1",
|
||
"169.254.169.254", // 云元数据端点——本函数存在的主要理由
|
||
"169.254.1.1", "fe80::1",
|
||
"0.0.0.0", "::",
|
||
"224.0.0.1", "ff02::1",
|
||
"::ffff:127.0.0.1", // IPv4-mapped 形式必须还原后再判,否则是绕过口
|
||
"",
|
||
}
|
||
for _, s := range blocked {
|
||
if !isBlockedDialIP(net.ParseIP(s)) {
|
||
t.Errorf("%s 应被拒绝", s)
|
||
}
|
||
}
|
||
// RFC1918 私网**刻意放行**:内网自建 Mattermost / SMTP 中继是常见合法用法。
|
||
// 这条断言把这个取舍固定下来——若将来有人顺手加上私网判断,这里会失败,
|
||
// 从而逼出一次有意识的决定(而不是静默废掉一批部署)。
|
||
allowed := []string{"10.0.0.5", "172.16.3.4", "192.168.1.10", "8.8.8.8", "2606:4700::1111"}
|
||
for _, s := range allowed {
|
||
if isBlockedDialIP(net.ParseIP(s)) {
|
||
t.Errorf("%s 应被放行(私网是常见的合法投递目标)", s)
|
||
}
|
||
}
|
||
}
|
||
|
||
// TestValidateHTTPURLRejectsLiteralPrivateTargets 覆盖配置阶段的前置提示:
|
||
// 字面 IP 在保存时就该被拒,而不是等第一次投递失败。
|
||
func TestValidateHTTPURLRejectsLiteralPrivateTargets(t *testing.T) {
|
||
t.Setenv(AllowLocalTargetsEnv, "")
|
||
for _, raw := range []string{
|
||
"http://127.0.0.1:8080/hook",
|
||
"http://169.254.169.254/latest/meta-data/",
|
||
"http://[::1]:8080/hook",
|
||
} {
|
||
if err := validateHTTPURL(raw); err == nil {
|
||
t.Errorf("%s 应在配置阶段被拒绝", raw)
|
||
}
|
||
}
|
||
// 公网地址与私网地址照常通过(私网留给拨号阶段,那里不拦)。
|
||
for _, raw := range []string{"https://oapi.dingtalk.com/robot/send", "http://10.0.0.9/hook"} {
|
||
if err := validateHTTPURL(raw); err != nil {
|
||
t.Errorf("%s 应通过校验: %v", raw, err)
|
||
}
|
||
}
|
||
}
|
||
|
||
// TestValidateHTTPURLErrorNeverLeaksCredentials 是审计指出我上一轮遗漏的分支。
|
||
//
|
||
// url.Parse **失败**时会返回 *url.Error,其 Error() 含完整原始地址。上一轮我只
|
||
// 脱敏了 http.Client.Do 的返回错误,漏了这里;而当时补的「永久失败路径」用例
|
||
// (file://、gopher://、ftp://)其实都能被 url.Parse 解析成功、走的是 scheme 分支,
|
||
// 所以全绿也证明不了这条路径安全——是假保证。
|
||
func TestValidateHTTPURLErrorNeverLeaksCredentials(t *testing.T) {
|
||
cases := []string{
|
||
"http://127.0.0.1/%zz?access_token=" + leakProbeToken, // 非法百分号转义
|
||
"https://a.example.com:port/x?access_token=" + leakProbeToken, // 端口非数字
|
||
"http://[::1?access_token=" + leakProbeToken, // 括号不配对
|
||
}
|
||
for _, raw := range cases {
|
||
// 先确认这个输入**确实**让 url.Parse 失败。不做这一步的话,用例可能在
|
||
// 毫无察觉的情况下走到别的分支(上一轮的假保证就是这么来的)。
|
||
if _, err := url.Parse(raw); err == nil {
|
||
t.Errorf("%q 本应解析失败,否则这条用例没有覆盖到目标分支", raw)
|
||
continue
|
||
}
|
||
err := validateHTTPURL(raw)
|
||
if err == nil {
|
||
t.Errorf("%q 应校验失败", raw)
|
||
continue
|
||
}
|
||
assertNoSecret(t, err.Error(), leakProbeToken)
|
||
}
|
||
// 确认渠道层的包装也没有把地址带出去。
|
||
t.Setenv(AllowLocalTargetsEnv, "")
|
||
err := (dingTalkChannel{}).Validate(map[string]any{"webhook": cases[0]})
|
||
if err == nil {
|
||
t.Fatal("非法地址应校验失败")
|
||
}
|
||
assertNoSecret(t, err.Error(), leakProbeToken)
|
||
}
|