mirror of
https://github.com/RuoJi6/ARTEX.git
synced 2026-10-27 17:03:49 +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]>
192 lines
7.0 KiB
Go
192 lines
7.0 KiB
Go
package notify
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"net/url"
|
||
"strings"
|
||
)
|
||
|
||
// telegramTextLimit 是 Telegram sendMessage 的 text 字段上限(字符数)。
|
||
const telegramTextLimit = 4096
|
||
|
||
// telegramChannel 实现 Telegram Bot API。
|
||
//
|
||
// 平台特性:
|
||
// - 鉴权全部在 URL path 里(/bot<token>/sendMessage),无需加签。
|
||
// - 用 HTML 解析模式而不是 MarkdownV2:MarkdownV2 要求转义 `_*[]()~`>#+-=|{}.!`
|
||
// 共 18 个字符,漏一个就整条消息被拒;HTML 只需转义 & < > 三个。
|
||
// - 业务错误同样藏在 HTTP 200 里,靠 ok 字段判断。
|
||
type telegramChannel struct{}
|
||
|
||
func (telegramChannel) Kind() string { return KindTelegram }
|
||
|
||
// Telegram 单聊约 1 条/秒、群组 20 条/分钟。取保守值。
|
||
func (telegramChannel) DefaultRatePerMin() int { return 20 }
|
||
|
||
// Bot Token 是完整凭据;chat_id 只是收件人,不算秘密(拿到它没有 Token 也发不了消息)。
|
||
func (telegramChannel) SecretKeys() []string { return []string{"bot_token"} }
|
||
|
||
// base_url 决定 Token 被发往哪个 API 端点(如自建反代),改它必须重新表态 Token。
|
||
func (telegramChannel) DestinationKeys() []string { return []string{"base_url"} }
|
||
|
||
func (telegramChannel) Validate(cfg map[string]any) error {
|
||
if cfgString(cfg, "bot_token") == "" {
|
||
return errors.New("缺少 Bot Token")
|
||
}
|
||
if cfgString(cfg, "chat_id") == "" {
|
||
return errors.New("缺少 Chat ID")
|
||
}
|
||
if base := cfgString(cfg, "base_url"); base != "" {
|
||
if err := validateHTTPURL(base); err != nil {
|
||
return fmt.Errorf("API 地址无效: %w", err)
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func (c telegramChannel) Send(ctx context.Context, cfg map[string]any, m Message) (int, error) {
|
||
if err := c.Validate(cfg); err != nil {
|
||
return 0, Permanent(err)
|
||
}
|
||
endpoint, err := telegramEndpoint(cfg)
|
||
if err != nil {
|
||
return 0, Permanent(err)
|
||
}
|
||
text, kept := telegramHTML(m)
|
||
payload := map[string]any{
|
||
"chat_id": cfgString(cfg, "chat_id"),
|
||
"text": text,
|
||
"parse_mode": "HTML",
|
||
"disable_web_page_preview": false,
|
||
}
|
||
raw, err := doJSON(ctx, "POST", endpoint, nil, payload)
|
||
if err != nil {
|
||
return 0, err
|
||
}
|
||
var res struct {
|
||
OK bool `json:"ok"`
|
||
ErrorCode int `json:"error_code"`
|
||
Description string `json:"description"`
|
||
}
|
||
if err := json.Unmarshal(raw, &res); err != nil {
|
||
return 0, fmt.Errorf("解析 Telegram 响应失败: %w (%s)", err, snippet(raw))
|
||
}
|
||
if res.OK {
|
||
return kept, nil
|
||
}
|
||
// 429 是限流,退避后重试有效;其余(400 参数错、401 token 错、403 被拉黑、
|
||
// 404 chat 不存在)都是配置问题,重试不会自愈。
|
||
if res.ErrorCode == 429 {
|
||
return 0, fmt.Errorf("Telegram 限流: %s", res.Description)
|
||
}
|
||
return 0, Permanent(fmt.Errorf("Telegram 返回错误 %d: %s", res.ErrorCode, res.Description))
|
||
}
|
||
|
||
// telegramEndpoint 拼出 sendMessage 地址。base_url 留空时用官方 API,
|
||
// 非空时用于自建 Bot API 反代(国内网络下的常见需求)。
|
||
func telegramEndpoint(cfg map[string]any) (string, error) {
|
||
base := cfgString(cfg, "base_url")
|
||
if base == "" {
|
||
base = "https://api.telegram.org"
|
||
}
|
||
base = strings.TrimSuffix(base, "/")
|
||
token := cfgString(cfg, "bot_token")
|
||
raw := base + "/bot" + token + "/sendMessage"
|
||
u, err := url.Parse(raw)
|
||
if err != nil {
|
||
// 不透传 err:地址里含 Bot Token,且此时连 addr 都不该回显。
|
||
return "", fmt.Errorf("拼接 API 地址失败(API 地址:%s)", redactRequestTarget(base))
|
||
}
|
||
return u.String(), nil
|
||
}
|
||
|
||
// telegramHTML 渲染 HTML 正文,返回正文与实际写入的条目数(见 Channel.Send)。
|
||
func telegramHTML(m Message) (string, int) {
|
||
var b strings.Builder
|
||
b.WriteString("<b>" + telegramEscape(markdownTitle(m)) + "</b>\n")
|
||
if m.Batch {
|
||
// Telegram 的上限是**字符数**,所以打包也按字符计量(runeSize)。
|
||
footer := ""
|
||
if m.HomeURL != "" {
|
||
footer = fmt.Sprintf("\n\n<a href=\"%s\">在平台中查看全部</a>", telegramEscapeAttr(m.HomeURL))
|
||
}
|
||
kept := packItemCount(m.Items, telegramTextLimit, telegramReservedRunes, footer, runeSize, func(it Item, idx int) string {
|
||
return telegramBatchLine(it, idx+1)
|
||
})
|
||
items := m.Items[:kept]
|
||
b.Reset()
|
||
b.WriteString("<b>" + telegramEscape(telegramBatchTitle(m, items, len(m.Items))) + "</b>")
|
||
for i, it := range items {
|
||
b.WriteString("\n" + telegramEscape(telegramBatchLine(it, i+1)))
|
||
}
|
||
b.WriteString(footer)
|
||
return TruncateHTML(b.String(), telegramTextLimit), kept
|
||
}
|
||
if len(m.Items) == 0 {
|
||
return b.String(), 0
|
||
}
|
||
it := m.Items[0]
|
||
if it.IsStatusChange() {
|
||
b.WriteString(fmt.Sprintf("\n<b>状态变更</b>:%s → %s",
|
||
telegramEscape(StatusLabel(it.FromStatus)), telegramEscape(StatusLabel(it.ToStatus))))
|
||
}
|
||
if it.VulnClass != "" && it.VulnClass != it.Title() {
|
||
b.WriteString("\n<b>类型</b>:" + telegramEscape(it.VulnClass))
|
||
}
|
||
if a := assetLine(it.Assets, maxAssetsShown); a != "" {
|
||
b.WriteString("\n<b>资产</b>:" + telegramEscape(a))
|
||
}
|
||
if s := OneLine(it.Summary, maxSummaryRunes); s != "" {
|
||
b.WriteString("\n<b>摘要</b>:" + telegramEscape(s))
|
||
}
|
||
if it.DetailURL != "" {
|
||
b.WriteString(fmt.Sprintf("\n\n<a href=\"%s\">查看详情</a>", telegramEscapeAttr(it.DetailURL)))
|
||
}
|
||
return TruncateHTML(b.String(), telegramTextLimit), 1
|
||
}
|
||
|
||
// telegramReservedRunes 预留给消息标题与可能出现的截断提示(按字符计)。
|
||
const telegramReservedRunes = 160
|
||
|
||
// telegramBatchLine 渲染汇总里的一条(未转义,由调用方统一转义)。
|
||
func telegramBatchLine(it Item, idx int) string {
|
||
if a := assetLine(it.Assets, maxAssetsShown); a != "" {
|
||
return fmt.Sprintf("%d. %s · %s — %s", idx, SeverityLabel(it.Severity), it.Title(), a)
|
||
}
|
||
return fmt.Sprintf("%d. %s · %s", idx, SeverityLabel(it.Severity), it.Title())
|
||
}
|
||
|
||
// telegramBatchTitle 渲染汇总消息的标题行。条数用的是**本条实际包含**的条数,
|
||
// 而不是本批总数——否则读者会以为消息头写的数字就是全部。
|
||
func telegramBatchTitle(m Message, items []Item, total int) string {
|
||
title := fmt.Sprintf("漏洞汇总 · 共 %d 条", total)
|
||
if extra := total - len(items); extra > 0 {
|
||
title += fmt.Sprintf("(显示前 %d 条,其余 %d 条下一条继续)", len(items), extra)
|
||
}
|
||
if m.WindowMinutes > 0 {
|
||
title = fmt.Sprintf("近 %d 分钟 · %s", m.WindowMinutes, title)
|
||
}
|
||
return title
|
||
}
|
||
|
||
// telegramEscape 转义 HTML 文本内容。
|
||
// Telegram 只认这三种实体,转义后 & 之类的已有实体会被二次转义——这正是
|
||
// 期望行为:我们要显示的是原始字符,不是让用户注入 HTML。
|
||
func telegramEscape(s string) string {
|
||
s = strings.ReplaceAll(s, "&", "&")
|
||
s = strings.ReplaceAll(s, "<", "<")
|
||
s = strings.ReplaceAll(s, ">", ">")
|
||
return s
|
||
}
|
||
|
||
// telegramEscapeAttr 转义 HTML 属性值。在文本转义之外还要处理引号——
|
||
// URL 里带引号会提前闭合 href 属性,把后面的内容变成注入点。
|
||
func telegramEscapeAttr(s string) string {
|
||
s = telegramEscape(s)
|
||
s = strings.ReplaceAll(s, "\"", """)
|
||
return s
|
||
}
|