Harden DNS CAA and TXT verification

Extract shared DNS checks into dnsclient and fail closed on
truncated or non-success CAA responses. Climb past eTLD+1,
validate RFC 8659 issue-value syntax, and map NXDOMAIN TXT
lookups to the pending-verification path.

Signed-off-by: Cursor Agent <cursoragent@cursor.com>

Co-authored-by: Bryan FRIMIN <bryan@frimin.fr>
This commit is contained in:
Cursor Agent
2026-07-24 20:20:05 +00:00
committed by Bryan Frimin
parent ec65b54583
commit 64ef051d18
11 changed files with 1214 additions and 240 deletions

View File

@@ -21,7 +21,6 @@ import (
"strings"
"time"
"codeberg.org/miekg/dns"
"go.gearno.de/kit/log"
"go.gearno.de/kit/pg"
"go.gearno.de/kit/worker"
@@ -31,7 +30,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/dnsclient"
"go.probo.inc/probo/pkg/gid"
"golang.org/x/crypto/acme"
)
@@ -70,7 +69,7 @@ type (
cnameTarget string
caaIssuerDomain string
resolverAddr string
dnsClient *dnsclient.Client
managedBaseDomain string
}
@@ -113,7 +112,7 @@ func NewBeginChallengeWorker(
},
cnameTarget: cnameTarget,
caaIssuerDomain: caaIssuerDomain,
resolverAddr: resolverAddr,
dnsClient: dnsclient.NewClient(resolverAddr),
managedBaseDomain: managedBaseDomain,
}
@@ -207,7 +206,10 @@ func (h *beginChallengeHandler) Process(ctx context.Context, certificate coredat
dnsCtx, dnsSpan := h.tracer.Start(ctx, "certmanager.dns_check")
dnsStarted := time.Now()
if err := h.checkDNSConfiguration(dnsCtx, certificate.Hostname); err != nil {
cnameCtx, cnameCancel := context.WithTimeout(dnsCtx, dnsExchangeTimeout)
err := h.dnsClient.CheckCNAME(cnameCtx, certificate.Hostname, h.cnameTarget)
cnameCancel()
if err != nil {
h.acmeService.metrics.observeStep(provisionPhaseDNSCheck, provisionResultDNSError, dnsStarted)
h.recordSpanError(dnsSpan, err, classifyProvisioningError(err))
dnsSpan.End()
@@ -345,116 +347,17 @@ func (h *beginChallengeHandler) loadSkipDNSChecks(ctx context.Context, hostname
return skip, nil
}
func (h *beginChallengeHandler) checkDNSConfiguration(ctx context.Context, hostname string) error {
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}}}
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: %w", err)
}
if len(resp.Answer) == 0 {
return fmt.Errorf("no cname records found for domain %q", hostname)
}
if len(resp.Answer) > 1 {
return fmt.Errorf("multiple cname records found for domain %q", hostname)
}
resolvedRecord, ok := resp.Answer[0].(*dns.CNAME)
if !ok {
return fmt.Errorf("first answer is not a cname record for domain %q", hostname)
}
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,
resolvedRecord.Target,
expectedFQDN,
)
}
return nil
}
func (h *beginChallengeHandler) checkCAARecords(ctx context.Context, hostname string) error {
checkNames, err := dnsverify.CheckNames(hostname)
if err != nil {
return err
err := h.dnsClient.CheckCAA(ctx, hostname, h.caaIssuerDomain)
if err == nil {
return nil
}
dnsCtx, cancel := context.WithTimeout(ctx, dnsExchangeTimeout)
defer cancel()
client := dns.NewClient()
for _, checkName := range checkNames {
fqdn := dnsverify.ToFQDN(checkName)
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
}
caaRecords = append(caaRecords, caa)
}
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
}
}
}
return fmt.Errorf(
"%w: domain %q by %q",
ErrCAANotPermitted,
hostname,
h.caaIssuerDomain,
)
if errors.Is(err, dnsclient.ErrCAADenied) {
return fmt.Errorf("%w: domain %q by %q", ErrCAANotPermitted, hostname, h.caaIssuerDomain)
}
return nil
return err
}
func (h *beginChallengeHandler) skipsDNSChecks(