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]>
This commit is contained in:
luckoneandClaude Code committed 2026-09-30 16:25:06 +08:00
1 parent f3fb37f70d
commit a049f09b97
26 files changed
+1586 -229

No files matched your search

+32 -12
View File
@@ -8,6 +8,7 @@ import (
"encoding/json"
"errors"
"fmt"
"net"
"net/url"
"strconv"
"time"
@@ -31,6 +32,9 @@ func (dingTalkChannel) DefaultRatePerMin() int { return 20 }
// 钉钉的 Webhook 地址里带 access_token,本身就是凭据,因此整体掩码。
func (dingTalkChannel) SecretKeys() []string { return []string{"webhook", "secret"} }
// 目标是钉钉的 Webhook 地址本身;改地址必须同时对新地址重新表态加签密钥。
func (dingTalkChannel) DestinationKeys() []string { return []string{"webhook"} }
func (dingTalkChannel) Validate(cfg map[string]any) error {
hook := cfgString(cfg, "webhook")
if hook == "" {
@@ -43,19 +47,19 @@ func (dingTalkChannel) Validate(cfg map[string]any) error {
}
// Send 投递一次消息。有回链且是单条时用 ActionCard(带按钮),否则用 markdown。
func (c dingTalkChannel) Send(ctx context.Context, cfg map[string]any, m Message) error {
func (c dingTalkChannel) Send(ctx context.Context, cfg map[string]any, m Message) (int, error) {
hook := cfgString(cfg, "webhook")
if err := c.Validate(cfg); err != nil {
return Permanent(err)
return 0, Permanent(err)
}
endpoint, err := dingTalkSignedURL(hook, cfgString(cfg, "secret"), time.Now())
if err != nil {
return Permanent(err)
return 0, Permanent(err)
}
title := markdownTitle(m)
// 钉钉 markdown 正文无明确字节上限,但仍做上限保护,避免证据字段异常膨胀。
text := markdownBody(m, 20000)
text, kept := markdownBody(m, 20000)
var payload any
if !m.Batch && len(m.Items) == 1 && m.Items[0].DetailURL != "" {
@@ -78,7 +82,7 @@ func (c dingTalkChannel) Send(ctx context.Context, cfg map[string]any, m Message
raw, err := doJSON(ctx, "POST", endpoint, nil, payload)
if err != nil {
return err
return 0, err
}
// 钉钉把业务错误藏在 200 响应里。
var res struct {
@@ -86,14 +90,14 @@ func (c dingTalkChannel) Send(ctx context.Context, cfg map[string]any, m Message
ErrMsg string `json:"errmsg"`
}
if err := json.Unmarshal(raw, &res); err != nil {
return fmt.Errorf("解析钉钉响应失败: %w (%s)", err, snippet(raw))
return 0, fmt.Errorf("解析钉钉响应失败: %w (%s)", err, snippet(raw))
}
if res.ErrCode != 0 {
// 301000 是签名校验失败、310000 是关键词不匹配——都是配置错误,
// 重试不会自愈。
return Permanent(fmt.Errorf("钉钉返回错误 %d: %s", res.ErrCode, res.ErrMsg))
return 0, Permanent(fmt.Errorf("钉钉返回错误 %d: %s", res.ErrCode, res.ErrMsg))
}
return nil
return kept, nil
}
// dingTalkSignedURL 按官方加签规则给 webhook 追加 timestamp 与 sign 参数。
@@ -122,13 +126,26 @@ func dingTalkSignedURL(hook, secret string, now time.Time) (string, error) {
return u.String(), nil
}
// validateHTTPURL 校验地址可用且协议受支持。
// 限制协议是防御性的:file:// 之类会让 http.Client 产生意料之外的行为,
// 虽然 webhook 地址由管理员配置、不是攻击者可控,但没有理由放开这个面。
// validateHTTPURL 校验地址可用、协议受支持,并对字面 IP 目标做内网判断。
//
// 两点讲究:
//
// 1. **错误信息必须脱敏**。url.Parse 自己返回的是 *url.Error,它的 Error() 带
// **完整原始地址**,而本功能这几家的地址里就嵌着凭据(钉钉 access_token、
// 企微 key、Telegram 的 bot token、飞书 hook id)。曾经这里直接 `return err`,
// 于是「地址格式非法」这条错误就把凭据带了出去,流向测试接口的 400 响应、
// 每次投递落库的 last_error、服务端日志与投递历史接口。
//
// 2. **字面 IP 直接判内网**,域名留给拨号阶段判(blockInternalDial 才是最终
// 生效点,也能覆盖 DNS 重绑定)。这里做一次是为了让保存配置时就能得到提示,
// 而不是等到第一次投递失败。
//
// 限制协议是防御性的:file:///gopher:// 之类会让 http.Client 产生意料之外的
// 行为(虽已被 scheme 检查挡下,但没有理由放开这个面)。
func validateHTTPURL(raw string) error {
u, err := url.Parse(raw)
if err != nil {
return err
return fmt.Errorf("地址无法解析(%s)", redactRequestTarget(raw))
}
if u.Scheme != "http" && u.Scheme != "https" {
return fmt.Errorf("只支持 http/https,收到 %q", u.Scheme)
@@ -136,5 +153,8 @@ func validateHTTPURL(raw string) error {
if u.Host == "" {
return errors.New("缺少主机名")
}
if ip := net.ParseIP(u.Hostname()); ip != nil && isBlockedDialIP(ip) && !allowLocalTargets() {
return fmt.Errorf("拒绝投递到本机/链路本地地址 %s(如确需投递到本机服务,设置 %s=1)", ip, AllowLocalTargetsEnv)
}
return nil
}