fix(notify): 邮件渠道的 SMTP 拨号补上 SSRF 守卫

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 仍可投递。
This commit is contained in:
Autumn-27 committed 2026-10-01 06:43:48 -04:00
1 parent 77834f0080
commit 5081d95b99
2 files changed
+50 -1

No files matched your search

+6 -1
View File
@@ -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 {
+44
View File
@@ -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——会话没真正建立")
}
}