Files
ARTEX/notify/render.go
T
luckoneandClaude Code a049f09b97 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]>
2026-09-30 16:25:06 +08:00

161 lines
5.8 KiB
Go

package notify
import (
"strings"
"unicode/utf8"
)
const ellipsis = "…"
// TruncateBytes 把 s 截断到不超过 max 字节,保证结果是合法 UTF-8 且不切断字符。
//
// 为什么必须按字符边界切:企微群机器人的 markdown 有 4096 **字节**硬上限(不是
// 字符数),而中文一个字 3 字节。直接按字节切片会把一个汉字切成两半,产出非法
// UTF-8——平台侧要么整条拒收,要么显示成乱码方块。这里的做法是先从预算位置
// 往前回退到最近的 rune 起始字节(utf8.RuneStart 判定续字节 0b10xxxxxx)。
//
// max<=0 表示不限制。截断后追加省略号,除非 max 小到装不下省略号。
func TruncateBytes(s string, max int) string {
if max <= 0 || len(s) <= max {
return s
}
budget := max - len(ellipsis)
suffix := ellipsis
if budget < 0 {
// max 比省略号还短:放弃省略号,纯截断,避免结果反而超出 max。
budget = max
suffix = ""
}
cut := budget
for cut > 0 && !utf8.RuneStart(s[cut]) {
cut--
}
return s[:cut] + suffix
}
// OneLine 把多行文本压成单行:折叠所有空白,再按字符数截断。
// 用于 IM 消息的标题行——摘要里常有换行,直接塞进表格/标题会撑坏排版。
// max<=0 表示不限制长度。
func OneLine(s string, max int) string {
s = strings.Join(strings.Fields(s), " ")
return TruncateRunes(s, max)
}
// TruncateRunes 把 s 截断到不超过 max 个字符(而非字节),超出时追加省略号。
// max<=0 表示不限制。
//
// 与 TruncateBytes 的区别在于平台口径:企微按字节限长,Telegram 按字符数限长。
// 用错口径不会报错,只会让消息被切得远比预期短(中文 1 字 = 3 字节,
// 按字节切 4096 只剩约 1365 字),所以两个函数都必须保留、按渠道选用。
func TruncateRunes(s string, max int) string {
if max <= 0 {
return s
}
runes := []rune(s)
if len(runes) <= max {
return s
}
if max <= 1 {
return string(runes[:max])
}
return string(runes[:max-1]) + ellipsis
}
// TruncateHTML 按字符数截断 HTML 片段,并保证不产生半截标签。
//
// 直接对 HTML 做字符截断会切出 `<a href="htt` 这种残缺标签,平台解析器要么
// 报错拒收整条、要么把后续正文当成属性值吞掉。这里的做法是:先按字符截断,
// 再检查尾部是否有未闭合的 `<`,有就退到它之前。
//
// 不做标签配平(补全 </b> 之类):Telegram 的 HTML 解析器会自动闭合未闭合标签,
// 而自己实现配平要处理属性里的引号、注释、自闭合标签,复杂度与收益不成比例。
func TruncateHTML(s string, max int) string {
if max <= 0 || len([]rune(s)) <= max {
return s
}
cut := TruncateRunes(s, max)
// 尾部若是 `<` 开头的残片(最后出现 `<` 之后没有 `>`),退回 `<` 之前。
if lt := strings.LastIndex(cut, "<"); lt >= 0 && !strings.Contains(cut[lt:], ">") {
cut = cut[:lt]
}
// 尾部若是被切断的 HTML 实体(如 `&amp;` 被切成 `&amp`),同样要退回去。
// 实体残片在一个只认实体的解析器里可能让**整条消息**被拒收——一条超过
// 长度上限的汇总消息本来就常见,不值得为此丢掉整条通知。
if amp := strings.LastIndex(cut, "&"); amp >= 0 && !strings.Contains(cut[amp:], ";") {
cut = cut[:amp]
}
return cut
}
// packItemCount 计算在预算内能**完整**放下多少条,供汇总消息按整条打包。
//
// 为什么要按整条而不是渲染完整篇再截断:截断会让后半截条目凭空消失,
// 而它们的投递记录仍会被标记为已送达——消息里看不出来、投递历史里也看不出来,
// 漏洞就这么没了。按整条打包后,装不下的条目留在库里成为下一批,
// 调用方拿到的 kept 就是本条消息真正送达的条数。
//
// 参数:maxSize<=0 表示不限制;reserve 是给消息头部/尾部预留的量;
// size 负责计量(各平台口径不同:企微/钉钉按字节,Telegram 按字符数——
// 用错口径不会报错,只会让中文消息被压到远小于上限);
// render 把第 idx 条渲染成它的实际文本——长度因内容而异,不能靠估算。
//
// 至少返回 1(只要还有条目)。单条极端超长时也要发出这一条、由调用方的
// 最终截断兜底,否则一条超长漏洞会把整批永久卡在原地。
func packItemCount(items []Item, maxSize, reserve int, footer string, size func(string) int, render func(Item, int) string) int {
if maxSize <= 0 {
return len(items)
}
budget := maxSize - reserve - size(footer)
if budget < 0 {
budget = 0
}
used := 0
for i, it := range items {
used += size(render(it, i))
if used > budget && i > 0 {
return i
}
}
return len(items)
}
// byteSize / runeSize 是 packItemCount 的两种计量口径,命名出来避免调用处
// 出现裸的 func(s string) int 闭包,否则很难一眼看出用的是哪种口径。
func byteSize(s string) int { return len(s) }
func runeSize(s string) int { return utf8.RuneCountInString(s) }
// assetLine 把资产列表渲染成一行展示文本,超过 limit 个时省略其余并标注总数。
// 一个漏洞可能锚定几十个资产,全列出来会挤爆消息。
func assetLine(assets []string, limit int) string {
if len(assets) == 0 {
return ""
}
if limit <= 0 || len(assets) <= limit {
return strings.Join(assets, "、")
}
return strings.Join(assets[:limit], "、") + " 等 " + itoa(len(assets)) + " 个"
}
// itoa 是 strconv.Itoa 的短别名,仅用于拼接展示文本,避免到处 import strconv。
func itoa(n int) string {
if n == 0 {
return "0"
}
neg := n < 0
if neg {
n = -n
}
var buf [20]byte
i := len(buf)
for n > 0 {
i--
buf[i] = byte('0' + n%10)
n /= 10
}
if neg {
i--
buf[i] = '-'
}
return string(buf[i:])
}