mirror of
https://github.com/mjl-/mox.git
synced 2024-12-26 16:33:47 +03:00
give delivering to mx targets with underscores in name a chance of succeeding
the underscores aren't valid, but have been seen in the wild, so we have a workaround for them. there are limitations, it won't work with idna domains. and if the domain has other policies, like mta-sts, the mx host won't pass either. after report from richard g about delivery issue, thanks!
This commit is contained in:
parent
682f8a0904
commit
d1e93020d8
4 changed files with 68 additions and 13 deletions
48
dns/dns.go
48
dns/dns.go
|
@ -10,9 +10,15 @@ import (
|
||||||
"golang.org/x/net/idna"
|
"golang.org/x/net/idna"
|
||||||
|
|
||||||
"github.com/mjl-/adns"
|
"github.com/mjl-/adns"
|
||||||
|
|
||||||
|
"github.com/mjl-/mox/moxvar"
|
||||||
)
|
)
|
||||||
|
|
||||||
var errTrailingDot = errors.New("dns name has trailing dot")
|
var (
|
||||||
|
errTrailingDot = errors.New("dns name has trailing dot")
|
||||||
|
errUnderscore = errors.New("domain name with underscore")
|
||||||
|
errIDNA = errors.New("idna")
|
||||||
|
)
|
||||||
|
|
||||||
// Domain is a domain name, with one or more labels, with at least an ASCII
|
// Domain is a domain name, with one or more labels, with at least an ASCII
|
||||||
// representation, and for IDNA non-ASCII domains a unicode representation.
|
// representation, and for IDNA non-ASCII domains a unicode representation.
|
||||||
|
@ -83,13 +89,14 @@ func ParseDomain(s string) (Domain, error) {
|
||||||
if strings.HasSuffix(s, ".") {
|
if strings.HasSuffix(s, ".") {
|
||||||
return Domain{}, errTrailingDot
|
return Domain{}, errTrailingDot
|
||||||
}
|
}
|
||||||
|
|
||||||
ascii, err := idna.Lookup.ToASCII(s)
|
ascii, err := idna.Lookup.ToASCII(s)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Domain{}, fmt.Errorf("to ascii: %w", err)
|
return Domain{}, fmt.Errorf("%w: to ascii: %v", errIDNA, err)
|
||||||
}
|
}
|
||||||
unicode, err := idna.Lookup.ToUnicode(s)
|
unicode, err := idna.Lookup.ToUnicode(s)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Domain{}, fmt.Errorf("to unicode: %w", err)
|
return Domain{}, fmt.Errorf("%w: to unicode: %w", errIDNA, err)
|
||||||
}
|
}
|
||||||
// todo: should we cause errors for unicode domains that were not in
|
// todo: should we cause errors for unicode domains that were not in
|
||||||
// canonical form? we are now accepting all kinds of obscure spellings
|
// canonical form? we are now accepting all kinds of obscure spellings
|
||||||
|
@ -101,6 +108,41 @@ func ParseDomain(s string) (Domain, error) {
|
||||||
return Domain{ascii, unicode}, nil
|
return Domain{ascii, unicode}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ParseDomainLax parses a domain like ParseDomain, but allows labels with
|
||||||
|
// underscores if the entire domain name is ASCII-only non-IDNA and Pedantic mode
|
||||||
|
// is not enabled. Used for interoperability, e.g. domains may specify MX
|
||||||
|
// targets with underscores.
|
||||||
|
func ParseDomainLax(s string) (Domain, error) {
|
||||||
|
if moxvar.Pedantic || !strings.Contains(s, "_") {
|
||||||
|
return ParseDomain(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
// If there is any non-ASCII, this is certainly not an A-label-only domain.
|
||||||
|
s = strings.ToLower(s)
|
||||||
|
for _, c := range s {
|
||||||
|
if c >= 0x80 {
|
||||||
|
return Domain{}, fmt.Errorf("%w: underscore and non-ascii not allowed", errUnderscore)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try parsing with underscores replaced with allowed ASCII character.
|
||||||
|
// If that's not valid, the version with underscore isn't either.
|
||||||
|
repl := strings.ReplaceAll(s, "_", "a")
|
||||||
|
d, err := ParseDomain(repl)
|
||||||
|
if err != nil {
|
||||||
|
return Domain{}, fmt.Errorf("%w: %v", errUnderscore, err)
|
||||||
|
}
|
||||||
|
// If we found an IDNA domain, we're not going to allow it.
|
||||||
|
if d.Unicode != "" {
|
||||||
|
return Domain{}, fmt.Errorf("%w: idna domain with underscores not allowed", errUnderscore)
|
||||||
|
}
|
||||||
|
// Just to be safe, ensure no unexpected conversions happened.
|
||||||
|
if d.ASCII != repl {
|
||||||
|
return Domain{}, fmt.Errorf("%w: underscores and non-canonical names not allowed", errUnderscore)
|
||||||
|
}
|
||||||
|
return Domain{ASCII: s}, nil
|
||||||
|
}
|
||||||
|
|
||||||
// IsNotFound returns whether an error is an adns.DNSError with IsNotFound set.
|
// IsNotFound returns whether an error is an adns.DNSError with IsNotFound set.
|
||||||
// IsNotFound means the requested type does not exist for the given domain (a
|
// IsNotFound means the requested type does not exist for the given domain (a
|
||||||
// nodata or nxdomain response). It doesn't not necessarily mean no other types for
|
// nodata or nxdomain response). It doesn't not necessarily mean no other types for
|
||||||
|
|
|
@ -6,9 +6,15 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestParseDomain(t *testing.T) {
|
func TestParseDomain(t *testing.T) {
|
||||||
test := func(s string, exp Domain, expErr error) {
|
test := func(lax bool, s string, exp Domain, expErr error) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
dom, err := ParseDomain(s)
|
var dom Domain
|
||||||
|
var err error
|
||||||
|
if lax {
|
||||||
|
dom, err = ParseDomainLax(s)
|
||||||
|
} else {
|
||||||
|
dom, err = ParseDomain(s)
|
||||||
|
}
|
||||||
if (err == nil) != (expErr == nil) || expErr != nil && !errors.Is(err, expErr) {
|
if (err == nil) != (expErr == nil) || expErr != nil && !errors.Is(err, expErr) {
|
||||||
t.Fatalf("parse domain %q: err %v, expected %v", s, err, expErr)
|
t.Fatalf("parse domain %q: err %v, expected %v", s, err, expErr)
|
||||||
}
|
}
|
||||||
|
@ -18,10 +24,15 @@ func TestParseDomain(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// We rely on normalization of names throughout the code base.
|
// We rely on normalization of names throughout the code base.
|
||||||
test("xmox.nl", Domain{"xmox.nl", ""}, nil)
|
test(false, "xmox.nl", Domain{"xmox.nl", ""}, nil)
|
||||||
test("XMOX.NL", Domain{"xmox.nl", ""}, nil)
|
test(false, "XMOX.NL", Domain{"xmox.nl", ""}, nil)
|
||||||
test("TEST☺.XMOX.NL", Domain{"xn--test-3o3b.xmox.nl", "test☺.xmox.nl"}, nil)
|
test(false, "TEST☺.XMOX.NL", Domain{"xn--test-3o3b.xmox.nl", "test☺.xmox.nl"}, nil)
|
||||||
test("TEST☺.XMOX.NL", Domain{"xn--test-3o3b.xmox.nl", "test☺.xmox.nl"}, nil)
|
test(false, "TEST☺.XMOX.NL", Domain{"xn--test-3o3b.xmox.nl", "test☺.xmox.nl"}, nil)
|
||||||
test("ℂᵤⓇℒ。𝐒🄴", Domain{"curl.se", ""}, nil) // https://daniel.haxx.se/blog/2022/12/14/idn-is-crazy/
|
test(false, "ℂᵤⓇℒ。𝐒🄴", Domain{"curl.se", ""}, nil) // https://daniel.haxx.se/blog/2022/12/14/idn-is-crazy/
|
||||||
test("xmox.nl.", Domain{}, errTrailingDot)
|
test(false, "xmox.nl.", Domain{}, errTrailingDot)
|
||||||
|
|
||||||
|
test(false, "_underscore.xmox.nl", Domain{}, errIDNA)
|
||||||
|
test(true, "_underscore.xmox.NL", Domain{ASCII: "_underscore.xmox.nl"}, nil)
|
||||||
|
test(true, "_underscore.☺.xmox.nl", Domain{}, errUnderscore)
|
||||||
|
test(true, "_underscore.xn--test-3o3b.xmox.nl", Domain{}, errUnderscore)
|
||||||
}
|
}
|
||||||
|
|
|
@ -148,7 +148,8 @@ func GatherDestinations(ctx context.Context, log *mlog.Log, resolver dns.Resolve
|
||||||
// The Go resolver already sorts by preference, randomizing records of same
|
// The Go resolver already sorts by preference, randomizing records of same
|
||||||
// preference. ../rfc/5321:3885
|
// preference. ../rfc/5321:3885
|
||||||
for _, mx := range mxl {
|
for _, mx := range mxl {
|
||||||
host, err := dns.ParseDomain(strings.TrimSuffix(mx.Host, "."))
|
// Parsing lax (unless pedantic mode) for MX targets with underscores as seen in the wild.
|
||||||
|
host, err := dns.ParseDomainLax(strings.TrimSuffix(mx.Host, "."))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// note: should not happen because Go resolver already filters these out.
|
// note: should not happen because Go resolver already filters these out.
|
||||||
err = fmt.Errorf("%w: invalid host name in mx record %q: %v", errDNS, mx.Host, err)
|
err = fmt.Errorf("%w: invalid host name in mx record %q: %v", errDNS, mx.Host, err)
|
||||||
|
|
|
@ -444,7 +444,8 @@ func evaluate(ctx context.Context, record *Record, resolver dns.Resolver, args A
|
||||||
if i >= 10 {
|
if i >= 10 {
|
||||||
return StatusPermerror, d.MechanismString(), "", rauthentic, ErrTooManyDNSRequests
|
return StatusPermerror, d.MechanismString(), "", rauthentic, ErrTooManyDNSRequests
|
||||||
}
|
}
|
||||||
mxd, err := dns.ParseDomain(strings.TrimSuffix(mx.Host, "."))
|
// Parsing lax (unless in pedantic mode) for MX targets with underscores as seen in the wild.
|
||||||
|
mxd, err := dns.ParseDomainLax(strings.TrimSuffix(mx.Host, "."))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return StatusPermerror, d.MechanismString(), "", rauthentic, err
|
return StatusPermerror, d.MechanismString(), "", rauthentic, err
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue