diff --git a/pkg/certmanager/provision_worker.go b/pkg/certmanager/provision_worker.go index 6a46367e1..e18def6e2 100644 --- a/pkg/certmanager/provision_worker.go +++ b/pkg/certmanager/provision_worker.go @@ -31,6 +31,7 @@ import ( "go.opentelemetry.io/otel/trace" "go.probo.inc/probo/pkg/coredata" "go.probo.inc/probo/pkg/crypto/cipher" + "go.probo.inc/probo/pkg/dnsverify" "go.probo.inc/probo/pkg/gid" "golang.org/x/crypto/acme" ) @@ -345,15 +346,8 @@ func (h *beginChallengeHandler) loadSkipDNSChecks(ctx context.Context, hostname } func (h *beginChallengeHandler) checkDNSConfiguration(ctx context.Context, hostname string) error { - customerFQDN := hostname - if !strings.HasSuffix(customerFQDN, ".") { - customerFQDN = customerFQDN + "." - } - - expectedFQDN := h.cnameTarget - if !strings.HasSuffix(expectedFQDN, ".") { - expectedFQDN = expectedFQDN + "." - } + customerFQDN := dnsverify.ToFQDN(hostname) + expectedFQDN := dnsverify.ToFQDN(h.cnameTarget) msg := &dns.Msg{MsgHeader: dns.MsgHeader{ID: dns.ID(), RecursionDesired: true}} msg.Question = []dns.RR{&dns.CNAME{Hdr: dns.Header{Name: customerFQDN, Class: dns.ClassINET}}} @@ -381,7 +375,15 @@ func (h *beginChallengeHandler) checkDNSConfiguration(ctx context.Context, hostn return fmt.Errorf("first answer is not a cname record for domain %q", hostname) } - if !strings.EqualFold(expectedFQDN, resolvedRecord.Target) { + if !dnsverify.EqualNames(resolvedRecord.Hdr.Name, customerFQDN) { + return fmt.Errorf( + "cname owner mismatch: domain %q has record owned by %q", + hostname, + strings.TrimSuffix(resolvedRecord.Hdr.Name, "."), + ) + } + + if !dnsverify.EqualNames(resolvedRecord.Target, expectedFQDN) { return fmt.Errorf( "cname target mismatch: domain %q resolves to %q, expected %q", hostname, @@ -394,56 +396,65 @@ func (h *beginChallengeHandler) checkDNSConfiguration(ctx context.Context, hostn } func (h *beginChallengeHandler) checkCAARecords(ctx context.Context, hostname string) error { - fqdn := hostname - if !strings.HasSuffix(fqdn, ".") { - fqdn = fqdn + "." + checkNames, err := dnsverify.CheckNames(hostname) + if err != nil { + return err } - msg := &dns.Msg{MsgHeader: dns.MsgHeader{ID: dns.ID(), RecursionDesired: true}} - msg.Question = []dns.RR{&dns.CAA{Hdr: dns.Header{Name: fqdn, Class: dns.ClassINET}}} - dnsCtx, cancel := context.WithTimeout(ctx, dnsExchangeTimeout) defer cancel() client := dns.NewClient() - resp, _, err := client.Exchange( - dnsCtx, - msg, - "udp", - h.resolverAddr, - ) - if err != nil { - return fmt.Errorf("cannot exchange dns message for caa records: %w", err) - } + for _, checkName := range checkNames { + fqdn := dnsverify.ToFQDN(checkName) - var caaRecords []*dns.CAA + msg := &dns.Msg{MsgHeader: dns.MsgHeader{ID: dns.ID(), RecursionDesired: true}} + msg.Question = []dns.RR{&dns.CAA{Hdr: dns.Header{Name: fqdn, Class: dns.ClassINET}}} + + resp, _, err := client.Exchange( + dnsCtx, + msg, + "udp", + h.resolverAddr, + ) + if err != nil { + return fmt.Errorf("cannot exchange dns message for caa records: %w", err) + } + + var caaRecords []*dns.CAA + + for _, rr := range resp.Answer { + caa, ok := rr.(*dns.CAA) + if !ok || !dnsverify.EqualNames(caa.Hdr.Name, fqdn) { + continue + } - for _, rr := range resp.Answer { - if caa, ok := rr.(*dns.CAA); ok { caaRecords = append(caaRecords, caa) } - } - if len(caaRecords) == 0 { - return nil - } + if len(caaRecords) == 0 { + continue + } - for _, caa := range caaRecords { - if caa.Tag == "issue" { - issuer, _, _ := strings.Cut(caa.Value, ";") - if strings.EqualFold(strings.TrimSpace(issuer), h.caaIssuerDomain) { - return nil + for _, caa := range caaRecords { + if caa.Tag == "issue" { + issuer, _, _ := strings.Cut(caa.Value, ";") + if strings.EqualFold(strings.TrimSpace(issuer), h.caaIssuerDomain) { + return nil + } } } + + return fmt.Errorf( + "%w: domain %q by %q", + ErrCAANotPermitted, + hostname, + h.caaIssuerDomain, + ) } - return fmt.Errorf( - "%w: domain %q by %q", - ErrCAANotPermitted, - hostname, - h.caaIssuerDomain, - ) + return nil } func (h *beginChallengeHandler) skipsDNSChecks( diff --git a/pkg/dnsverify/names.go b/pkg/dnsverify/names.go new file mode 100644 index 000000000..2ed6da077 --- /dev/null +++ b/pkg/dnsverify/names.go @@ -0,0 +1,78 @@ +// Copyright (c) 2025-2026 Probo Inc . +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +package dnsverify + +import ( + "fmt" + "strings" + + "golang.org/x/net/publicsuffix" +) + +// ToFQDN normalizes a DNS name to lowercase FQDN form with a trailing dot. +func ToFQDN(name string) string { + name = strings.ToLower(strings.TrimSpace(name)) + name = strings.TrimSuffix(name, ".") + + if name == "" { + return "." + } + + return name + "." +} + +// EqualNames reports whether two DNS names refer to the same owner, ignoring +// case and an optional trailing dot. +func EqualNames(a, b string) bool { + return ToFQDN(a) == ToFQDN(b) +} + +// CheckNames returns the DNS names to evaluate for CAA, starting at the exact +// hostname being verified and walking up through each parent to the +// registrable apex (eTLD+1). The first entry is always the requested hostname +// itself, not its apex. +func CheckNames(hostname string) ([]string, error) { + hostname = strings.ToLower(strings.TrimSpace(hostname)) + hostname = strings.TrimSuffix(hostname, ".") + if hostname == "" { + return nil, fmt.Errorf("cannot build DNS check names: empty hostname") + } + + apex, err := publicsuffix.EffectiveTLDPlusOne(hostname) + if err != nil { + return nil, fmt.Errorf("cannot build DNS check names for %q: %w", hostname, err) + } + + names := []string{hostname} + current := hostname + + for !strings.EqualFold(current, apex) { + dot := strings.Index(current, ".") + if dot < 0 { + break + } + + current = current[dot+1:] + names = append(names, current) + } + + return names, nil +} diff --git a/pkg/dnsverify/names_test.go b/pkg/dnsverify/names_test.go new file mode 100644 index 000000000..a30d7a1bd --- /dev/null +++ b/pkg/dnsverify/names_test.go @@ -0,0 +1,72 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +package dnsverify_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.probo.inc/probo/pkg/dnsverify" +) + +func TestEqualNames(t *testing.T) { + t.Parallel() + + assert.True(t, dnsverify.EqualNames("trust.example.com", "trust.example.com.")) + assert.True(t, dnsverify.EqualNames("Trust.Example.COM", "trust.example.com")) + assert.False(t, dnsverify.EqualNames("trust.example.com", "example.com")) +} + +func TestCheckNames(t *testing.T) { + t.Parallel() + + t.Run("subdomain starts at child then walks to apex", func(t *testing.T) { + t.Parallel() + + names, err := dnsverify.CheckNames("trust.example.com") + + require.NoError(t, err) + assert.Equal(t, []string{"trust.example.com", "example.com"}, names) + }) + + t.Run("apex stays on apex", func(t *testing.T) { + t.Parallel() + + names, err := dnsverify.CheckNames("example.com") + + require.NoError(t, err) + assert.Equal(t, []string{"example.com"}, names) + }) + + t.Run("nested subdomain walks each parent", func(t *testing.T) { + t.Parallel() + + names, err := dnsverify.CheckNames("portal.trust.example.com") + + require.NoError(t, err) + assert.Equal( + t, + []string{"portal.trust.example.com", "trust.example.com", "example.com"}, + names, + ) + }) +} diff --git a/pkg/iam/saml_domain_verifier.go b/pkg/iam/saml_domain_verifier.go index d21857cb9..b130c6223 100644 --- a/pkg/iam/saml_domain_verifier.go +++ b/pkg/iam/saml_domain_verifier.go @@ -32,6 +32,7 @@ import ( "go.gearno.de/kit/pg" "go.opentelemetry.io/otel/trace" "go.probo.inc/probo/pkg/coredata" + "go.probo.inc/probo/pkg/dnsverify" "go.probo.inc/probo/pkg/gid" ) @@ -227,7 +228,7 @@ func (v *SAMLDomainVerifier) checkDNSTXTRecord(ctx context.Context, emailDomain for _, answer := range resp.Answer { txt, ok := answer.(*dns.TXT) - if !ok { + if !ok || !dnsverify.EqualNames(txt.Hdr.Name, emailDomain) { continue }