mirror of
https://github.com/RuoJi6/ARTEX.git
synced 2026-10-27 17:03:49 +08:00
对推送功能做了一轮安全审计,每条都先复现、再修、再补回归测试。 以下按严重程度排列。 【严重】掩码可被「改目标地址、保留凭据」绕过 目标地址与凭据是两套独立字段,而配置合并对「未提及的键」保留原值。于是 只改地址、对凭据避而不谈,服务端就会把库里的真凭据发到任意地址。已实测 复现四个渠道:通用 Webhook 的 Authorization 头、Telegram 的 Bot Token (进请求路径)、邮件的 SMTP 密码(STARTTLS 后交给对端)、钉钉/飞书的加签 密钥(该项不可利用,一并列出仅为说明面)。完全静默、不依赖重定向,所以 上一轮加的「拒绝跨主机跳转」挡不住它。 修法:新增 Channel.DestinationKeys() 声明「消息发往哪」,PrepareConfigUpdate 在地址变化时要求对**每一个**凭据字段显式表态(给新值,或显式留空表示不再 需要)——原样回传掩码值等于「沿用旧凭据」,正是攻击形状,一并拒绝。 刻意不做「自动丢弃凭据」:对可选字段(如 headers)那会变成「鉴权静默没了 但接口返回成功」,比报错更难排查。 【中】markdown 系三渠道对不可信内容零转义 钉钉/企微/飞书此前完全不转义(Telegram 与邮件都做了),而标题与摘要来自 模型输出、资产 url 来自被测目标。一条标题里的 `[文字](http://evil)` 会在 工程师的钉钉里变成可点击外链;`` 会被客户端拉取, 泄露阅读者 IP 并暴露「这条已被看过」。现统一单行化 + markdown 元字符转义。 修的过程中还纠了自己一个设计错误:转义一度被放进 markdownTitle,而它被 markdown 正文、Telegram 的 HTML、飞书卡片、Webhook 的 JSON 与邮件主题四种 语境共用,markdown 的反斜杠泄漏到了 Telegram 消息里(用户会看到 `\(1\)`)。 转义必须发生在各自的渲染出口——测试直接抓到了这个回归。 【中】投递地址的 SSRF 面 此前只校验 scheme 与 host,云元数据 169.254.169.254、环回、内网一律可投递; 而失败响应体前 200 字节会进 last_error 并被投递历史接口回显,构成半盲读 原语。现在在**拨号阶段**设防(最终生效点,同时覆盖 DNS 重绑定与同主机 重定向)。环回/链路本地需显式 ARTEX_NOTIFY_ALLOW_LOCAL=1 才放行(本机 SMTP 中继是合法配置);RFC1918 私网**刻意放行**——内网自建 Mattermost / SMTP 很常见,做到那个程度会把正常部署一起废掉,这条取舍写进了测试断言。 【中】汇总消息被截断后整批标记已送达 = 静默丢失 渠道都有长度上限(企微 4096 字节最紧)。此前渲染完整篇再截断、然后整批 标记成功:被截掉的那些既不在消息里、也不在失败列表里,投递历史还显示 成功。现改为按**整条**打包,只标记真正装下的,其余回队续发,消息头部 如实写明还有多少条在下一条。被推迟的条目**不消耗重试次数**(领取时乐观 +1 的那次减回去),否则 500 条的积压会在第三段就把尾部判成失败。 【中】其余修复 - min_severity 打错字让过滤器静默失效(未知级别序数为 0 → rank>=0 恒真, 用户以为「仅高危」实际全推)。改为写入时校验取值。 - rate_per_min=0(不限流)不可达:文档/UI/令牌桶都按 0=不限流解释,唯独 写库那层 `if <=0 取默认值` 把它改成 20 或 100。默认值改到接口层填。 - digest 渠道完全绕过令牌桶(allow 被扣却没人用)。 - 失败处置按整批 max(attempts) 判断,让老投递连坐同批的新投递。 - 汇总批次里快照无法解析的投递被静默标记成功。 - url.Parse 失败时错误文本仍带完整地址(上一轮只修了 http.Client.Do 那侧; 且当时的用例全走 scheme 分支、没覆盖解析失败路径,是假保证)。 - 单渠道每轮投递条数可能超出租约时长(多实例下重复发送)。加了防漂移 断言,写的时候立刻发现原取值 6 恰好用满租约、零余量,已改 5。 - Telegram 截断可能切断 HTML 实体,导致整条被拒收。 - 邮件把 SMTP 4xx(灰名单等临时失败)判成永久失败,最该重试的场景从不重试。 - 提交嵌套结构时会把掩码字面量真的写进库,导致鉴权静默失效。 测试:notify 包从 82% 提到 86%,新增 SSRF 守卫、掩码绕过(用攻击形状的 输入而非「防御逻辑的正确输入」)、分段投递收敛、markdown 转义、SMTP 4xx/5xx 分类等回归用例;端到端补了一条「60 条长标题跨多轮发完且不卡死」的收敛测试。 Co-Authored-By: Claude Code <[email protected]>
195 lines
6.2 KiB
Go
195 lines
6.2 KiB
Go
package notify
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"sort"
|
||
"strconv"
|
||
"strings"
|
||
)
|
||
|
||
// Channel 是一个通知渠道的适配器。实现必须**无状态**:同一个实例会被多个渠道
|
||
// 配置并发复用,凭据一律从 cfg 参数传入。
|
||
type Channel interface {
|
||
// Kind 返回渠道类型标识,须与注册表的键一致。
|
||
Kind() string
|
||
// Validate 在保存配置时调用,校验必填字段与格式。返回的错误会直接展示给
|
||
// 配置者,所以文案要说明「缺哪个字段」而不是泛泛的「配置无效」。
|
||
Validate(cfg map[string]any) error
|
||
// Send 投递一次消息,返回**实际送达的条目数**与错误。
|
||
//
|
||
// 为什么要返回条数:各平台都有消息长度上限,汇总消息装不下整批时会被截断。
|
||
// 若调用方无条件把整批标记为已送达,被截掉的那些条目就消失了——消息里看不到、
|
||
// 投递历史里也显示成功,没有任何地方能发现漏洞从未发出。返回 kept 后,
|
||
// 调用方只标记前 kept 条,其余留待下一批。
|
||
//
|
||
// 返回错误表示投递失败,其中 *PermanentError 表示不该重试。
|
||
// 失败时 kept 无意义,调用方应忽略它。
|
||
Send(ctx context.Context, cfg map[string]any, m Message) (int, error)
|
||
// DefaultRatePerMin 返回该渠道官方建议的每分钟投递上限,作为新建渠道实例
|
||
// 时的默认限流值。返回 0 表示无已知限制。
|
||
DefaultRatePerMin() int
|
||
// SecretKeys 返回该渠道配置里属于凭据的键名。API 回显时这些键的值会被掩码,
|
||
// 更新时收到掩码值则保留库中的原值。只有实现自己清楚哪些字段算凭据
|
||
// (企业微信的整个 Webhook 地址就是凭据,而钉钉的只是其中的 secret),
|
||
// 所以这个知识必须由渠道提供,不能由上层猜测。
|
||
SecretKeys() []string
|
||
// DestinationKeys 返回该渠道配置里决定「消息发往哪里」的键名。
|
||
//
|
||
// 与 SecretKeys 一样是安全相关的东西:目标地址与凭据是两套独立字段,
|
||
// 若允许「只改地址、凭据原样保留」,任何能改渠道配置的人都能把库里的真凭据
|
||
// 发到自己控制的服务器,渠道配置的掩码就完全失去意义。
|
||
// 详见 PrepareConfigUpdate。
|
||
DestinationKeys() []string
|
||
}
|
||
|
||
// registry 是渠道注册表。刻意用显式字面量而不是 init() 自注册:这样「有哪些渠道」
|
||
// 在一个地方就能看全,且新增渠道会在编译期暴露遗漏,而不是靠运行时副作用。
|
||
var registry = map[string]Channel{
|
||
KindDingTalk: dingTalkChannel{},
|
||
KindFeishu: feishuChannel{},
|
||
KindWeCom: weComChannel{},
|
||
KindWebhook: webhookChannel{},
|
||
KindTelegram: telegramChannel{},
|
||
KindEmail: emailChannel{},
|
||
}
|
||
|
||
// Get 按类型取渠道实现。
|
||
func Get(kind string) (Channel, bool) {
|
||
c, ok := registry[kind]
|
||
return c, ok
|
||
}
|
||
|
||
// ValidKind 报告 kind 是否为受支持的渠道类型。
|
||
func ValidKind(kind string) bool {
|
||
_, ok := registry[kind]
|
||
return ok
|
||
}
|
||
|
||
// Kinds 返回全部受支持的渠道类型,按字典序排列(供 UI 下拉稳定展示)。
|
||
func Kinds() []string {
|
||
out := make([]string, 0, len(registry))
|
||
for k := range registry {
|
||
out = append(out, k)
|
||
}
|
||
sort.Strings(out)
|
||
return out
|
||
}
|
||
|
||
// PermanentError 标记一个不该重试的投递失败:凭据错误、目标拒绝、请求体非法等。
|
||
// 重试只对瞬时故障(网络抖动、限流、对端 5xx)有意义;对永久失败反复退避重试
|
||
// 既不会成功,又会把真正的错误刷没在重试日志里。
|
||
type PermanentError struct{ Err error }
|
||
|
||
func (e *PermanentError) Error() string { return e.Err.Error() }
|
||
func (e *PermanentError) Unwrap() error { return e.Err }
|
||
|
||
// Permanent 把 err 标记为永久失败。err 为 nil 时返回 nil,
|
||
// 方便写成 `return Permanent(someCheck())`。
|
||
func Permanent(err error) error {
|
||
if err == nil {
|
||
return nil
|
||
}
|
||
return &PermanentError{Err: err}
|
||
}
|
||
|
||
// IsPermanent 报告 err 链上是否带有永久失败标记。
|
||
func IsPermanent(err error) bool {
|
||
var pe *PermanentError
|
||
return errors.As(err, &pe)
|
||
}
|
||
|
||
// ---- 配置读取helper ----
|
||
//
|
||
// 渠道配置来自数据库的 JSONB 列,经 encoding/json 反序列化后是 map[string]any,
|
||
// 数值一律是 float64、数组是 []any。下面这些 helper 统一这层转换,并容忍用户
|
||
// 在 UI 里留空导致的类型偏差(如把端口填成字符串)。
|
||
|
||
// cfgString 取字符串配置项,前后空白一律裁掉——从网页表单复制粘贴很容易带上。
|
||
func cfgString(cfg map[string]any, key string) string {
|
||
v, ok := cfg[key]
|
||
if !ok {
|
||
return ""
|
||
}
|
||
s, ok := v.(string)
|
||
if !ok {
|
||
return ""
|
||
}
|
||
return strings.TrimSpace(s)
|
||
}
|
||
|
||
// cfgInt 取整数配置项,兼容 float64(JSON 默认)与字符串两种来源。
|
||
func cfgInt(cfg map[string]any, key string) int {
|
||
switch v := cfg[key].(type) {
|
||
case float64:
|
||
return int(v)
|
||
case int:
|
||
return v
|
||
case string:
|
||
n, err := strconv.Atoi(strings.TrimSpace(v))
|
||
if err != nil {
|
||
return 0
|
||
}
|
||
return n
|
||
default:
|
||
return 0
|
||
}
|
||
}
|
||
|
||
// cfgBool 取布尔配置项,兼容字符串 "true"/"1"。
|
||
func cfgBool(cfg map[string]any, key string) bool {
|
||
switch v := cfg[key].(type) {
|
||
case bool:
|
||
return v
|
||
case string:
|
||
s := strings.ToLower(strings.TrimSpace(v))
|
||
return s == "true" || s == "1" || s == "yes"
|
||
default:
|
||
return false
|
||
}
|
||
}
|
||
|
||
// cfgStrings 取字符串数组配置项,自动裁空白并丢弃空串。
|
||
func cfgStrings(cfg map[string]any, key string) []string {
|
||
raw, ok := cfg[key].([]any)
|
||
if !ok {
|
||
// 也接受单个字符串,方便只有一个值时的表单提交。
|
||
if s := cfgString(cfg, key); s != "" {
|
||
return []string{s}
|
||
}
|
||
return nil
|
||
}
|
||
out := make([]string, 0, len(raw))
|
||
for _, v := range raw {
|
||
s, ok := v.(string)
|
||
if !ok {
|
||
continue
|
||
}
|
||
if s = strings.TrimSpace(s); s != "" {
|
||
out = append(out, s)
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
// cfgMap 取字符串映射配置项(如自定义 HTTP 头),键值都裁空白,丢弃空键。
|
||
func cfgMap(cfg map[string]any, key string) map[string]string {
|
||
raw, ok := cfg[key].(map[string]any)
|
||
if !ok {
|
||
return nil
|
||
}
|
||
out := make(map[string]string, len(raw))
|
||
for k, v := range raw {
|
||
k = strings.TrimSpace(k)
|
||
if k == "" {
|
||
continue
|
||
}
|
||
s, ok := v.(string)
|
||
if !ok {
|
||
continue
|
||
}
|
||
out[k] = s
|
||
}
|
||
return out
|
||
}
|