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]>
209 lines
6.9 KiB
Go
209 lines
6.9 KiB
Go
package notify
|
|
|
|
import (
|
|
"strings"
|
|
"testing"
|
|
"unicode/utf8"
|
|
)
|
|
|
|
// 本文件覆盖「按整条打包」这个修复:汇总消息超出渠道长度上限时,必须**按整条**
|
|
// 截断并把没装下的条目数如实报出来,让调用方只标记真正送达的那些。
|
|
//
|
|
// 之前的做法是渲染完整篇再截断、然后整批标记已送达:消息后半截凭空消失,
|
|
// 而投递历史显示全部成功——漏洞就这么没了,且没有任何地方能发现。
|
|
|
|
func TestMarkdownBodyPacksWholeItemsWithinByteLimit(t *testing.T) {
|
|
// 200 条中文汇总,必然远超企微 4096 字节。
|
|
m := batchMsg(200)
|
|
body, kept := markdownBody(m, weComMarkdownLimit)
|
|
|
|
if len(body) > weComMarkdownLimit {
|
|
t.Fatalf("正文 %d 字节超上限 %d", len(body), weComMarkdownLimit)
|
|
}
|
|
if !utf8.ValidString(body) {
|
|
t.Fatal("正文不是合法 UTF-8")
|
|
}
|
|
if kept <= 0 || kept >= len(m.Items) {
|
|
t.Fatalf("应只装下一部分(0 < kept < %d),得到 %d", len(m.Items), kept)
|
|
}
|
|
// 头部必须如实说明本条只包含多少条、其余有多少条——否则读者会把头部
|
|
// 那个数字当成全部。
|
|
if !strings.Contains(body, "其余") || !strings.Contains(body, "下一条消息继续") {
|
|
t.Fatalf("头部应说明还有多少条未包含在本条里:\n%s", body[:minInt(400, len(body))])
|
|
}
|
|
// 只应包含前 kept 条。
|
|
for i := 0; i < kept; i++ {
|
|
if !strings.Contains(body, "漏洞"+itoa(i+1)) {
|
|
t.Fatalf("第 %d 条应在本条消息里:\n%s", i+1, body)
|
|
}
|
|
}
|
|
if strings.Contains(body, "漏洞"+itoa(kept+1)) {
|
|
t.Fatalf("第 %d 条不该出现(它属于下一批)", kept+1)
|
|
}
|
|
}
|
|
|
|
func TestMarkdownBodyKeepsEverythingWhenUnderLimit(t *testing.T) {
|
|
m := batchMsg(3)
|
|
body, kept := markdownBody(m, 0) // 0 = 不限制
|
|
if kept != len(m.Items) {
|
|
t.Fatalf("不限制长度时应全部保留,得到 kept=%d", kept)
|
|
}
|
|
if strings.Contains(body, "其余") {
|
|
t.Fatalf("没有截断时不该出现截断提示:\n%s", body)
|
|
}
|
|
}
|
|
|
|
func TestMarkdownBodyAlwaysKeepsAtLeastOneItem(t *testing.T) {
|
|
// 预算小到连一条都装不下时,仍要发出一条(由最终截断兜底)。
|
|
// 否则一条超长漏洞会把整批永久卡在原地:每次领取都装不下、每次都不发。
|
|
m := batchMsg(5)
|
|
_, kept := markdownBody(m, 50)
|
|
if kept != 1 {
|
|
t.Fatalf("至少应保留 1 条,得到 %d", kept)
|
|
}
|
|
}
|
|
|
|
func TestMarkdownBodySingleReturnsOne(t *testing.T) {
|
|
_, kept := markdownBody(singleMsg(), 4096)
|
|
if kept != 1 {
|
|
t.Fatalf("单条消息应报送达 1 条,得到 %d", kept)
|
|
}
|
|
// 空消息没有可送达的条目。
|
|
if _, k := markdownBody(Message{}, 4096); k != 0 {
|
|
t.Fatalf("空消息应报 0 条,得到 %d", k)
|
|
}
|
|
}
|
|
|
|
func TestTelegramPackingUsesRuneBudget(t *testing.T) {
|
|
m := batchMsg(200)
|
|
text, kept := telegramHTML(m)
|
|
// Telegram 按**字符数**限长;用字节口径会把中文消息压到三分之一。
|
|
if n := utf8.RuneCountInString(text); n > telegramTextLimit {
|
|
t.Fatalf("正文 %d 字符超上限 %d", n, telegramTextLimit)
|
|
}
|
|
if kept <= 0 || kept >= len(m.Items) {
|
|
t.Fatalf("应只装下一部分,得到 %d", kept)
|
|
}
|
|
if !strings.Contains(text, "下一条继续") {
|
|
t.Fatalf("应说明还有余量未包含:\n%.300s", text)
|
|
}
|
|
}
|
|
|
|
func TestFeishuPackingReportsKept(t *testing.T) {
|
|
m := batchMsg(2000)
|
|
_, kept := feishuCard(m)
|
|
if kept <= 0 || kept >= len(m.Items) {
|
|
t.Fatalf("卡片应只装下一部分,得到 %d", kept)
|
|
}
|
|
}
|
|
|
|
func TestWebhookAndEmailReportAllItems(t *testing.T) {
|
|
// 这两个渠道不截断正文,整批都算送达。
|
|
m := batchMsg(7)
|
|
if n := len(m.Items); n != 7 {
|
|
t.Fatal("前置条件不成立")
|
|
}
|
|
// 通过渲染器的返回值间接确认:markdownBody(0) 不限制时全部保留。
|
|
if _, k := markdownBody(m, 0); k != len(m.Items) {
|
|
t.Fatalf("不限制长度时应用全部,得到 %d", k)
|
|
}
|
|
}
|
|
|
|
// TestMarkdownEscapesUntrustedContent 是「不可信内容不得改变消息结构」的回归测试。
|
|
// 标题与摘要来自模型输出(模型读的是被测目标响应),资产名来自被测目标的 URL。
|
|
func TestMarkdownEscapesUntrustedContent(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
item Item
|
|
must []string // 结果里必须出现(转义形态)
|
|
wrong []string // 结果里不得出现(未转义形态)
|
|
}{
|
|
{
|
|
name: "标题里的换行 + 外链",
|
|
item: Item{
|
|
Severity: "high",
|
|
Name: "登录口 SQL 注入\n[紧急:点此验证账号](http://attacker.tld)",
|
|
},
|
|
// 换行必须被折叠(否则能伪造出新的列表项/引用块);
|
|
// 方括号与圆括号必须被转义(否则是可点击的外链)。
|
|
must: []string{`\[紧急:点此验证账号\]`, `\(http://attacker.tld\)`},
|
|
wrong: []string{"\n[紧急", "\n\n[紧急"},
|
|
},
|
|
{
|
|
name: "标题里的图片信标",
|
|
item: Item{
|
|
Severity: "high",
|
|
Name: "漏洞 ",
|
|
},
|
|
must: []string{`\!`, `\(http://attacker.tld/beacon\)`},
|
|
wrong: []string{" {
|
|
m := Message{Items: []Item{tc.item}}
|
|
// 单条模式的写Item 是三个 markdown 渠道共用的渲染路径。
|
|
var b strings.Builder
|
|
writeItem(&b, tc.item, "", true)
|
|
got := b.String()
|
|
for _, want := range tc.must {
|
|
if !strings.Contains(got, want) {
|
|
t.Errorf("缺少转义形态 %q:\n%s", want, got)
|
|
}
|
|
}
|
|
for _, bad := range tc.wrong {
|
|
if strings.Contains(got, bad) {
|
|
t.Errorf("出现了未转义形态 %q(可被用来注入结构或外链):\n%s", bad, got)
|
|
}
|
|
}
|
|
_ = m
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestMarkdownEscapeBackslashFirst 锁住转义顺序:反斜杠必须最先处理,
|
|
// 否则会给后面补上的反斜杠再套一层,输出里出现双反斜杠。
|
|
func TestMarkdownEscapeBackslashFirst(t *testing.T) {
|
|
if got := markdownEscape(`a\b*c`); got != `a\\b\*c` {
|
|
t.Fatalf("转义顺序有误,得到 %q", got)
|
|
}
|
|
}
|
|
|
|
// TestTelegramTitleHasNoMarkdownEscapes 锁住一个具体的回归:
|
|
// markdown 转义不能泄漏到 Telegram 的 HTML 输出里(曾经在共享的标题函数里
|
|
// 加过转义,结果 Telegram 消息里出现 `\(1\)` 这种可见反斜杠)。
|
|
func TestTelegramTitleHasNoMarkdownEscapes(t *testing.T) {
|
|
m := Message{Items: []Item{{Severity: "high", Name: "alert(1) *重点*"}}}
|
|
text, _ := telegramHTML(m)
|
|
if strings.Contains(text, `\(`) || strings.Contains(text, `\*`) {
|
|
t.Fatalf("Telegram 正文里出现了 markdown 的反斜杠转义:\n%s", text)
|
|
}
|
|
}
|
|
|
|
func minInt(a, b int) int {
|
|
if a < b {
|
|
return a
|
|
}
|
|
return b
|
|
}
|