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
+11
-6
@@ -128,12 +128,17 @@ func (d *DB) SaveNotificationChannel(ctx context.Context, c *NotificationChannel
|
||||
if c.Mode == "" {
|
||||
c.Mode = NotifyModeRealtime
|
||||
}
|
||||
if c.RatePerMin <= 0 {
|
||||
// 0 表示不限流,是合法配置;但未指定时给一个安全默认值,
|
||||
// 避免新渠道默认就把平台打限流。
|
||||
if ch, ok := notify.Get(c.Kind); ok {
|
||||
c.RatePerMin = ch.DefaultRatePerMin()
|
||||
}
|
||||
// 这里刻意**不**对 0 做任何加工:0 是合法配置,含义是「不限流」。
|
||||
//
|
||||
// 曾经写成 `if c.RatePerMin <= 0 { c.RatePerMin = 默认值 }`,本意是「未指定时
|
||||
// 给个安全默认」,但那把「显式设成 0」也一起吞掉了——文档、UI 提示与
|
||||
// takeTokens 都把 0 解释为不限流,唯独这里悄悄改成 20(钉钉/企微/Telegram)
|
||||
// 或 100(飞书),操作者以为放开了限流、实际被 20/分钟卡着且没有任何提示。
|
||||
//
|
||||
// 「未指定」与「显式 0」的区别只有调用方知道(请求体里字段缺省 vs 明确传 0),
|
||||
// 所以默认值由 server 层在字段缺省时填,见 notifyCreateChannel。
|
||||
if c.RatePerMin < 0 {
|
||||
return 0, errors.New("限流值不能为负")
|
||||
}
|
||||
if c.Config == nil {
|
||||
c.Config = json.RawMessage(`{}`)
|
||||
|
||||
@@ -167,7 +167,16 @@ func (d *DB) DigestBatchDue(ctx context.Context, channelID int64, minAge time.Du
|
||||
//
|
||||
// 按 id 升序取前 N 条而非随机取:最早产生的投递最先发出去,积压时不会出现
|
||||
// 「新漏洞先发、老漏洞永远排在后面」的饥饿。
|
||||
func (d *DB) ClaimDigestBatch(ctx context.Context, channelID int64, lease time.Duration) ([]*NotificationDelivery, error) {
|
||||
func (d *DB) ClaimDigestBatch(ctx context.Context, channelID int64, limit int, lease time.Duration) ([]*NotificationDelivery, error) {
|
||||
if limit <= 0 {
|
||||
return nil, nil
|
||||
}
|
||||
// 单批同时受两个上界约束:调用方的限流额度(本轮还能发几条)与内存上界
|
||||
// MaxDigestBatchSize。此前只用了后者,于是 digest 渠道的 rate_per_min
|
||||
// 完全不生效——令牌被 takeTokens 扣掉却没人用,等于白扣。
|
||||
if limit > MaxDigestBatchSize {
|
||||
limit = MaxDigestBatchSize
|
||||
}
|
||||
out, err := d.claimDeliveries(ctx, lease, claimQuery{
|
||||
sql: `SELECT dd.id FROM notification_deliveries dd
|
||||
JOIN notification_channels c ON c.id = dd.channel_id
|
||||
@@ -175,7 +184,7 @@ WHERE dd.channel_id = $1 AND dd.state IN ($2,$3) AND dd.next_attempt_at <= now()
|
||||
ORDER BY dd.id
|
||||
FOR UPDATE OF dd SKIP LOCKED
|
||||
LIMIT $4`,
|
||||
args: []any{channelID, NotifyStatePending, NotifyStateSending, MaxDigestBatchSize},
|
||||
args: []any{channelID, NotifyStatePending, NotifyStateSending, limit},
|
||||
}, func(tx *sql.Tx, ids []int64) error {
|
||||
batchID := ids[0]
|
||||
for _, id := range ids {
|
||||
@@ -290,6 +299,27 @@ WHERE id IN (`+ph+`)`,
|
||||
return err
|
||||
}
|
||||
|
||||
// DeferDeliveries 把一批投递退回 pending、立即可再领,并**撤销领取时计的那一次尝试**。
|
||||
//
|
||||
// 用途只有一个:汇总消息按渠道长度上限分段发送时,没装进本条的条目要留到下一批。
|
||||
// 那不是失败,所以不该消耗重试预算——领取时 attempts 已经乐观地 +1 了,
|
||||
// 这里必须减回去。否则一个 500 条的积压会按每段 20 条切成 25 段,
|
||||
// 尾部条目在第 3 段就被 MaxNotifyAttempts 判成 failed,而它们从未出过任何错。
|
||||
//
|
||||
// GREATEST(...,0) 兜住「有人手工重发把 attempts 清零后又走到这里」的情况,
|
||||
// 不让计数变成负数。
|
||||
func (d *DB) DeferDeliveries(ctx context.Context, ids []int64, reason string) error {
|
||||
ph, args := placeholders(3, ids)
|
||||
if len(args) == 0 {
|
||||
return nil
|
||||
}
|
||||
_, err := d.ExecContext(ctx, `UPDATE notification_deliveries
|
||||
SET state=$1, attempts=GREATEST(attempts-1, 0), next_attempt_at=now(), last_error=$2
|
||||
WHERE id IN (`+ph+`)`,
|
||||
append([]any{NotifyStatePending, truncateNotifyError(reason)}, args...)...)
|
||||
return err
|
||||
}
|
||||
|
||||
// FailDeliveries 把一批投递标记为最终失败,等待人工在投递历史里重发。
|
||||
func (d *DB) FailDeliveries(ctx context.Context, ids []int64, errMsg string) error {
|
||||
// 占位符从 $3 开始:$1 是 state、$2 是 last_error。
|
||||
|
||||
+54
-10
@@ -366,7 +366,7 @@ func TestDigestBatchDueAndStableBatchID(t *testing.T) {
|
||||
t.Fatal("超过周期的批次应判定为到期")
|
||||
}
|
||||
|
||||
batch, err := d.ClaimDigestBatch(ctx, ch.ID, time.Minute)
|
||||
batch, err := d.ClaimDigestBatch(ctx, ch.ID, MaxDigestBatchSize, time.Minute)
|
||||
if err != nil {
|
||||
t.Fatalf("领取汇总批次失败: %v", err)
|
||||
}
|
||||
@@ -400,7 +400,7 @@ func TestDigestBatchDueAndStableBatchID(t *testing.T) {
|
||||
if _, err := d.Exec(`UPDATE notification_deliveries SET next_attempt_at = now() - interval '1 minute' WHERE channel_id=$1`, ch.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reclaimed, err := d.ClaimDigestBatch(ctx, ch.ID, time.Minute)
|
||||
reclaimed, err := d.ClaimDigestBatch(ctx, ch.ID, MaxDigestBatchSize, time.Minute)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -695,16 +695,24 @@ func TestNotificationChannelCRUDRoundTrip(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestSaveNotificationChannelDefaultsRate 覆盖「未指定限流时套用渠道默认值」。
|
||||
// 漏掉这一步会让新渠道默认不限流,直接把平台打到限流。
|
||||
func TestSaveNotificationChannelDefaultsRate(t *testing.T) {
|
||||
// TestSaveNotificationChannelKeepsExplicitZeroRate 锁住一个曾经写错的地方:
|
||||
// **0 是合法配置,含义是「不限流」,不能被 db 层当成「未指定」覆盖成默认值**。
|
||||
//
|
||||
// 历史 bug:SaveNotificationChannel 里写了 `if RatePerMin <= 0 { 取默认值 }`,
|
||||
// 于是文档、UI 提示、takeTokens 都按「0=不限流」解释,唯独写库这一层悄悄改成
|
||||
// 20(钉钉/企微/Telegram)或 100(飞书)——操作者以为放开了限流、实际被卡着,
|
||||
// 而且没有任何提示。「未指定」与「显式 0」的区别只有请求体能表达,
|
||||
// 所以默认值在 server 层填(见 notifyCreateChannel),db 层只管存。
|
||||
func TestSaveNotificationChannelKeepsExplicitZeroRate(t *testing.T) {
|
||||
d := notifyTestDB(t)
|
||||
ctx := context.Background()
|
||||
ch := &NotificationChannel{
|
||||
Name: "默认限流", Kind: notify.KindDingTalk,
|
||||
|
||||
// 显式 0(不限流):必须原样存下来。
|
||||
unlimited := &NotificationChannel{
|
||||
Name: "不限流", Kind: notify.KindDingTalk, RatePerMin: 0,
|
||||
Config: json.RawMessage(`{"webhook":"https://example.com/h"}`),
|
||||
}
|
||||
id, err := d.SaveNotificationChannel(ctx, ch)
|
||||
id, err := d.SaveNotificationChannel(ctx, unlimited)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -713,12 +721,21 @@ func TestSaveNotificationChannelDefaultsRate(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.RatePerMin != 20 {
|
||||
t.Fatalf("钉钉默认限流应为 20,得到 %d", got.RatePerMin)
|
||||
if got.RatePerMin != 0 {
|
||||
t.Fatalf("显式 0 表示不限流,必须原样保存,得到 %d", got.RatePerMin)
|
||||
}
|
||||
if got.Mode != NotifyModeRealtime {
|
||||
t.Fatalf("默认模式应为 realtime,得到 %s", got.Mode)
|
||||
}
|
||||
|
||||
// 负值是非法输入,应被拒绝而不是悄悄改成别的值。
|
||||
bad := &NotificationChannel{
|
||||
Name: "负限流", Kind: notify.KindDingTalk, RatePerMin: -1,
|
||||
Config: json.RawMessage(`{"webhook":"https://example.com/h"}`),
|
||||
}
|
||||
if _, err := d.SaveNotificationChannel(ctx, bad); err == nil {
|
||||
t.Fatal("负限流应被拒绝")
|
||||
}
|
||||
}
|
||||
|
||||
// TestDeleteChannelCascadesDeliveries 锁住外键行为:渠道删除后其投递历史一并消失
|
||||
@@ -756,3 +773,30 @@ func TestDeleteChannelCascadesDeliveries(t *testing.T) {
|
||||
t.Fatal("删渠道不应连带删除事件本身")
|
||||
}
|
||||
}
|
||||
|
||||
// TestClaimDigestBatchHonorsCallerLimit 覆盖审计指出的一处口子:
|
||||
// 汇总渠道此前完全绕过令牌桶——allow 被 takeTokens 扣掉却没人用,
|
||||
// rate_per_min 对 digest 模式毫无作用。现在 limit 也参与约束。
|
||||
func TestClaimDigestBatchHonorsCallerLimit(t *testing.T) {
|
||||
d := notifyTestDB(t)
|
||||
ctx := context.Background()
|
||||
ch := newTestChannel(t, d, notify.KindDingTalk, NotifyModeDigest, `{}`)
|
||||
for i := 0; i < 10; i++ {
|
||||
addTestEvent(t, d, notify.EventFindingCreated, int64(7000+i), notify.Snapshot{Severity: "high"})
|
||||
}
|
||||
if _, _, err := d.FanOutPendingEvents(ctx, 100); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 取 limit=3:只能领到 3 条,其余留在库里。
|
||||
got, err := d.ClaimDigestBatch(ctx, ch.ID, 3, time.Minute)
|
||||
if err != nil {
|
||||
t.Fatalf("领取失败: %v", err)
|
||||
}
|
||||
if len(got) != 3 {
|
||||
t.Fatalf("应按调用方限流额度只领 3 条,得到 %d", len(got))
|
||||
}
|
||||
// limit=0 表示本轮额度用尽:一条都不该领,也不该报错。
|
||||
if got, err := d.ClaimDigestBatch(ctx, ch.ID, 0, time.Minute); err != nil || len(got) != 0 {
|
||||
t.Fatalf("额度为 0 时应领 0 条且不报错,得到 %d 条 err=%v", len(got), err)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user