修复站内回环投递依赖公网解析导致误判并补充验签回归
Some checks are pending
Security Checks / security (push) Waiting to run

This commit is contained in:
smail 2026-10-01 22:41:59 +08:00
parent acbec5ac12
commit 26189331ca
3 changed files with 110 additions and 1 deletions

View File

@ -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)

View File

@ -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)

View File

@ -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)