Files
ARTEX-chixuweihu/notify/markdown.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

204 lines
8.0 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package notify
import (
"fmt"
"strings"
)
// 本文件是「Markdown 系」渠道(钉钉、企业微信)共用的消息渲染。
// 飞书用卡片 JSON、Telegram 用 HTML、邮件用 HTML,各自在适配器里渲染。
// maxAssetsShown 是消息里最多列出几个资产。一个漏洞可能锚定几十个资产,
// 全列会挤爆消息且没有信息价值——第 4 个之后的域名没人会在 IM 里看。
const maxAssetsShown = 3
// maxSummaryRunes 是摘要被压缩到多少字符。IM 消息是「提示去看详情」,
// 不是报告本体,完整内容在平台里。
const maxSummaryRunes = 120
// markdownReservedBytes 预留给消息头部(汇总行 + 级别分布 + 可能的截断提示)
// 与尾部(平台链接)。按整条打包时把这部分从预算里扣掉,保证头尾不会被截掉——
// 头尾一旦被截,读者连「这是哪一批、还有多少条没显示」都看不出来。
const markdownReservedBytes = 320
// markdownEscape 转义 markdown 元字符。
//
// 为什么必须做:漏洞标题、摘要、类型、资产展示名全都来自**不可信来源**——
// 标题与摘要出自模型输出(模型读的是被测目标的响应),资产的 url 则是扫描
// 得到的完整 URL(含目标可控的查询串)。不转义的话,一条标题为
//
// 登录口 SQL 注入\n[紧急:点此验证账号](http://attacker.tld)
//
// 的漏洞会在安全工程师的钉钉/飞书里渲染成**可点击的外链**;而
// `![](http://attacker.tld/beacon)` 会在渲染时被客户端拉取,等于通报了
// 「这条漏洞已经被看过」并泄露阅读者 IP。就算是无恶意的内容,注入的粗体或
// 引用块也能把下面的严重漏洞挤出折叠线。
//
// 转义集合覆盖标题/链接/强调/列表/引用/删除线这几类会改变结构或产生可点击
// 元素的字符。`\` 必须最先处理,否则会把后面补上的反斜杠再次转义。
func markdownEscape(s string) string {
replacer := strings.NewReplacer(
`\`, `\\`,
"`", "\\`",
"*", `\*`,
"_", `\_`,
"[", `\[`,
"]", `\]`,
"(", `\(`,
")", `\)`,
"!", `\!`,
"#", `\#`,
">", `\>`,
"|", `\|`,
"~", `\~`,
)
return replacer.Replace(s)
}
// markdownText 把不可信文本压成单行并转义,供 markdown 正文使用。
// 单行化是转义之外的另一半:换行本身就能伪造出新的列表项或引用块,
// 而转义字符挡不住它。
func markdownText(s string, maxRunes int) string {
return markdownEscape(OneLine(s, maxRunes))
}
// markdownTitle 返回消息标题(IM 平台的标题栏/卡片标题),内容是**未转义的原文**。
//
// 这里刻意不做转义:这个标题被四种语境的渲染器共用——markdown 正文、Telegram 的
// HTML、飞书卡片的 plain_text、以及通用 Webhook 的 JSON 与邮件主题。每个语境的
// 转义规则都不同(markdown 转义塞进 HTML 会留下可见的反斜杠,塞进 JSON 会污染
// 数据),所以转义必须由各自的输出端负责,见 writeItem / feishuItemLines /
// telegramEscape。曾经在共享函数里加过 markdown 转义,结果 Telegram 消息里
// 出现了 `\(1\)` 这种可见的反斜杠。
func markdownTitle(m Message) string {
if m.Batch {
return fmt.Sprintf("漏洞汇总 · 共 %d 条", len(m.Items))
}
if len(m.Items) == 0 {
return "漏洞通知"
}
it := m.Items[0]
return fmt.Sprintf("[%s] %s", SeverityLabel(it.Severity), OneLine(it.Title(), 0))
}
// markdownBody 渲染消息正文,返回正文与**实际写入的条目数**。
//
// 返回值 kept 是这次投递真正送达的条目数,调用方据此只把前 kept 条标记为
// 已送达——被渠道长度上限挡在外面的条目必须留待下一批,而不是跟着一起被
// 标记成功。这正是「静默丢失」的来源:消息被截断了,但投递记录显示全部送达,
// 没有任何地方能看出后半截从未发出。
//
// maxBytes<=0 表示不限制。
func markdownBody(m Message, maxBytes int) (string, int) {
if !m.Batch {
if len(m.Items) == 0 {
return "", 0
}
var b strings.Builder
writeItem(&b, m.Items[0], "", true)
// 单条消息即使超长也照发(由最终截断兜底):一条漏洞的部分信息
// 也好过一条都不发。
return TruncateBytes(b.String(), maxBytes), 1
}
footer := ""
if m.HomeURL != "" {
footer = fmt.Sprintf("\n[在平台中查看全部](%s)\n", m.HomeURL)
}
kept := packItemCount(m.Items, maxBytes, markdownReservedBytes, footer, byteSize, func(it Item, idx int) string {
var b strings.Builder
writeItem(&b, it, fmt.Sprintf("%d. ", idx+1), false)
return b.String()
})
items := m.Items[:kept]
var b strings.Builder
b.WriteString(markdownBatchIntro(m, items, len(m.Items)))
for i, it := range items {
writeItem(&b, it, fmt.Sprintf("%d. ", i+1), false)
}
b.WriteString(footer)
return TruncateBytes(b.String(), maxBytes), kept
}
// markdownBatchIntro 渲染汇总消息的开头:时间窗、条数与级别分布。
// 有了这些,收到汇总的人不用点进平台就能判断这批需不需要立刻处理。
//
// items 是**实际装下**的条目,total 是本批应有的总数。两者不同时必须明说
// 「还有多少条在下一条消息里」——否则读者会以为消息头写的那个数字就是全部,
// 而后面那些从未发出的条目在界面上完全不存在。
func markdownBatchIntro(m Message, items []Item, total int) string {
var b strings.Builder
if m.WindowMinutes > 0 {
fmt.Fprintf(&b, "**近 %d 分钟新增 %d 个漏洞**", m.WindowMinutes, total)
} else {
fmt.Fprintf(&b, "**新增 %d 个漏洞**", total)
}
if extra := total - len(items); extra > 0 {
fmt.Fprintf(&b, "(本条显示前 %d 条,其余 %d 条将在下一条消息继续)", len(items), extra)
}
// 按级别给出分布,让读者一眼看到有没有严重项。只统计**本条实际包含**的
// 条目,保证「严重 3」和下面能数出来的条目一致。
counts := map[string]int{}
for _, it := range items {
counts[it.Severity]++
}
var parts []string
for _, sev := range []string{"critical", "high", "medium", "low"} {
if n := counts[sev]; n > 0 {
parts = append(parts, fmt.Sprintf("%s %d", SeverityLabel(sev), n))
}
}
if len(parts) > 0 {
b.WriteString("\n" + strings.Join(parts, " · "))
}
b.WriteString("\n\n")
return b.String()
}
// writeItem 渲染单个漏洞条目。
//
// prefix 用于汇总列表的序号;single=true 时渲染完整版(含摘要与回链),
// 汇总列表里只渲染一行摘要——否则 50 条汇总会变成一篇长文档。
//
// 所有来自外部的内容(标题/类型/资产/摘要)都过 markdownText:
// 单行化 + 转义。回链是管理员配置的 public_base_url 拼出来的,不是不可信内容,
// 且必须是可点的链接,所以原样输出。
func writeItem(b *strings.Builder, it Item, prefix string, single bool) {
line := fmt.Sprintf("%s**%s · %s**", prefix, SeverityLabel(it.Severity), markdownText(it.Title(), 0))
if !single {
// 汇总模式:单行呈现,资产与摘要压缩后跟在后面。
var extras []string
if a := assetLine(it.Assets, maxAssetsShown); a != "" {
extras = append(extras, markdownText(a, 0))
}
if it.Summary != "" {
extras = append(extras, markdownText(it.Summary, 60))
}
if len(extras) > 0 {
line += " — " + strings.Join(extras, " · ")
}
b.WriteString(line + "\n")
return
}
b.WriteString(line + "\n")
if it.IsStatusChange() {
fmt.Fprintf(b, "**状态变更**:%s → %s\n",
markdownText(StatusLabel(it.FromStatus), 0), markdownText(StatusLabel(it.ToStatus), 0))
}
if it.VulnClass != "" && it.VulnClass != it.Title() {
fmt.Fprintf(b, "**类型**:%s\n", markdownText(it.VulnClass, 0))
}
if a := assetLine(it.Assets, maxAssetsShown); a != "" {
fmt.Fprintf(b, "**资产**:%s\n", markdownText(a, 0))
}
if it.Summary != "" {
if s := markdownText(it.Summary, maxSummaryRunes); s != "" {
fmt.Fprintf(b, "**摘要**:%s\n", s)
}
}
if it.DetailURL != "" {
fmt.Fprintf(b, "[查看详情](%s)\n", it.DetailURL)
}
}