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]>
225 lines
7.5 KiB
Go
225 lines
7.5 KiB
Go
package notify
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"net/http"
|
||
"strings"
|
||
"text/template"
|
||
"time"
|
||
)
|
||
|
||
// webhookChannel 是通用 Webhook 适配器:用户自定 URL、方法、请求头与 JSON 模板。
|
||
// 它的存在让本功能不必为 Slack / Mattermost / Discord / 自建系统各写一个实现——
|
||
// 那些平台都能被一个可配模板覆盖。
|
||
type webhookChannel struct{}
|
||
|
||
func (webhookChannel) Kind() string { return KindWebhook }
|
||
|
||
// 通用 Webhook 没有官方限制,返回 0 表示默认不限流,由使用者按对端能力自定。
|
||
func (webhookChannel) DefaultRatePerMin() int { return 0 }
|
||
|
||
// 掩码 url 与 headers:目标地址本身常带 token,自定义头里通常放着鉴权凭据,
|
||
// 两者都会出现在接口回显里,所以都要挡。
|
||
// 代价是编辑时若想改动其中一个头,需要重新填整组头(掩码值会被解释为「保持原值」)——
|
||
// 这个取舍是刻意的:宁可多填一次,也不把凭据回显到浏览器。
|
||
func (webhookChannel) SecretKeys() []string { return []string{"url", "headers"} }
|
||
|
||
// 目的地是 url。改 url 时必须重新表态 headers —— 否则原始 Authorization 头
|
||
// 会被原样发到新地址,这正是掩码绕过的主路径。
|
||
func (webhookChannel) DestinationKeys() []string { return []string{"url"} }
|
||
|
||
// webhookDefaultTemplate 是未填模板时的兜底请求体:一个直白的 JSON 结构,
|
||
// 覆盖绝大多数「收一条 JSON 入库」的自建接收端。
|
||
const webhookDefaultTemplate = `{
|
||
"title": {{json .Title}},
|
||
"batch": {{.Batch}},
|
||
"count": {{.Count}},
|
||
"items": [
|
||
{{- range $i, $it := .Items}}
|
||
{{- if $i}},{{end}}
|
||
{
|
||
"finding_id": {{$it.FindingID}},
|
||
"name": {{json $it.Name}},
|
||
"vulnclass": {{json $it.VulnClass}},
|
||
"severity": {{json $it.Severity}},
|
||
"summary": {{json $it.Summary}},
|
||
"assets": {{json $it.Assets}},
|
||
"detail_url": {{json $it.DetailURL}}
|
||
}
|
||
{{- end}}
|
||
]
|
||
}`
|
||
|
||
// webhookTemplateData 是暴露给用户模板的上下文。
|
||
type webhookTemplateData struct {
|
||
Title string
|
||
Batch bool
|
||
Count int
|
||
Items []webhookItem
|
||
HomeURL string
|
||
// SentAt 是本次投递时间(RFC3339),供接收端记录。
|
||
SentAt string
|
||
}
|
||
|
||
type webhookItem struct {
|
||
FindingID int64
|
||
Name string
|
||
VulnClass string
|
||
Severity string
|
||
SeverityLabel string
|
||
Summary string
|
||
Assets []string
|
||
DetailURL string
|
||
FromStatus string
|
||
ToStatus string
|
||
// StatusLabel 是状态变更的可读描述,如「待处理 → 已修复」;非状态变更时为空。
|
||
StatusLabel string
|
||
}
|
||
|
||
func (webhookChannel) Validate(cfg map[string]any) error {
|
||
raw := cfgString(cfg, "url")
|
||
if raw == "" {
|
||
return errors.New("缺少目标 URL")
|
||
}
|
||
if err := validateHTTPURL(raw); err != nil {
|
||
return fmt.Errorf("目标 URL 无效: %w", err)
|
||
}
|
||
if m := strings.ToUpper(cfgString(cfg, "method")); m != "" && m != http.MethodGet && m != http.MethodPost && m != http.MethodPut && m != http.MethodPatch {
|
||
return fmt.Errorf("不支持的方法 %s(可用 GET/POST/PUT/PATCH)", m)
|
||
}
|
||
if tpl := cfgString(cfg, "body_template"); tpl != "" {
|
||
if _, err := parseWebhookTemplate(tpl); err != nil {
|
||
return fmt.Errorf("请求体模板语法错误: %w", err)
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func (c webhookChannel) Send(ctx context.Context, cfg map[string]any, m Message) (int, error) {
|
||
if err := c.Validate(cfg); err != nil {
|
||
return 0, Permanent(err)
|
||
}
|
||
method := strings.ToUpper(cfgString(cfg, "method"))
|
||
if method == "" {
|
||
method = http.MethodPost
|
||
}
|
||
|
||
// GET 不带请求体:把内容塞进 query 超出模板能力范围,也不符合 GET 语义,
|
||
// 所以 GET 只适合「命中即触发钩子」这类接收端。
|
||
var payload any
|
||
if method != http.MethodGet {
|
||
body, err := renderWebhookBody(cfgString(cfg, "body_template"), m)
|
||
if err != nil {
|
||
return 0, Permanent(err)
|
||
}
|
||
// 模板渲染出的是字符串形式的 JSON,这里转成 json.RawMessage 原样发出,
|
||
// 避免二次转义把用户精心构造的结构套进一个 JSON 字符串里。
|
||
if !json.Valid([]byte(body)) {
|
||
return 0, Permanent(errors.New("请求体模板渲染结果不是合法 JSON"))
|
||
}
|
||
payload = json.RawMessage(body)
|
||
}
|
||
|
||
headers := cfgMap(cfg, "headers")
|
||
if ct := cfgString(cfg, "content_type"); ct != "" {
|
||
// 允许覆盖,但放在 headers 之后应用,保证显式配置优先。
|
||
if headers == nil {
|
||
headers = map[string]string{}
|
||
}
|
||
headers["Content-Type"] = ct
|
||
}
|
||
if _, err := doJSON(ctx, method, cfgString(cfg, "url"), headers, payload); err != nil {
|
||
return 0, err
|
||
}
|
||
// 通用 Webhook 不截断正文(接收端是用户自己的服务,体积由 body_template 决定),
|
||
// 因此整批都算送达。
|
||
return len(m.Items), nil
|
||
}
|
||
|
||
// renderWebhookBody 用用户模板(或默认模板)渲染请求体。
|
||
func renderWebhookBody(tpl string, m Message) (string, error) {
|
||
if strings.TrimSpace(tpl) == "" {
|
||
tpl = webhookDefaultTemplate
|
||
}
|
||
t, err := parseWebhookTemplate(tpl)
|
||
if err != nil {
|
||
return "", fmt.Errorf("请求体模板语法错误: %w", err)
|
||
}
|
||
var buf bytes.Buffer
|
||
if err := t.Execute(&buf, newWebhookTemplateData(m)); err != nil {
|
||
return "", fmt.Errorf("渲染请求体模板失败: %w", err)
|
||
}
|
||
return buf.String(), nil
|
||
}
|
||
|
||
// parseWebhookTemplate 解析模板。
|
||
//
|
||
// missingkey=zero 让缺失的 map 键渲染成零值而不是报错——但本文件的上下文是结构体,
|
||
// 主要作用是让 .Items 为空时 range 不出错。真正需要防的是 .Items 为 nil。
|
||
func parseWebhookTemplate(tpl string) (*template.Template, error) {
|
||
return template.New("body").Funcs(webhookTemplateFuncs).Option("missingkey=zero").Parse(tpl)
|
||
}
|
||
|
||
// webhookTemplateFuncs 是暴露给模板的辅助函数。
|
||
var webhookTemplateFuncs = template.FuncMap{
|
||
// json 把任意值序列化成 JSON。
|
||
//
|
||
// 这个函数不是锦上添花而是必需的:省去它,用户只能写 {{.Title}} 直接插值,
|
||
// 而漏洞标题里只要有引号或换行,整段请求体就不再是合法 JSON——接收端会
|
||
// 拒收,且报错信息指向「JSON 解析失败」,完全联想不到是标题里有个引号。
|
||
"json": func(v any) (string, error) {
|
||
raw, err := json.Marshal(v)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
return string(raw), nil
|
||
},
|
||
// jsons 用于把 JSON 片段嵌进另一段 JSON 字符串值内部(做一层字符串转义)。
|
||
"jsons": func(v any) (string, error) {
|
||
raw, err := json.Marshal(v)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
quoted, err := json.Marshal(string(raw))
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
// 去掉外层引号:调用方自己决定要不要加引号。
|
||
return string(quoted[1 : len(quoted)-1]), nil
|
||
},
|
||
}
|
||
|
||
func newWebhookTemplateData(m Message) webhookTemplateData {
|
||
d := webhookTemplateData{
|
||
Title: markdownTitle(m),
|
||
Batch: m.Batch,
|
||
Count: len(m.Items),
|
||
HomeURL: m.HomeURL,
|
||
SentAt: time.Now().Format(time.RFC3339),
|
||
Items: make([]webhookItem, 0, len(m.Items)),
|
||
}
|
||
for _, it := range m.Items {
|
||
wi := webhookItem{
|
||
FindingID: it.FindingID,
|
||
Name: it.Name,
|
||
VulnClass: it.VulnClass,
|
||
Severity: it.Severity,
|
||
SeverityLabel: SeverityLabel(it.Severity),
|
||
Summary: it.Summary,
|
||
Assets: append([]string{}, it.Assets...),
|
||
DetailURL: it.DetailURL,
|
||
FromStatus: it.FromStatus,
|
||
ToStatus: it.ToStatus,
|
||
}
|
||
if it.IsStatusChange() {
|
||
wi.StatusLabel = StatusLabel(it.FromStatus) + " → " + StatusLabel(it.ToStatus)
|
||
}
|
||
d.Items = append(d.Items, wi)
|
||
}
|
||
return d
|
||
}
|