From 5081d95b99e46a6bcd03b12269779c05df6fcdf4 Mon Sep 17 00:00:00 2001 From: Autumn-27 <53844290+Autumn-27@users.noreply.github.com> Date: Thu, 1 Oct 2026 06:43:48 -0400 Subject: [PATCH] =?UTF-8?q?fix(notify):=20=E9=82=AE=E4=BB=B6=E6=B8=A0?= =?UTF-8?q?=E9=81=93=E7=9A=84=20SMTP=20=E6=8B=A8=E5=8F=B7=E8=A1=A5?= =?UTF-8?q?=E4=B8=8A=20SSRF=20=E5=AE=88=E5=8D=AB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit emailDial 用的是裸 net.Dialer,没挂 blockInternalDial,Validate 也只检查 host 非空。于是 SMTP 是整套 SSRF 防护里唯一的缺口:host 填 169.254.169.254 或 127.0.0.1 能直接连上,而 smtp.NewClient 握手失败时会把对端返回的那一行 (textproto 的 short response)包进错误、经 last_error 由投递历史接口回显, 正是其它渠道已经关掉的半盲读原语;「连接被拒 vs 超时」的耗时差异还能 用来探测端口。 拨号阶段是最终生效点,也覆盖 DNS 重绑定,与 HTTP 系渠道共用同一道守卫。 RFC1918 私网继续放行,与 isBlockedDialIP 既有的取舍一致——内网自建 SMTP 中继是常见的合法部署。 这个缺口没有测试会发现:本包 TestMain 全局打开了 ARTEX_NOTIFY_ALLOW_LOCAL, 邮件用例无论守卫在不在都会通过。新增的两条用例自己清掉该变量, 并配一条反向用例确认显式放开后本机 SMTP 仍可投递。 --- notify/email.go | 7 ++++++- notify/ssrf_test.go | 44 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 1 deletion(-) diff --git a/notify/email.go b/notify/email.go index bba4b00..70da4c7 100644 --- a/notify/email.go +++ b/notify/email.go @@ -130,8 +130,13 @@ func (c emailChannel) Send(ctx context.Context, cfg map[string]any, m Message) ( // 会话期限在**建连处**就设好(而非事后补设),因为 net/smtp 的 Client 把底层 // 连接藏在未导出字段里,外部拿不到它;连接一旦交出去就只能靠预先设置的 deadline // 兜底。这也顺带覆盖了握手阶段的阻塞。 +// Control 挂 blockInternalDial 与 HTTP 系渠道共用同一道守卫。不挂的话 SMTP +// 就是整套 SSRF 防护的缺口:host 填 169.254.169.254 或 127.0.0.1 能直接连上, +// 而 smtp.NewClient 握手失败时会把对端返回的那一行包进错误、经 last_error +// 由投递历史接口回显,构成半盲读原语;「连接被拒 vs 超时」的耗时差异还能 +// 用来探测端口。拨号阶段是最终生效点,也覆盖 DNS 重绑定。 func emailDial(ctx context.Context, addr, host string, implicitTLS bool) (*smtp.Client, error) { - d := &net.Dialer{Timeout: emailDialTimeout} + d := &net.Dialer{Timeout: emailDialTimeout, Control: blockInternalDial} var conn net.Conn var err error if implicitTLS { diff --git a/notify/ssrf_test.go b/notify/ssrf_test.go index da15182..e869bd3 100644 --- a/notify/ssrf_test.go +++ b/notify/ssrf_test.go @@ -147,3 +147,47 @@ func TestValidateHTTPURLErrorNeverLeaksCredentials(t *testing.T) { } assertNoSecret(t, err.Error(), leakProbeToken) } + +// TestEmailDialGuardRejectsLoopbackByDefault 覆盖 SMTP 渠道的拨号守卫。 +// +// 邮件渠道曾经用的是裸 net.Dialer,是整套 SSRF 防护里唯一的缺口:host 填成 +// 169.254.169.254 或 127.0.0.1 能直接连上,而 smtp.NewClient 握手失败时会把 +// 对端返回的那一行包进错误、经 last_error 由投递历史接口回显——正是其它渠道 +// 已经关掉的半盲读原语;「连接被拒 vs 超时」的耗时差异还能用来探测端口。 +// +// 本包的 TestMain 全局打开了 AllowLocalTargetsEnv(大量用例用 127.0.0.1 上的 +// 假接收端),所以这个用例必须自己把它清掉——否则守卫在不在都会通过, +// 这也正是缺口当初没被任何测试发现的原因。 +func TestEmailDialGuardRejectsLoopbackByDefault(t *testing.T) { + f := newFakeSMTP(t) + cfg := emailCfg(t, f, nil) + + t.Setenv(AllowLocalTargetsEnv, "") // 关掉逃生口 = 默认行为 + _, err := (emailChannel{}).Send(context.Background(), cfg, singleMsg()) + if err == nil { + t.Fatal("默认不应允许把邮件投递到环回地址") + } + // 连接根本不该建立:守卫在 Control 钩子里拦下,EHLO 永远发不出去。 + if f.sawCommand("EHLO") || f.sawCommand("HELO") { + t.Fatal("SMTP 会话已经建立——守卫没生效") + } + // 错误信息要能指导用户怎么放开(本机 postfix 中继是合法配置)。 + if !strings.Contains(err.Error(), AllowLocalTargetsEnv) { + t.Errorf("拒绝信息应说明如何显式放开: %v", err) + } +} + +// TestEmailDialGuardAllowsLoopbackWhenOptedIn 是配对的反向用例:显式打开后 +// 必须能正常投递。内网自建 SMTP / 本机中继是非常常见的部署,守卫不能一刀切。 +func TestEmailDialGuardAllowsLoopbackWhenOptedIn(t *testing.T) { + f := newFakeSMTP(t) + cfg := emailCfg(t, f, nil) + + t.Setenv(AllowLocalTargetsEnv, "1") + if _, err := (emailChannel{}).Send(context.Background(), cfg, singleMsg()); err != nil { + t.Fatalf("显式放开后本机 SMTP 应可投递: %v", err) + } + if !f.sawCommand("EHLO") { + t.Fatal("未看到 EHLO——会话没真正建立") + } +}