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

@@ -24,25 +24,23 @@ import (
"context"
"errors"
"fmt"
"strings"
"time"
"codeberg.org/miekg/dns"
"go.gearno.de/kit/log"
"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/dnsclient"
"go.probo.inc/probo/pkg/gid"
)
type (
SAMLDomainVerifier struct {
pg *pg.Client
interval time.Duration
resolverAddr string
logger *log.Logger
tracer trace.Tracer
pg *pg.Client
interval time.Duration
dnsClient *dnsclient.Client
logger *log.Logger
tracer trace.Tracer
}
)
@@ -63,11 +61,11 @@ func NewSAMLDomainVerifier(
resolverAddr string,
) *SAMLDomainVerifier {
return &SAMLDomainVerifier{
pg: pgClient,
interval: interval,
resolverAddr: resolverAddr,
logger: logger.Named("saml-domain-verifier"),
tracer: tp.Tracer("go.probo.inc/probo/pkg/iam/saml_domain_verifier"),
pg: pgClient,
interval: interval,
dnsClient: dnsclient.NewClient(resolverAddr),
logger: logger.Named("saml-domain-verifier"),
tracer: tp.Tracer("go.probo.inc/probo/pkg/iam/saml_domain_verifier"),
}
}
@@ -202,40 +200,18 @@ func (v *SAMLDomainVerifier) tryVerifyDomain(ctx context.Context, configID gid.G
}
func (v *SAMLDomainVerifier) checkDNSTXTRecord(ctx context.Context, emailDomain string, expectedValue string) error {
msg := dns.NewMsg(emailDomain, dns.TypeTXT)
client := dns.NewClient()
resp, _, err := client.Exchange(ctx, msg, "udp", v.resolverAddr)
if err != nil {
return fmt.Errorf("cannot query TXT record for %q: %w", emailDomain, err)
err := v.dnsClient.CheckTXT(ctx, emailDomain, expectedValue)
if err == nil {
return nil
}
if resp.Truncated {
resp, _, err = client.Exchange(ctx, msg, "tcp", v.resolverAddr)
if err != nil {
return fmt.Errorf("cannot query TXT record for %q over TCP: %w", emailDomain, err)
}
}
if resp.Rcode != dns.RcodeSuccess {
return fmt.Errorf("cannot query TXT record for %q: %s", emailDomain, dns.RcodeToString[resp.Rcode])
}
if len(resp.Answer) == 0 {
if errors.Is(err, dnsclient.ErrTXTNotFound) {
return fmt.Errorf("%w for %q", errDomainTXTRecordNotFound, emailDomain)
}
for _, answer := range resp.Answer {
txt, ok := answer.(*dns.TXT)
if !ok || !dnsverify.EqualNames(txt.Hdr.Name, emailDomain) {
continue
}
if strings.Join(txt.Txt, "") == expectedValue {
return nil
}
if errors.Is(err, dnsclient.ErrTXTMismatch) {
return fmt.Errorf("%w for %q", errDomainTXTRecordMismatch, emailDomain)
}
return fmt.Errorf("%w for %q", errDomainTXTRecordMismatch, emailDomain)
return err
}