From 26189331caf4b3bc35f396d0ef6c1886d6b2e374 Mon Sep 17 00:00:00 2001 From: smail Date: Thu, 1 Oct 2026 22:41:59 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E7=AB=99=E5=86=85=E5=9B=9E?= =?UTF-8?q?=E7=8E=AF=E6=8A=95=E9=80=92=E4=BE=9D=E8=B5=96=E5=85=AC=E7=BD=91?= =?UTF-8?q?=E8=A7=A3=E6=9E=90=E5=AF=BC=E8=87=B4=E8=AF=AF=E5=88=A4=E5=B9=B6?= =?UTF-8?q?=E8=A1=A5=E5=85=85=E9=AA=8C=E7=AD=BE=E5=9B=9E=E5=BD=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/dto/parsemail/dkim.go | 38 ++++++++++++ backend/dto/parsemail/dkim_security_test.go | 66 +++++++++++++++++++++ backend/listen/smtp_server/read_content.go | 7 ++- 3 files changed, 110 insertions(+), 1 deletion(-) diff --git a/backend/dto/parsemail/dkim.go b/backend/dto/parsemail/dkim.go index bc2a66d..c117599 100644 --- a/backend/dto/parsemail/dkim.go +++ b/backend/dto/parsemail/dkim.go @@ -5,6 +5,7 @@ import ( "crypto" "crypto/rsa" "crypto/x509" + "encoding/base64" "encoding/pem" "fmt" "git.pcux.cn/xuans/Smail/config" @@ -14,6 +15,7 @@ import ( "github.com/emersion/go-msgauth/dkim" log "github.com/sirupsen/logrus" "io" + "net" "os" "strings" ) @@ -123,6 +125,42 @@ func Check(ctx *context.Context, mail io.Reader) bool { return CheckForDomain(ctx, mail, "") } +// CheckLocalDelivery 仅对回环投递使用当前主域公钥验签,不能仅凭来源地址放行。 +func CheckLocalDelivery(ctx *context.Context, mail io.Reader, domain string, remote net.Addr) bool { + if instance == nil { + return false + } + return checkLocalDelivery(ctx, mail, domain, config.CurrentMailDomains().Primary, remote, instance.privateKey) +} + +func checkLocalDelivery(ctx *context.Context, mail io.Reader, domain, primary string, remote net.Addr, signer crypto.Signer) bool { + address, ok := remote.(*net.TCPAddr) + if !ok || address == nil || !address.IP.IsLoopback() || signer == nil { + return false + } + domain = strings.TrimSpace(domain) + primary = strings.TrimSpace(primary) + if primary == "" || !strings.EqualFold(domain, primary) { + return false + } + publicKey, err := x509.MarshalPKIXPublicKey(signer.Public()) + if err != nil { + return false + } + // 只提供本机实际使用的选择器和域名公钥,其他签名交回正常 DNS 验证流程。 + options := &dkim.VerifyOptions{LookupTXT: func(name string) ([]string, error) { + if !strings.EqualFold(name, "default._domainkey."+primary) { + return nil, fmt.Errorf("signature does not use the current local DKIM selector") + } + return []string{"v=DKIM1; k=rsa; p=" + base64.StdEncoding.EncodeToString(publicKey)}, nil + }} + verified := checkForDomain(ctx, mail, domain, options) + if verified { + log.WithContext(ctx).Info("DKIM local delivery signature verified") + } + return verified +} + // CheckForDomain 在指定域名时要求有效签名与发件域严格一致,避免其他域签名冒用本域。 func CheckForDomain(ctx *context.Context, mail io.Reader, domain string) bool { return checkForDomain(ctx, mail, domain, nil) diff --git a/backend/dto/parsemail/dkim_security_test.go b/backend/dto/parsemail/dkim_security_test.go index f4e5401..c504dc5 100644 --- a/backend/dto/parsemail/dkim_security_test.go +++ b/backend/dto/parsemail/dkim_security_test.go @@ -9,6 +9,7 @@ import ( "crypto/x509" "encoding/base64" "encoding/pem" + "net" "os" "path/filepath" "strings" @@ -65,6 +66,71 @@ func TestCheckForDomainRequiresValidAlignedSignature(t *testing.T) { } } +func TestLocalDeliveryRequiresCurrentKeyDomainAndLoopback(t *testing.T) { + key, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatal(err) + } + otherKey, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatal(err) + } + message := "From: sender@example.com\r\nTo: recipient@example.com\r\nSubject: local authentication\r\n\r\noriginal body\r\n" + for _, tc := range []struct { + name, address, domain, selector string + foreignKey, tampered, unsigned bool + want bool + }{ + {name: "ipv4_without_dns", address: "127.0.0.1", want: true}, + {name: "ipv6_without_dns", address: "::1", want: true}, + {name: "mapped_loopback", address: "::ffff:127.0.0.1", want: true}, + {name: "public_remote", address: "203.0.113.9"}, + {name: "private_remote", address: "10.0.0.9"}, + {name: "missing_remote"}, + {name: "foreign_domain", address: "127.0.0.1", domain: "other.example"}, + {name: "subdomain", address: "127.0.0.1", domain: "sub.example.com"}, + {name: "unknown_selector", address: "127.0.0.1", selector: "other"}, + {name: "wrong_key", address: "127.0.0.1", foreignKey: true}, + {name: "tampered", address: "127.0.0.1", tampered: true}, + {name: "unsigned", address: "127.0.0.1", unsigned: true}, + } { + t.Run(tc.name, func(t *testing.T) { + domain, selector := tc.domain, tc.selector + if domain == "" { + domain = "example.com" + } + if selector == "" { + selector = "default" + } + signingKey := key + if tc.foreignKey { + signingKey = otherKey + } + data := message + if !tc.unsigned { + var signed bytes.Buffer + if err := dkim.Sign(&signed, strings.NewReader(data), &dkim.SignOptions{Domain: domain, Selector: selector, Signer: signingKey}); err != nil { + t.Fatal(err) + } + data = signed.String() + } + if tc.tampered { + data += "tampered body\r\n" + } + remote := &net.TCPAddr{IP: net.ParseIP(tc.address), Port: 12345} + if got := checkLocalDelivery(nil, strings.NewReader(data), domain, "example.com", remote, key); got != tc.want { + t.Fatalf("本机投递认证结果为 %v,期望 %v", got, tc.want) + } + }) + } + if checkLocalDelivery(nil, strings.NewReader(message), "example.com", "example.com", nil, key) { + t.Fatal("缺少连接地址时不能通过本机认证") + } + if checkLocalDelivery(nil, strings.NewReader(message), "example.com", "example.com", &net.TCPAddr{IP: net.ParseIP("127.0.0.1")}, nil) { + t.Fatal("缺少当前签名密钥时不能通过本机认证") + } +} + func TestLoadPrivateKeyAcceptsStrongRSAKey(t *testing.T) { path := writeDKIMTestKey(t, 2048) signer, err := loadPrivateKey(path) diff --git a/backend/listen/smtp_server/read_content.go b/backend/listen/smtp_server/read_content.go index 76d24cf..14bffc2 100644 --- a/backend/listen/smtp_server/read_content.go +++ b/backend/listen/smtp_server/read_content.go @@ -200,7 +200,12 @@ func (s *Session) Data(r io.Reader) (resultErr error) { dkimDomain = fromDomain } // DKIM校验 - dkimStatus = parsemail.CheckForDomain(ctx, bytes.NewReader(emailData), dkimDomain) + if localSender { + dkimStatus = parsemail.CheckLocalDelivery(ctx, bytes.NewReader(emailData), fromDomain, s.RemoteAddress) + } + if !dkimStatus { + dkimStatus = parsemail.CheckForDomain(ctx, bytes.NewReader(emailData), dkimDomain) + } SPFStatus = spfCheck(s.RemoteAddress.String(), email.Sender, email.Sender.EmailAddress)