From 657f4922b4b7960ef495bb88a22827c76e05b2e2 Mon Sep 17 00:00:00 2001 From: smail Date: Wed, 16 Sep 2026 14:27:53 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E4=BB=A3=E7=90=86=E7=8E=AF?= =?UTF-8?q?=E5=A2=83=E9=82=AE=E4=BB=B6=E7=9B=AE=E6=A0=87=E8=A7=A3=E6=9E=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/utils/smtp/smtp.go | 78 +++++++++++++++++++++++++++++++++ backend/utils/smtp/smtp_test.go | 22 ++++++++++ 2 files changed, 100 insertions(+) diff --git a/backend/utils/smtp/smtp.go b/backend/utils/smtp/smtp.go index 432f00ff..b3d7c8a1 100644 --- a/backend/utils/smtp/smtp.go +++ b/backend/utils/smtp/smtp.go @@ -23,6 +23,7 @@ import ( "context" "crypto/tls" "encoding/base64" + "encoding/json" "errors" "fmt" "git.pcux.cn/xuans/Smail/config" @@ -30,9 +31,11 @@ import ( log "github.com/sirupsen/logrus" "io" "net" + "net/http" "net/netip" "net/smtp" "net/textproto" + "net/url" "strings" "time" ) @@ -47,8 +50,11 @@ const ( outboundSMTPTLSHandshakeTimeout = 2 * time.Second outboundSMTPTransactionTimeout = 2 * time.Minute outboundSMTPQuitWriteTimeout = 250 * time.Millisecond + remoteAddressResponseLimit = 64 * 1024 ) +var benchmarkNetworkPrefix = netip.MustParsePrefix("198.18.0.0/15") + // A Client represents a client connection to an SMTP server. type Client struct { // Text is the textproto.Conn used by the Client. It is exported to allow for @@ -189,6 +195,78 @@ func resolveRemoteAddresses(ctx context.Context, host string) ([]netip.Addr, err if len(addresses) == 0 { return nil, errors.New("smtp: remote address is unavailable") } + if containsOnlyBenchmarkAddresses(addresses) { + addresses, err = resolveRemoteAddressesOverHTTPS(ctx, host) + if err != nil { + return nil, fmt.Errorf("smtp: resolve real remote address: %w", err) + } + } + return addresses, nil +} + +func containsOnlyBenchmarkAddresses(addresses []netip.Addr) bool { + if len(addresses) == 0 { + return false + } + for _, address := range addresses { + if !benchmarkNetworkPrefix.Contains(address.Unmap()) { + return false + } + } + return true +} + +func resolveRemoteAddressesOverHTTPS(ctx context.Context, host string) ([]netip.Addr, error) { + endpoint := "https://cloudflare-dns.com/dns-query?name=" + url.QueryEscape(host) + "&type=A" + request, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return nil, err + } + request.Header.Set("Accept", "application/dns-json") + + client := &http.Client{ + Timeout: outboundSMTPDialTimeout, + CheckRedirect: func(_ *http.Request, _ []*http.Request) error { + return http.ErrUseLastResponse + }, + } + response, err := client.Do(request) + if err != nil { + return nil, err + } + defer response.Body.Close() + if response.StatusCode != http.StatusOK { + return nil, fmt.Errorf("unexpected DNS response status: %d", response.StatusCode) + } + + var payload struct { + Status int `json:"Status"` + Answer []struct { + Type int `json:"type"` + Data string `json:"data"` + } `json:"Answer"` + } + decoder := json.NewDecoder(io.LimitReader(response.Body, remoteAddressResponseLimit)) + if err = decoder.Decode(&payload); err != nil { + return nil, err + } + if payload.Status != 0 { + return nil, fmt.Errorf("DNS response code: %d", payload.Status) + } + + addresses := make([]netip.Addr, 0, len(payload.Answer)) + for _, answer := range payload.Answer { + if answer.Type != 1 { + continue + } + address, parseErr := netip.ParseAddr(strings.TrimSpace(answer.Data)) + if parseErr == nil { + addresses = append(addresses, address.Unmap()) + } + } + if len(addresses) == 0 { + return nil, errors.New("remote address is unavailable") + } return addresses, nil } diff --git a/backend/utils/smtp/smtp_test.go b/backend/utils/smtp/smtp_test.go index f0ca2bb4..69dd8554 100644 --- a/backend/utils/smtp/smtp_test.go +++ b/backend/utils/smtp/smtp_test.go @@ -11,6 +11,7 @@ import ( "io" "math/big" "net" + "net/netip" "net/textproto" "strings" "testing" @@ -42,6 +43,27 @@ func TestDialRejectsNonPublicRemoteAddress(t *testing.T) { } } +func TestContainsOnlyBenchmarkAddresses(t *testing.T) { + tests := []struct { + name string + addresses []netip.Addr + want bool + }{ + {name: "empty", want: false}, + {name: "fake IP", addresses: []netip.Addr{netip.MustParseAddr("198.18.1.77")}, want: true}, + {name: "multiple fake IPs", addresses: []netip.Addr{netip.MustParseAddr("198.18.1.77"), netip.MustParseAddr("198.19.1.82")}, want: true}, + {name: "public IP", addresses: []netip.Addr{netip.MustParseAddr("47.251.242.178")}, want: false}, + {name: "mixed", addresses: []netip.Addr{netip.MustParseAddr("198.18.1.77"), netip.MustParseAddr("127.0.0.1")}, want: false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := containsOnlyBenchmarkAddresses(tt.addresses); got != tt.want { + t.Fatalf("containsOnlyBenchmarkAddresses() = %v, want %v", got, tt.want) + } + }) + } +} + func TestSendMailUnsafeRejectsNonLoopbackTargets(t *testing.T) { tests := []struct { name string