mirror of
https://github.com/RuoJi6/ARTEX.git
synced 2026-10-27 17:03:49 +08:00
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:
1 parent
77834f0080
commit
5081d95b99
2 files changed
+50
-1
No files matched your search
+6
-1
@@ -130,8 +130,13 @@ func (c emailChannel) Send(ctx context.Context, cfg map[string]any, m Message) (
|
|||||||
// 会话期限在**建连处**就设好(而非事后补设),因为 net/smtp 的 Client 把底层
|
// 会话期限在**建连处**就设好(而非事后补设),因为 net/smtp 的 Client 把底层
|
||||||
// 连接藏在未导出字段里,外部拿不到它;连接一旦交出去就只能靠预先设置的 deadline
|
// 连接藏在未导出字段里,外部拿不到它;连接一旦交出去就只能靠预先设置的 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) {
|
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 conn net.Conn
|
||||||
var err error
|
var err error
|
||||||
if implicitTLS {
|
if implicitTLS {
|
||||||
|
|||||||
@@ -147,3 +147,47 @@ func TestValidateHTTPURLErrorNeverLeaksCredentials(t *testing.T) {
|
|||||||
}
|
}
|
||||||
assertNoSecret(t, err.Error(), leakProbeToken)
|
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——会话没真正建立")
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user