Verify DNS records on the queried child domain
Custom-domain CNAME and SAML TXT checks accepted answers without confirming the record owner matched the hostname being verified, so an apex record could satisfy verification for a subdomain. CAA checks also looked only at the exact hostname and ignored parent policy. Add dnsverify helpers to compare owner names and walk from the requested hostname up to the registrable apex for CAA (RFC 6844). Require matching record owners for CNAME and TXT answers. Signed-off-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: Bryan FRIMIN <bryan@frimin.fr>
This commit is contained in:
committed by
Bryan Frimin
parent
d100826479
commit
002437b568
@@ -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(
|
||||
|
||||
78
pkg/dnsverify/names.go
Normal file
78
pkg/dnsverify/names.go
Normal file
@@ -0,0 +1,78 @@
|
||||
// Copyright (c) 2025-2026 Probo Inc <hello@probo.com>.
|
||||
//
|
||||
// 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
|
||||
}
|
||||
72
pkg/dnsverify/names_test.go
Normal file
72
pkg/dnsverify/names_test.go
Normal file
@@ -0,0 +1,72 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||
//
|
||||
// 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,
|
||||
)
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user