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]>
256 lines
11 KiB
Go
256 lines
11 KiB
Go
package notify
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"io"
|
||
"net"
|
||
"net/http"
|
||
"net/url"
|
||
"os"
|
||
"strings"
|
||
"syscall"
|
||
"time"
|
||
)
|
||
|
||
// allowLocalTargets 决定是否允许把消息投递到环回 / 链路本地地址。
|
||
//
|
||
// 默认拒绝。这几段地址不是 IM 机器人或公网邮件服务器会出现的地方,而它们能
|
||
// 打到的东西很敏感:同机另一个服务的管理端口、以及云环境的元数据端点
|
||
// (169.254.169.254,可读出实例凭据)。投递地址是管理员配的,但一个被 XSS/CSRF
|
||
// 借用的管理会话、或共用同一 JWT 的第二个人,都能靠改配置把响应内容读回来
|
||
// ——doJSON 会把 4xx/5xx 的响应体前 200 字节写进 last_error,而投递历史接口
|
||
// 会把它回显出来,这就是一条半盲读原语。
|
||
//
|
||
// 但「本机 SMTP 中继」(127.0.0.1:25 上的 postfix)是自建邮件的常见配置,
|
||
// 一刀切会把人卡住。所以留一个显式逃生口而不是硬编码放行:
|
||
// 设置 ARTEX_NOTIFY_ALLOW_LOCAL=1 即允许。
|
||
//
|
||
// 导出为 AllowLocalTargetsEnv 是为了让测试能明确地打开它——本包与 server 包的
|
||
// 用例大量使用 127.0.0.1 上的 httptest 假接收端,不打开就全部被守卫拦下。
|
||
const AllowLocalTargetsEnv = "ARTEX_NOTIFY_ALLOW_LOCAL"
|
||
|
||
func allowLocalTargets() bool {
|
||
v := strings.TrimSpace(os.Getenv(AllowLocalTargetsEnv))
|
||
return v == "1" || strings.EqualFold(v, "true")
|
||
}
|
||
|
||
// isBlockedDialIP 报告目标 IP 是否属于「默认不允许投递」的地址段。
|
||
//
|
||
// 只拒绝环回、链路本地(含云元数据 169.254.169.254)、未指定与组播。
|
||
// **不拒绝** RFC1918 私网:内网自建 Mattermost / SMTP 中继是很常见的合法用法,
|
||
// 把它们一并挡掉会让功能在真实环境里直接不可用。这条取舍是刻意的——
|
||
// 防护要挡住真正敏感的目标,同时不能把正常部署一起废掉。
|
||
func isBlockedDialIP(ip net.IP) bool {
|
||
if ip == nil {
|
||
return true
|
||
}
|
||
// IPv4-mapped IPv6(::ffff:127.0.0.1)要还原成 IPv4 再判,否则绕过检查。
|
||
if v4 := ip.To4(); v4 != nil {
|
||
ip = v4
|
||
}
|
||
return ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() ||
|
||
ip.IsInterfaceLocalMulticast() || ip.IsUnspecified() || ip.IsMulticast()
|
||
}
|
||
|
||
// blockInternalDial 是 http.Transport 拨号器的 Control 钩子,在**连接建立时**
|
||
// 检查目标地址。
|
||
//
|
||
// 为什么设在拨号阶段而不是只在保存配置时校验:这里才是最终生效点。
|
||
// 它同时覆盖两种绕过配置校验的情形——DNS 重绑定(校验时解析到公网 IP、
|
||
// 真正连接时解析到内网)与重定向(虽然我们已拒绝跨主机跳转,但同主机跳转
|
||
// 仍可能把路径指到别处)。
|
||
func blockInternalDial(_, address string, _ syscall.RawConn) error {
|
||
if allowLocalTargets() {
|
||
return nil
|
||
}
|
||
host, _, err := net.SplitHostPort(address)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
ip := net.ParseIP(host)
|
||
if ip == nil {
|
||
return fmt.Errorf("无法解析目标地址 %q", host)
|
||
}
|
||
if isBlockedDialIP(ip) {
|
||
return fmt.Errorf("拒绝投递到本机/链路本地地址 %s(如确需投递到本机服务,设置 %s=1)", ip, AllowLocalTargetsEnv)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// notifyTransport 在默认 Transport 的基础上只加一个拨号守卫。
|
||
// 用 Clone 保留默认的全部调优(连接池、HTTP/2、超时、proxy 等),
|
||
// 避免为了加一个检查而改动其它行为。
|
||
var notifyTransport = func() *http.Transport {
|
||
t, ok := http.DefaultTransport.(*http.Transport)
|
||
if !ok {
|
||
return &http.Transport{}
|
||
}
|
||
clone := t.Clone()
|
||
clone.DialContext = (&net.Dialer{Timeout: 10 * time.Second, Control: blockInternalDial}).DialContext
|
||
return clone
|
||
}()
|
||
|
||
// httpClient 是所有渠道投递共用的客户端。
|
||
//
|
||
// 刻意**不**复用项目的全局出口代理(server 侧的 GlobalProxy):那个代理是给渗透
|
||
// 目标流量用的,常是不稳定的隧道,而通知的可用性不该被目标网络的抖动绑架。
|
||
// IM 推送直连即可。超时设为 15 秒——比这更慢的对端实际上已经是故障状态。
|
||
//
|
||
// 拒绝跨主机重定向:本功能的投递地址都是「一个固定 endpoint」形态,正常不会
|
||
// 重定向到别的主机;而这几家的凭据(钉钉的 access_token、企微的 key、Telegram 的
|
||
// bot token)**就在 URL 里**,跟随跨主机跳转等于把凭据交给重定向目标。同主机
|
||
// 的跳转(如末尾补斜杠)仍允许。
|
||
var httpClient = &http.Client{
|
||
Timeout: 15 * time.Second,
|
||
Transport: notifyTransport,
|
||
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||
if len(via) >= 5 {
|
||
return errors.New("重定向次数过多")
|
||
}
|
||
if len(via) > 0 && req.URL.Host != via[0].URL.Host {
|
||
return fmt.Errorf("拒绝跨主机重定向(%s → %s)", via[0].URL.Host, req.URL.Host)
|
||
}
|
||
return nil
|
||
},
|
||
}
|
||
|
||
// respBodyLimit 限制读取响应体的大小。对端异常时可能吐回超大内容,而我们只需要
|
||
// 错误码和一小段错误描述用于展示在投递历史里。
|
||
const respBodyLimit = 8 << 10
|
||
|
||
// doJSON 发送一次请求并返回响应体(已限长)。
|
||
//
|
||
// payload 为 nil 时发送空 body(用于 GET 或平台不要求 body 的场景)。
|
||
// headers 里的键值原样附加,用于通用 Webhook 的自定义头。
|
||
//
|
||
// 错误分类是这个函数的核心职责:网络层失败与 5xx/408/429 归为「可重试」,
|
||
// 其余 4xx 归为「永久失败」——重试一个 403 只是把同一个错误刷 3 遍日志。
|
||
func doJSON(ctx context.Context, method, url string, headers map[string]string, payload any) ([]byte, error) {
|
||
var body io.Reader
|
||
if payload != nil {
|
||
raw, err := json.Marshal(payload)
|
||
if err != nil {
|
||
// 序列化失败是本地 bug(配置字段类型不对),重试也不会变好。
|
||
return nil, Permanent(fmt.Errorf("构造请求体失败: %w", err))
|
||
}
|
||
body = bytes.NewReader(raw)
|
||
}
|
||
req, err := http.NewRequestWithContext(ctx, method, url, body)
|
||
if err != nil {
|
||
// URL 非法——多半是用户把地址填错了,属于永久失败。
|
||
// 这里同样不能透传 err:url.Parse 的错误文本里含完整地址。
|
||
return nil, Permanent(fmt.Errorf("请求地址非法: %s", redactRequestTarget(url)))
|
||
}
|
||
if payload != nil {
|
||
req.Header.Set("Content-Type", "application/json; charset=utf-8")
|
||
}
|
||
for k, v := range headers {
|
||
req.Header.Set(k, v)
|
||
}
|
||
resp, err := httpClient.Do(req)
|
||
if err != nil {
|
||
// 连接被拒、DNS 失败、超时——多为瞬时故障,交给退避重试。
|
||
//
|
||
// 错误文本必须脱敏后再往外传。原因:http.Client.Do 返回的是 *url.Error,
|
||
// 它的 Error() 是 `Op "完整URL": 底层错误`,而本功能这几家的凭据**就在 URL 里**
|
||
// (钉钉 access_token、企微 key、飞书 hook id、Telegram /bot<token>/)。
|
||
// 不脱敏的话,凭据会顺着这条错误串流到四个地方:notification_deliveries
|
||
// 的 last_error(明文落库)、投递历史接口的响应(**绕过渠道配置的掩码**)、
|
||
// 服务端日志、以及测试发送接口回给前端的 502 文本。
|
||
return nil, fmt.Errorf("请求失败: %s", redactTransportError(err))
|
||
}
|
||
defer resp.Body.Close()
|
||
raw, readErr := io.ReadAll(io.LimitReader(resp.Body, respBodyLimit))
|
||
if readErr != nil {
|
||
return nil, fmt.Errorf("读取响应失败: %w", readErr)
|
||
}
|
||
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
|
||
return raw, nil
|
||
}
|
||
// 429(限流)与 408(超时)值得重试;其余 4xx 是配置或权限问题,重试无意义。
|
||
if resp.StatusCode == http.StatusTooManyRequests || resp.StatusCode == http.StatusRequestTimeout {
|
||
return nil, fmt.Errorf("对方限流或超时 (HTTP %d): %s", resp.StatusCode, snippet(raw))
|
||
}
|
||
if resp.StatusCode >= 500 {
|
||
return nil, fmt.Errorf("对方服务异常 (HTTP %d): %s", resp.StatusCode, snippet(raw))
|
||
}
|
||
return nil, Permanent(fmt.Errorf("对方拒绝请求 (HTTP %d): %s", resp.StatusCode, snippet(raw)))
|
||
}
|
||
|
||
// snippet 把响应体压成一行短文本,用于错误信息。响应里可能带换行与大量空白,
|
||
// 直接塞进 last_error 会让投递历史页面排版崩掉。
|
||
func snippet(raw []byte) string {
|
||
return OneLine(string(raw), 200)
|
||
}
|
||
|
||
// redactRequestTarget 把投递地址压成「scheme://host/…」,用于错误信息。
|
||
//
|
||
// 这是本包唯一的地址脱敏口径,刻意做得**足够粗暴**:除了 scheme 与 host,
|
||
// 其余一律丢弃。原因是没有一个「通用且安全」的方式判断 URL 的哪一段是凭据:
|
||
//
|
||
// 钉钉 凭据在 query /robot/send?access_token=xxx
|
||
// 企微 凭据在 query /cgi-bin/webhook/send?key=xxx
|
||
// 飞书 凭据在**路径末段** /open-apis/bot/v2/hook/<hook_id>
|
||
// Telegram 凭据在**路径中段** /bot<token>/sendMessage
|
||
//
|
||
// 想「只保留有用部分」就得按渠道打补丁,而漏掉任何一家就是一次凭据泄露。
|
||
// 保留 host 已经够用于排查(DNS 解析不了、连不上、证书不对都能定位),
|
||
// 具体是哪个机器人由渠道配置里的掩码尾号提示去认。
|
||
//
|
||
// 解析失败时返回固定占位符——绝不把原始串回显出去。
|
||
func redactRequestTarget(raw string) string {
|
||
u, err := url.Parse(raw)
|
||
if err != nil || u.Host == "" {
|
||
return "(地址不可解析)"
|
||
}
|
||
return u.Scheme + "://" + u.Host + "/…"
|
||
}
|
||
|
||
// redactTransportError 从传输层错误里剥掉地址,只保留底层原因。
|
||
//
|
||
// *url.Error 的结构是 {Op, URL, Err},Error() 会把 URL 一起打出来。
|
||
// 这里显式取 Err 字段,绕开它的 Error() ——比事后做字符串替换更可靠,
|
||
// 因为替换要正确应对 URL 编码/转义后的各种变体,容易漏。
|
||
func redactTransportError(err error) string {
|
||
var uerr *url.Error
|
||
if errors.As(err, &uerr) {
|
||
host := ""
|
||
if u, parseErr := url.Parse(uerr.URL); parseErr == nil {
|
||
host = u.Host
|
||
}
|
||
if uerr.Err != nil {
|
||
return fmt.Sprintf("%s %s: %s", uerr.Op, host, uerr.Err)
|
||
}
|
||
return fmt.Sprintf("%s %s: 未知错误", uerr.Op, host)
|
||
}
|
||
// 非 *url.Error(如重定向策略返回的错误)也可能带地址,统一走脱敏。
|
||
return redactURLsInText(err.Error())
|
||
}
|
||
|
||
// redactURLsInText 把一段文本里出现的 http(s) 地址替换成脱敏形态。
|
||
//
|
||
// 用于兜底那些拿不到结构化字段的错误(重定向策略错误、第三方库的自定义错误)。
|
||
// 只识别 http/https 前缀,按空白与引号切分——地址不会包含这两类字符。
|
||
func redactURLsInText(s string) string {
|
||
var b strings.Builder
|
||
for i := 0; i < len(s); {
|
||
rest := s[i:]
|
||
if strings.HasPrefix(rest, "http://") || strings.HasPrefix(rest, "https://") {
|
||
end := len(rest)
|
||
if j := strings.IndexAny(rest, " \t\n\"'"); j >= 0 {
|
||
end = j
|
||
}
|
||
b.WriteString(redactRequestTarget(rest[:end]))
|
||
i += end
|
||
continue
|
||
}
|
||
b.WriteByte(s[i])
|
||
i++
|
||
}
|
||
return b.String()
|
||
}
|