diff --git a/backend/listen/smtp_server/action.go b/backend/listen/smtp_server/action.go index 3c6a3c74..7f744e4e 100644 --- a/backend/listen/smtp_server/action.go +++ b/backend/listen/smtp_server/action.go @@ -107,6 +107,27 @@ func (s *Session) Rcpt(to string, opts *smtp.RcptOptions) error { if err := s.checkAuthorization(); err != nil { return err } + if !validation.IsMailboxAddress(to) { + return &smtp.SMTPError{Code: 550, EnhancedCode: smtp.EnhancedCode{5, 1, 3}, Message: "Invalid recipient address"} + } + // 匿名连接仅接收受管域名的邮件,不能等到正文阶段再静默丢弃外域收件人。 + if s.authorization == nil { + domains := config.CurrentMailDomains().Accepted + if len(domains) == 0 { + return &smtp.SMTPError{Code: 451, EnhancedCode: smtp.EnhancedCode{4, 3, 0}, Message: "Mail domains are unavailable"} + } + domain := to[strings.LastIndex(to, "@")+1:] + local := false + for _, accepted := range domains { + if strings.EqualFold(domain, accepted) { + local = true + break + } + } + if !local { + return &smtp.SMTPError{Code: 550, EnhancedCode: smtp.EnhancedCode{5, 7, 1}, Message: "Relay access denied"} + } + } if validation.IsNotificationAddress(to, config.CurrentMailDomains().Accepted) { return &smtp.SMTPError{Code: 550, EnhancedCode: smtp.EnhancedCode{5, 1, 1}, Message: "Notification address does not accept mail"} } diff --git a/backend/listen/smtp_server/action_security_test.go b/backend/listen/smtp_server/action_security_test.go index ecc6a6f8..518feb25 100644 --- a/backend/listen/smtp_server/action_security_test.go +++ b/backend/listen/smtp_server/action_security_test.go @@ -5,10 +5,44 @@ import ( "strings" "testing" + "git.pcux.cn/xuans/Smail/config" "git.pcux.cn/xuans/Smail/utils/context" "github.com/emersion/go-smtp" ) +func TestAnonymousRcptRejectsForeignDomains(t *testing.T) { + oldConfig := config.Instance + t.Cleanup(func() { config.Instance = oldConfig }) + config.Instance = &config.Config{Domain: "example.com", Domains: []string{"example.com", "alias.example"}} + for _, tt := range []struct { + address string + allowed bool + }{ + {"alice@example.com", true}, + {"alice@EXAMPLE.COM", true}, + {"alice@alias.example", true}, + {"alice@external.example", false}, + {"alice@example.com.attacker.test", false}, + {"alice@sub.example.com", false}, + {"not-an-address", false}, + } { + t.Run(tt.address, func(t *testing.T) { + session := &Session{Ctx: &context.Context{}, To: []string{"existing@example.com"}} + err := session.Rcpt(tt.address, nil) + if tt.allowed { + if err != nil || len(session.To) != 2 { + t.Fatalf("受管域名收件失败:%v", err) + } + return + } + var denied *smtp.SMTPError + if !errors.As(err, &denied) || denied.Code != 550 || len(session.To) != 1 { + t.Fatalf("匿名外域收件未明确拒绝或污染收件列表:%v", err) + } + }) + } +} + func TestSessionResetClearsTransactionStateOnly(t *testing.T) { ctx := &context.Context{UserID: 42, UserAccount: "smail", IsAdmin: true} session := &Session{ diff --git a/backend/utils/send/send.go b/backend/utils/send/send.go index 222da49e..19155501 100644 --- a/backend/utils/send/send.go +++ b/backend/utils/send/send.go @@ -287,10 +287,22 @@ func doSend(ctx *context.Context, fromDomain string, data []byte, to []*parsemai } err = deliverMXContext(deliveryContext, hosts, func(host, port string) error { address := net.JoinHostPort(host, port) + var attemptErr error if port == "465" { - return smtp.SendMailWithTLSContext(deliveryContext, host, address, nil, from, fromDomain, buildAddress(tos), data) + attemptErr = smtp.SendMailWithTLSContext(deliveryContext, host, address, nil, from, fromDomain, buildAddress(tos), data) + } else { + attemptErr = smtp.SendMailContext(deliveryContext, host, address, nil, from, fromDomain, buildAddress(tos), data) } - return smtp.SendMailContext(deliveryContext, host, address, nil, from, fromDomain, buildAddress(tos), data) + if attemptErr != nil { + // 分端口记录网络失败,不记录邮件内容、凭据及远端 SMTP 响应原文。 + fields := log.Fields{"action": "外域邮件投递尝试", "mxHost": host, "mxPort": port, "reason": logstore.MailFailureReason(attemptErr)} + var networkErr *net.OpError + if errors.As(attemptErr, &networkErr) { + fields["networkError"] = networkErr.Error() + } + log.WithContext(ctx).WithFields(fields).Warn("外域邮件投递通道失败") + } + return attemptErr }) if err != nil { recordFailure(err)