Files
ARTEX/server/notify_api.go
T
luckoneandClaude Code a049f09b97 fix(notify): 安全审计修复 —— 掩码绕过、SSRF、静默丢失等 12 项
对推送功能做了一轮安全审计,每条都先复现、再修、再补回归测试。
以下按严重程度排列。

【严重】掩码可被「改目标地址、保留凭据」绕过
目标地址与凭据是两套独立字段,而配置合并对「未提及的键」保留原值。于是
只改地址、对凭据避而不谈,服务端就会把库里的真凭据发到任意地址。已实测
复现四个渠道:通用 Webhook 的 Authorization 头、Telegram 的 Bot Token
(进请求路径)、邮件的 SMTP 密码(STARTTLS 后交给对端)、钉钉/飞书的加签
密钥(该项不可利用,一并列出仅为说明面)。完全静默、不依赖重定向,所以
上一轮加的「拒绝跨主机跳转」挡不住它。

修法:新增 Channel.DestinationKeys() 声明「消息发往哪」,PrepareConfigUpdate
在地址变化时要求对**每一个**凭据字段显式表态(给新值,或显式留空表示不再
需要)——原样回传掩码值等于「沿用旧凭据」,正是攻击形状,一并拒绝。
刻意不做「自动丢弃凭据」:对可选字段(如 headers)那会变成「鉴权静默没了
但接口返回成功」,比报错更难排查。

【中】markdown 系三渠道对不可信内容零转义
钉钉/企微/飞书此前完全不转义(Telegram 与邮件都做了),而标题与摘要来自
模型输出、资产 url 来自被测目标。一条标题里的 `[文字](http://evil)` 会在
工程师的钉钉里变成可点击外链;`![](http://evil/beacon)` 会被客户端拉取,
泄露阅读者 IP 并暴露「这条已被看过」。现统一单行化 + markdown 元字符转义。

修的过程中还纠了自己一个设计错误:转义一度被放进 markdownTitle,而它被
markdown 正文、Telegram 的 HTML、飞书卡片、Webhook 的 JSON 与邮件主题四种
语境共用,markdown 的反斜杠泄漏到了 Telegram 消息里(用户会看到 `\(1\)`)。
转义必须发生在各自的渲染出口——测试直接抓到了这个回归。

【中】投递地址的 SSRF 面
此前只校验 scheme 与 host,云元数据 169.254.169.254、环回、内网一律可投递;
而失败响应体前 200 字节会进 last_error 并被投递历史接口回显,构成半盲读
原语。现在在**拨号阶段**设防(最终生效点,同时覆盖 DNS 重绑定与同主机
重定向)。环回/链路本地需显式 ARTEX_NOTIFY_ALLOW_LOCAL=1 才放行(本机
SMTP 中继是合法配置);RFC1918 私网**刻意放行**——内网自建 Mattermost /
SMTP 很常见,做到那个程度会把正常部署一起废掉,这条取舍写进了测试断言。

【中】汇总消息被截断后整批标记已送达 = 静默丢失
渠道都有长度上限(企微 4096 字节最紧)。此前渲染完整篇再截断、然后整批
标记成功:被截掉的那些既不在消息里、也不在失败列表里,投递历史还显示
成功。现改为按**整条**打包,只标记真正装下的,其余回队续发,消息头部
如实写明还有多少条在下一条。被推迟的条目**不消耗重试次数**(领取时乐观
+1 的那次减回去),否则 500 条的积压会在第三段就把尾部判成失败。

【中】其余修复
- min_severity 打错字让过滤器静默失效(未知级别序数为 0 → rank>=0 恒真,
  用户以为「仅高危」实际全推)。改为写入时校验取值。
- rate_per_min=0(不限流)不可达:文档/UI/令牌桶都按 0=不限流解释,唯独
  写库那层 `if <=0 取默认值` 把它改成 20 或 100。默认值改到接口层填。
- digest 渠道完全绕过令牌桶(allow 被扣却没人用)。
- 失败处置按整批 max(attempts) 判断,让老投递连坐同批的新投递。
- 汇总批次里快照无法解析的投递被静默标记成功。
- url.Parse 失败时错误文本仍带完整地址(上一轮只修了 http.Client.Do 那侧;
  且当时的用例全走 scheme 分支、没覆盖解析失败路径,是假保证)。
- 单渠道每轮投递条数可能超出租约时长(多实例下重复发送)。加了防漂移
  断言,写的时候立刻发现原取值 6 恰好用满租约、零余量,已改 5。
- Telegram 截断可能切断 HTML 实体,导致整条被拒收。
- 邮件把 SMTP 4xx(灰名单等临时失败)判成永久失败,最该重试的场景从不重试。
- 提交嵌套结构时会把掩码字面量真的写进库,导致鉴权静默失效。

测试:notify 包从 82% 提到 86%,新增 SSRF 守卫、掩码绕过(用攻击形状的
输入而非「防御逻辑的正确输入」)、分段投递收敛、markdown 转义、SMTP 4xx/5xx
分类等回归用例;端到端补了一条「60 条长标题跨多轮发完且不卡死」的收敛测试。

Co-Authored-By: Claude Code <[email protected]>
2026-09-30 16:25:06 +08:00

516 lines
15 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package server
import (
"encoding/json"
"errors"
"fmt"
"net/http"
"strings"
"time"
"github.com/Autumn-27/artex/db"
"github.com/Autumn-27/artex/notify"
)
// 本文件是推送功能的 HTTP 接口。全部路由挂在 requireAuth 之后(见 Handler()),
// 与其它管理接口一致。
// notifyChannelDTO 是渠道的对外表述。
//
// Config 是**掩码后**的配置:凭据字段被替换成 notify.MaskedPrefix 开头的值。
// 前端把掩码值原样提交回来即表示「这个字段没改」,服务端据此保留库中原值
// (见 notify.MergeConfig)。
type notifyChannelDTO struct {
ID int64 `json:"id"`
Name string `json:"name"`
Kind string `json:"kind"`
Enabled bool `json:"enabled"`
Mode string `json:"mode"`
Config map[string]any `json:"config"`
Filter notify.Filter `json:"filter"`
RatePerMin int `json:"rate_per_min"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
// SecretKeys 告知前端哪些字段是凭据,据此渲染密码框与「留空即不改」的提示。
// 由渠道自己声明(notify.Channel.SecretKeys),前端不硬编码渠道知识。
SecretKeys []string `json:"secret_keys"`
}
// notifyDeliveryDTO 是投递历史的对外表述。
type notifyDeliveryDTO struct {
ID int64 `json:"id"`
FindingID int64 `json:"finding_id,string"`
EventKind string `json:"event_kind"`
ChannelID int64 `json:"channel_id"`
ChannelName string `json:"channel_name"`
ChannelKind string `json:"channel_kind"`
State string `json:"state"`
Attempts int `json:"attempts"`
LastError string `json:"last_error"`
BatchID *int64 `json:"batch_id,omitempty"`
CreatedAt time.Time `json:"created_at"`
SentAt *time.Time `json:"sent_at,omitempty"`
NextAttempt time.Time `json:"next_attempt_at"`
// 消息标题摘要,让历史列表不必展开就能看出这条推的是什么。
Title string `json:"title"`
Severity string `json:"severity"`
}
func toNotifyChannelDTO(ch *db.NotificationChannel) notifyChannelDTO {
var cfg map[string]any
if len(ch.Config) > 0 {
_ = json.Unmarshal(ch.Config, &cfg)
}
if cfg == nil {
cfg = map[string]any{}
}
secrets := []string{}
if c, ok := notify.Get(ch.Kind); ok {
secrets = c.SecretKeys()
}
return notifyChannelDTO{
ID: ch.ID,
Name: ch.Name,
Kind: ch.Kind,
Enabled: ch.IsEnabled(),
Mode: ch.Mode,
Config: notify.MaskConfig(ch.Kind, cfg),
Filter: notify.ParseFilter(ch.Filter),
RatePerMin: ch.RatePerMin,
CreatedAt: ch.CreatedAt,
UpdatedAt: ch.UpdatedAt,
SecretKeys: secrets,
}
}
func toNotifyDeliveryDTO(dl *db.NotificationDelivery) notifyDeliveryDTO {
snap, _ := parseSnapshot(dl)
dto := notifyDeliveryDTO{
ID: dl.ID,
FindingID: dl.FindingID,
EventKind: dl.EventKind,
ChannelID: dl.ChannelID,
ChannelName: dl.ChannelName,
ChannelKind: dl.ChannelKind,
State: dl.State,
Attempts: dl.Attempts,
LastError: dl.LastError,
BatchID: dl.BatchID,
CreatedAt: dl.CreatedAt,
SentAt: dl.SentAt,
NextAttempt: dl.NextAttemptAt,
Severity: snap.Severity,
}
if snap.Name != "" {
dto.Title = snap.Name
} else {
dto.Title = snap.VulnClass
}
return dto
}
// notifyMeta 返回通知页需要的静态元数据与全局设置,一次请求拿全,
// 避免前端为了渲染一个下拉框发三次请求。
func (s *Server) notifyMeta(w http.ResponseWriter, r *http.Request) {
pg := s.pg(w)
if pg == nil {
return
}
kinds := make([]map[string]any, 0, len(notify.Kinds()))
for _, k := range notify.Kinds() {
ch, _ := notify.Get(k)
kinds = append(kinds, map[string]any{
"kind": k,
"default_rate_per_min": ch.DefaultRatePerMin(),
"secret_keys": ch.SecretKeys(),
})
}
baseURL, _, _ := pg.GetSetting(settingNotifyPublicBaseURL)
digest, _, _ := pg.GetSetting(settingNotifyDigestMinutes)
stats, err := pg.NotificationStatsSnapshot(r.Context())
if err != nil {
writeErr(w, 500, err.Error())
return
}
writeJSON(w, 200, map[string]any{
"kinds": kinds,
"enabled": pg.GetBool(settingNotifyEnabled, true),
"public_base_url": baseURL,
"digest_interval_min": digest,
"defaults": map[string]any{
"digest_interval_min": notifyDefaultDigestMinutes,
},
"stats": stats,
})
}
func (s *Server) notifyListChannels(w http.ResponseWriter, r *http.Request) {
pg := s.pg(w)
if pg == nil {
return
}
channels, err := pg.ListNotificationChannels(r.Context())
if err != nil {
writeErr(w, 500, err.Error())
return
}
out := make([]notifyChannelDTO, 0, len(channels))
for _, ch := range channels {
out = append(out, toNotifyChannelDTO(ch))
}
writeJSON(w, 200, map[string]any{"channels": out})
}
// notifyChannelRequest 是新建/更新渠道的请求体。
//
// 全部业务字段用指针,以便区分「没传」与「传了零值」:PATCH 语义下,
// 没传的字段必须保留库中原值。
type notifyChannelRequest struct {
Name *string `json:"name"`
Kind *string `json:"kind"`
Enabled *bool `json:"enabled"`
Mode *string `json:"mode"`
Config map[string]any `json:"config"`
Filter *notify.Filter `json:"filter"`
RatePerMin *int `json:"rate_per_min"`
}
func (s *Server) notifyCreateChannel(w http.ResponseWriter, r *http.Request) {
pg := s.pg(w)
if pg == nil {
return
}
var req notifyChannelRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeErr(w, 400, "请求体不是合法 JSON: "+err.Error())
return
}
if req.Kind == nil || !notify.ValidKind(*req.Kind) {
writeErr(w, 400, fmt.Sprintf("渠道类型无效,可选:%s", strings.Join(notify.Kinds(), " / ")))
return
}
name := ""
if req.Name != nil {
name = strings.TrimSpace(*req.Name)
}
if name == "" {
writeErr(w, 400, "缺少渠道名称")
return
}
channel, _ := notify.Get(*req.Kind)
if err := channel.Validate(req.Config); err != nil {
writeErr(w, 400, err.Error())
return
}
ch := &db.NotificationChannel{
Name: name,
Kind: *req.Kind,
Enabled: req.Enabled,
Mode: db.NotifyModeRealtime,
RatePerMin: channel.DefaultRatePerMin(),
}
if req.Mode != nil {
if !db.ValidNotifyMode(*req.Mode) {
writeErr(w, 400, "推送模式无效,可选:realtime / digest")
return
}
ch.Mode = *req.Mode
}
if req.RatePerMin != nil {
// 显式给值就照用——包括 0,它表示「不限流」,是合法配置。
if *req.RatePerMin < 0 {
writeErr(w, 400, "限流值不能为负")
return
}
ch.RatePerMin = *req.RatePerMin
}
// 只有「字段缺省」才套用渠道默认值。默认值必须在这里决定而不是在 db 层:
// 只有请求体能区分「没传这个字段」与「显式传了 0」,而两者的含义完全不同
// (前者=用默认,后者=不限流)。db 层把 0 也当未指定,会让不限流配置不可达。
if req.RatePerMin == nil {
ch.RatePerMin = channel.DefaultRatePerMin()
}
if req.Filter != nil {
// 写入时校验取值受限的过滤字段(如 min_severity)。详见 notify.Filter.Validate:
// 门槛打错字会让过滤器静默失效变成全推,必须在入口拦掉。
if err := req.Filter.Validate(); err != nil {
writeErr(w, 400, err.Error())
return
}
raw, _ := json.Marshal(req.Filter)
ch.Filter = raw
}
rawCfg, _ := json.Marshal(req.Config)
ch.Config = rawCfg
id, err := pg.SaveNotificationChannel(r.Context(), ch)
if err != nil {
writeErr(w, 500, err.Error())
return
}
writeJSON(w, 200, map[string]any{"id": id})
}
func (s *Server) notifyUpdateChannel(w http.ResponseWriter, r *http.Request) {
pg := s.pg(w)
if pg == nil {
return
}
id, ok := pathInt(r, "id")
if !ok {
writeErr(w, 400, "渠道 id 无效")
return
}
current, err := pg.NotificationChannelByID(r.Context(), id)
if err != nil {
notifyChannelLookupErr(w, err)
return
}
var req notifyChannelRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeErr(w, 400, "请求体不是合法 JSON: "+err.Error())
return
}
// kind 允许修改,但改类型意味着凭据字段整套替换,不能与旧配置合并。
kind := current.Kind
if req.Kind != nil {
if !notify.ValidKind(*req.Kind) {
writeErr(w, 400, fmt.Sprintf("渠道类型无效,可选:%s", strings.Join(notify.Kinds(), " / ")))
return
}
kind = *req.Kind
}
channel, _ := notify.Get(kind)
var stored map[string]any
if kind == current.Kind {
if len(current.Config) > 0 {
_ = json.Unmarshal(current.Config, &stored)
}
}
if stored == nil {
stored = map[string]any{}
}
// 用 PrepareConfigUpdate 而不是裸的 MergeConfig:目标地址变更时必须让操作者
// 对凭据字段重新表态,否则「只改地址、凭据沿用」会把库里的真凭据发到新地址。
merged, err := notify.PrepareConfigUpdate(kind, stored, req.Config)
if err != nil {
writeErr(w, 400, err.Error())
return
}
if err := channel.Validate(merged); err != nil {
writeErr(w, 400, err.Error())
return
}
rawCfg, _ := json.Marshal(merged)
ch := &db.NotificationChannel{
ID: id,
Name: current.Name,
Kind: kind,
Enabled: current.Enabled,
Mode: current.Mode,
Config: rawCfg,
Filter: current.Filter,
RatePerMin: current.RatePerMin,
}
if req.Name != nil {
if ch.Name = strings.TrimSpace(*req.Name); ch.Name == "" {
writeErr(w, 400, "渠道名称不能为空")
return
}
}
if req.Enabled != nil {
ch.Enabled = req.Enabled
}
if req.Mode != nil {
if !db.ValidNotifyMode(*req.Mode) {
writeErr(w, 400, "推送模式无效,可选:realtime / digest")
return
}
ch.Mode = *req.Mode
}
if req.RatePerMin != nil {
if *req.RatePerMin < 0 {
writeErr(w, 400, "限流值不能为负")
return
}
ch.RatePerMin = *req.RatePerMin
}
if req.Filter != nil {
if err := req.Filter.Validate(); err != nil {
writeErr(w, 400, err.Error())
return
}
raw, _ := json.Marshal(req.Filter)
ch.Filter = raw
}
// 走 SetNotificationChannelEnabled 而非 SaveNotificationChannel 的路径,
// 是为了让「停用」同时把存量待发投递标记为 skipped,避免重新启用时收到
// 一批已过时的积压消息。
enabledChanged := ch.Enabled != nil && current.Enabled != nil && *ch.Enabled != *current.Enabled
if enabledChanged {
// 先把配置更新落库(此时 enabled 用旧值,避免提前触发跳过逻辑),
// 再单独切开关。两步之间没有并发窗口:本接口是唯一改这两个字段的入口。
prev := ch.Enabled
ch.Enabled = current.Enabled
if _, err := pg.SaveNotificationChannel(r.Context(), ch); err != nil {
writeErr(w, 500, err.Error())
return
}
if err := pg.SetNotificationChannelEnabled(r.Context(), id, *prev); err != nil {
writeErr(w, 500, err.Error())
return
}
writeJSON(w, 200, map[string]any{"id": id})
return
}
if _, err := pg.SaveNotificationChannel(r.Context(), ch); err != nil {
writeErr(w, 500, err.Error())
return
}
writeJSON(w, 200, map[string]any{"id": id})
}
func (s *Server) notifyDeleteChannel(w http.ResponseWriter, r *http.Request) {
pg := s.pg(w)
if pg == nil {
return
}
id, ok := pathInt(r, "id")
if !ok {
writeErr(w, 400, "渠道 id 无效")
return
}
if err := pg.DeleteNotificationChannel(r.Context(), id); err != nil {
notifyChannelLookupErr(w, err)
return
}
writeJSON(w, 200, map[string]any{"ok": true})
}
// notifyTestChannel 用当前保存的配置发一条测试消息。
//
// 直接调用渠道 Send 而不经投递队列:测试的目的是立刻告诉用户「这套配置能不能
// 发出去」,走队列会把结果藏进投递历史,用户得再去翻一遍才知道成没成。
// 因此本接口是**同步**的,超时上限由 notify 包的 HTTP 客户端决定(15 秒)。
func (s *Server) notifyTestChannel(w http.ResponseWriter, r *http.Request) {
pg := s.pg(w)
if pg == nil {
return
}
id, ok := pathInt(r, "id")
if !ok {
writeErr(w, 400, "渠道 id 无效")
return
}
ch, err := pg.NotificationChannelByID(r.Context(), id)
if err != nil {
notifyChannelLookupErr(w, err)
return
}
channel, ok := notify.Get(ch.Kind)
if !ok {
writeErr(w, 400, fmt.Sprintf("渠道类型 %q 未注册", ch.Kind))
return
}
var cfg map[string]any
if len(ch.Config) > 0 {
_ = json.Unmarshal(ch.Config, &cfg)
}
if err := channel.Validate(cfg); err != nil {
writeErr(w, 400, err.Error())
return
}
msg := notifyTestMessage(s.notifierBaseURL(pg))
start := time.Now()
// 测试消息只有一条,送达条数这里不需要(渠道长度上限对单条消息而言
// 由截断兜底,不涉及分段)。
if _, err := channel.Send(r.Context(), cfg, msg); err != nil {
// 把渠道返回的原始错误如实回给用户——这是他们调试配置的唯一线索。
writeErr(w, 502, err.Error())
return
}
writeJSON(w, 200, map[string]any{
"ok": true,
"latency_ms": time.Since(start).Milliseconds(),
})
}
// notifyTestMessage 构造测试消息。刻意用一眼能看出是测试的内容:
// 收到的人不应该把它误判成真实漏洞。
func notifyTestMessage(baseURL string) notify.Message {
return notify.Message{
Items: []notify.Item{{
FindingID: 0,
Name: "测试消息 · 渠道配置正常",
VulnClass: "连通性测试",
Severity: "low",
Summary: "这是 ARTEX 推送渠道的测试消息,收到即表示该渠道配置可用。",
Assets: []string{"artex.example.com"},
DetailURL: baseURL,
}},
HomeURL: baseURL,
}
}
// notifierBaseURL 读回链用的外部地址。
func (s *Server) notifierBaseURL(pg *db.DB) string {
v, _, _ := pg.GetSetting(settingNotifyPublicBaseURL)
return trimTrailingSlash(v)
}
func (s *Server) notifyListDeliveries(w http.ResponseWriter, r *http.Request) {
pg := s.pg(w)
if pg == nil {
return
}
f := db.NotificationDeliveryFilter{
State: r.URL.Query().Get("state"),
EventKind: r.URL.Query().Get("event_kind"),
}
if v := r.URL.Query().Get("channel_id"); v != "" {
f.ChannelID = int64(atoiDefault(v, 0))
}
page := queryInt(r, "page", 1)
pageSize := queryInt(r, "page_size", 50)
items, total, err := pg.ListNotificationDeliveries(r.Context(), f, page, pageSize)
if err != nil {
writeErr(w, 500, err.Error())
return
}
out := make([]notifyDeliveryDTO, 0, len(items))
for _, dl := range items {
out = append(out, toNotifyDeliveryDTO(dl))
}
writeJSON(w, 200, map[string]any{"deliveries": out, "total": total, "page": page, "page_size": pageSize})
}
func (s *Server) notifyRetryDelivery(w http.ResponseWriter, r *http.Request) {
pg := s.pg(w)
if pg == nil {
return
}
id, ok := pathInt(r, "id")
if !ok {
writeErr(w, 400, "投递 id 无效")
return
}
if err := pg.RetryNotificationDelivery(r.Context(), id); err != nil {
writeErr(w, 400, err.Error())
return
}
writeJSON(w, 200, map[string]any{"ok": true})
}
// notifyChannelLookupErr 把「渠道不存在」翻译成 404,其余错误 500。
func notifyChannelLookupErr(w http.ResponseWriter, err error) {
if errors.Is(err, db.ErrNotificationChannelNotFound) {
writeErr(w, 404, "通知渠道不存在")
return
}
writeErr(w, 500, err.Error())
}