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:
committed by
Bryan Frimin
parent
ec65b54583
commit
64ef051d18
@@ -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(
|
||||
|
||||
288
pkg/dnsclient/caa.go
Normal file
288
pkg/dnsclient/caa.go
Normal file
@@ -0,0 +1,288 @@
|
||||
// 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 dnsclient
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"codeberg.org/miekg/dns"
|
||||
)
|
||||
|
||||
// CheckCAA verifies that CAA policy from hostname up through each parent
|
||||
// toward the DNS root permits non-wildcard issuance by permittedIssuer
|
||||
// (RFC 8659). Evaluation stops at the first non-empty CAA RRset.
|
||||
func (c *Client) CheckCAA(ctx context.Context, hostname, permittedIssuer string) error {
|
||||
checkNames, err := HostnamesForCAA(hostname)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, checkName := range checkNames {
|
||||
fqdn := 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 := c.query(ctx, msg)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot exchange dns message for caa records: %w", err)
|
||||
}
|
||||
|
||||
if resp.Rcode != dns.RcodeSuccess {
|
||||
return fmt.Errorf(
|
||||
"cannot query caa records for %q: %s",
|
||||
checkName,
|
||||
dns.RcodeToString[resp.Rcode],
|
||||
)
|
||||
}
|
||||
|
||||
caaRecords := caaRecordsOwnedBy(resp, fqdn)
|
||||
if len(caaRecords) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
if caaPermitsIssuer(caaRecords, permittedIssuer) {
|
||||
return nil
|
||||
}
|
||||
|
||||
return fmt.Errorf("%w: domain %q by %q", ErrCAADenied, hostname, permittedIssuer)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func caaRecordsOwnedBy(resp *dns.Msg, owner string) []*dns.CAA {
|
||||
var records []*dns.CAA
|
||||
|
||||
for _, rr := range resp.Answer {
|
||||
caa, ok := rr.(*dns.CAA)
|
||||
if !ok || !EqualNames(caa.Hdr.Name, owner) {
|
||||
continue
|
||||
}
|
||||
|
||||
records = append(records, caa)
|
||||
}
|
||||
|
||||
return records
|
||||
}
|
||||
|
||||
// caaPermitsIssuer reports whether a non-empty CAA RRset permits ordinary
|
||||
// (non-wildcard) issuance by permittedIssuer per RFC 8659.
|
||||
//
|
||||
// Relevant rules for non-wildcard requests:
|
||||
// - Critical unrecognized tags deny issuance.
|
||||
// - Only "issue" properties authorize (case-insensitive tag match).
|
||||
// - "issuewild" is ignored (neither authorizes nor denies).
|
||||
// - Other non-critical tags (e.g. iodef) are ignored.
|
||||
// - If no "issue" property is present, issuance is permitted.
|
||||
// - An empty or malformed "issue" value is treated as an empty
|
||||
// issuer-domain-name and does not authorize any issuer.
|
||||
func caaPermitsIssuer(records []*dns.CAA, permittedIssuer string) bool {
|
||||
var issueValues []string
|
||||
|
||||
for _, caa := range records {
|
||||
switch {
|
||||
case strings.EqualFold(caa.Tag, "issue"):
|
||||
issueValues = append(issueValues, caa.Value)
|
||||
case strings.EqualFold(caa.Tag, "issuewild"):
|
||||
// issuewild applies only to wildcard issuance.
|
||||
default:
|
||||
if caa.Flag&1 != 0 {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(issueValues) == 0 {
|
||||
return true
|
||||
}
|
||||
|
||||
for _, value := range issueValues {
|
||||
issuer, ok := parseCAAIssueValue(value)
|
||||
if !ok || issuer == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
if strings.EqualFold(issuer, permittedIssuer) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// parseCAAIssueValue parses an RFC 8659 issue / issuewild property value.
|
||||
// Malformed values return ok=false and must be treated like an empty
|
||||
// issuer-domain-name (no authorization from that property).
|
||||
func parseCAAIssueValue(value string) (issuer string, ok bool) {
|
||||
s := trimCAALeadingWSP(value)
|
||||
|
||||
if s != "" && s[0] != ';' {
|
||||
end := 0
|
||||
for end < len(s) && !isCAAWSP(s[end]) && s[end] != ';' {
|
||||
end++
|
||||
}
|
||||
|
||||
issuer = s[:end]
|
||||
if !isCAAIssuerDomainName(issuer) {
|
||||
return "", false
|
||||
}
|
||||
|
||||
s = trimCAALeadingWSP(s[end:])
|
||||
}
|
||||
|
||||
if s == "" {
|
||||
return issuer, true
|
||||
}
|
||||
|
||||
if s[0] != ';' {
|
||||
return "", false
|
||||
}
|
||||
|
||||
s = trimCAALeadingWSP(s[1:])
|
||||
if s == "" {
|
||||
return issuer, true
|
||||
}
|
||||
|
||||
if !consumeCAAParameters(s) {
|
||||
return "", false
|
||||
}
|
||||
|
||||
return issuer, true
|
||||
}
|
||||
|
||||
func consumeCAAParameters(s string) bool {
|
||||
for {
|
||||
rest, ok := consumeCAAParameter(s)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
|
||||
s = trimCAALeadingWSP(rest)
|
||||
if s == "" {
|
||||
return true
|
||||
}
|
||||
|
||||
if s[0] != ';' {
|
||||
return false
|
||||
}
|
||||
|
||||
s = trimCAALeadingWSP(s[1:])
|
||||
if s == "" {
|
||||
// Trailing ";" with no following parameter is not in the ABNF.
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func consumeCAAParameter(s string) (string, bool) {
|
||||
if s == "" {
|
||||
return "", false
|
||||
}
|
||||
|
||||
tagEnd := 0
|
||||
for tagEnd < len(s) && !isCAAWSP(s[tagEnd]) && s[tagEnd] != '=' {
|
||||
tagEnd++
|
||||
}
|
||||
|
||||
if tagEnd == 0 || !isCAAIssuerLabel(s[:tagEnd]) {
|
||||
return "", false
|
||||
}
|
||||
|
||||
s = trimCAALeadingWSP(s[tagEnd:])
|
||||
if s == "" || s[0] != '=' {
|
||||
return "", false
|
||||
}
|
||||
|
||||
s = trimCAALeadingWSP(s[1:])
|
||||
|
||||
valueEnd := 0
|
||||
for valueEnd < len(s) && isCAAParameterValueByte(s[valueEnd]) {
|
||||
valueEnd++
|
||||
}
|
||||
|
||||
return s[valueEnd:], true
|
||||
}
|
||||
|
||||
func isCAAIssuerDomainName(name string) bool {
|
||||
if name == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
labels := strings.Split(name, ".")
|
||||
for _, label := range labels {
|
||||
if !isCAAIssuerLabel(label) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
func isCAAIssuerLabel(label string) bool {
|
||||
if label == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
// label = (ALPHA / DIGIT) *( *("-") (ALPHA / DIGIT) )
|
||||
if !isCAAAlphaNum(label[0]) {
|
||||
return false
|
||||
}
|
||||
|
||||
i := 1
|
||||
for i < len(label) {
|
||||
for i < len(label) && label[i] == '-' {
|
||||
i++
|
||||
}
|
||||
|
||||
if i >= len(label) || !isCAAAlphaNum(label[i]) {
|
||||
return false
|
||||
}
|
||||
|
||||
i++
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
func isCAAParameterValueByte(b byte) bool {
|
||||
// value = *(%x21-3A / %x3C-7E) — printable ASCII except space and ";".
|
||||
return (b >= 0x21 && b <= 0x3A) || (b >= 0x3C && b <= 0x7E)
|
||||
}
|
||||
|
||||
func isCAAAlphaNum(b byte) bool {
|
||||
return (b >= 'A' && b <= 'Z') || (b >= 'a' && b <= 'z') || (b >= '0' && b <= '9')
|
||||
}
|
||||
|
||||
func isCAAWSP(b byte) bool {
|
||||
return b == ' ' || b == '\t'
|
||||
}
|
||||
|
||||
func trimCAALeadingWSP(s string) string {
|
||||
i := 0
|
||||
for i < len(s) && isCAAWSP(s[i]) {
|
||||
i++
|
||||
}
|
||||
|
||||
return s[i:]
|
||||
}
|
||||
496
pkg/dnsclient/checks_test.go
Normal file
496
pkg/dnsclient/checks_test.go
Normal file
@@ -0,0 +1,496 @@
|
||||
// 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 dnsclient
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"codeberg.org/miekg/dns"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestCheckCNAME(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("accepts matching owner and target", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client := &Client{
|
||||
exchange: func(_ context.Context, msg *dns.Msg, _ string) (*dns.Msg, error) {
|
||||
cname := &dns.CNAME{Hdr: dns.Header{Name: msg.Question[0].Header().Name}}
|
||||
cname.Target = "custom.getprobo.com."
|
||||
|
||||
return &dns.Msg{Answer: []dns.RR{cname}}, nil
|
||||
},
|
||||
}
|
||||
|
||||
err := client.CheckCNAME(context.Background(), "trust.example.com", "custom.getprobo.com")
|
||||
|
||||
require.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("rejects apex owned record for subdomain query", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client := &Client{
|
||||
exchange: func(_ context.Context, _ *dns.Msg, _ string) (*dns.Msg, error) {
|
||||
cname := &dns.CNAME{Hdr: dns.Header{Name: "example.com."}}
|
||||
cname.Target = "custom.getprobo.com."
|
||||
|
||||
return &dns.Msg{Answer: []dns.RR{cname}}, nil
|
||||
},
|
||||
}
|
||||
|
||||
err := client.CheckCNAME(context.Background(), "trust.example.com", "custom.getprobo.com")
|
||||
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "cname owner mismatch")
|
||||
})
|
||||
|
||||
t.Run("retries over tcp when udp response is truncated", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var networks []string
|
||||
client := &Client{
|
||||
exchange: func(_ context.Context, msg *dns.Msg, network string) (*dns.Msg, error) {
|
||||
networks = append(networks, network)
|
||||
if network == "udp" {
|
||||
return &dns.Msg{
|
||||
MsgHeader: dns.MsgHeader{
|
||||
Rcode: dns.RcodeSuccess,
|
||||
Truncated: true,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
cname := &dns.CNAME{Hdr: dns.Header{Name: msg.Question[0].Header().Name}}
|
||||
cname.Target = "custom.getprobo.com."
|
||||
|
||||
return &dns.Msg{Answer: []dns.RR{cname}}, nil
|
||||
},
|
||||
}
|
||||
|
||||
err := client.CheckCNAME(context.Background(), "trust.example.com", "custom.getprobo.com")
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []string{"udp", "tcp"}, networks)
|
||||
})
|
||||
|
||||
t.Run("rejects response still truncated after tcp retry", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client := &Client{
|
||||
exchange: func(_ context.Context, _ *dns.Msg, _ string) (*dns.Msg, error) {
|
||||
cname := &dns.CNAME{Hdr: dns.Header{Name: "trust.example.com."}}
|
||||
cname.Target = "custom.getprobo.com."
|
||||
|
||||
return &dns.Msg{
|
||||
MsgHeader: dns.MsgHeader{
|
||||
Rcode: dns.RcodeSuccess,
|
||||
Truncated: true,
|
||||
},
|
||||
Answer: []dns.RR{cname},
|
||||
}, nil
|
||||
},
|
||||
}
|
||||
|
||||
err := client.CheckCNAME(context.Background(), "trust.example.com", "custom.getprobo.com")
|
||||
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "truncated")
|
||||
})
|
||||
}
|
||||
|
||||
func TestCheckTXT(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("ignores parent apex txt", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client := &Client{
|
||||
exchange: func(_ context.Context, _ *dns.Msg, _ string) (*dns.Msg, error) {
|
||||
txt := &dns.TXT{Hdr: dns.Header{Name: "example.com."}}
|
||||
txt.Txt = []string{"probo-verification=token"}
|
||||
|
||||
return &dns.Msg{Answer: []dns.RR{txt}}, nil
|
||||
},
|
||||
}
|
||||
|
||||
err := client.CheckTXT(context.Background(), "mail.example.com", "probo-verification=token")
|
||||
|
||||
require.Error(t, err)
|
||||
assert.ErrorIs(t, err, ErrTXTMismatch)
|
||||
})
|
||||
|
||||
t.Run("accepts txt on exact domain", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client := &Client{
|
||||
exchange: func(_ context.Context, _ *dns.Msg, _ string) (*dns.Msg, error) {
|
||||
txt := &dns.TXT{Hdr: dns.Header{Name: "example.com."}}
|
||||
txt.Txt = []string{"probo-verification=token"}
|
||||
|
||||
return &dns.Msg{Answer: []dns.RR{txt}}, nil
|
||||
},
|
||||
}
|
||||
|
||||
err := client.CheckTXT(context.Background(), "example.com", "probo-verification=token")
|
||||
|
||||
require.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("maps nxdomain to ErrTXTNotFound", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client := &Client{
|
||||
exchange: func(_ context.Context, _ *dns.Msg, _ string) (*dns.Msg, error) {
|
||||
return &dns.Msg{
|
||||
MsgHeader: dns.MsgHeader{Rcode: dns.RcodeNameError},
|
||||
}, nil
|
||||
},
|
||||
}
|
||||
|
||||
err := client.CheckTXT(context.Background(), "mail.example.com", "probo-verification=token")
|
||||
|
||||
require.Error(t, err)
|
||||
assert.ErrorIs(t, err, ErrTXTNotFound)
|
||||
})
|
||||
|
||||
t.Run("retries over tcp when udp response is truncated", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var networks []string
|
||||
client := &Client{
|
||||
exchange: func(_ context.Context, _ *dns.Msg, network string) (*dns.Msg, error) {
|
||||
networks = append(networks, network)
|
||||
if network == "udp" {
|
||||
return &dns.Msg{
|
||||
MsgHeader: dns.MsgHeader{
|
||||
Rcode: dns.RcodeSuccess,
|
||||
Truncated: true,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
txt := &dns.TXT{Hdr: dns.Header{Name: "example.com."}}
|
||||
txt.Txt = []string{"probo-verification=token"}
|
||||
|
||||
return &dns.Msg{
|
||||
MsgHeader: dns.MsgHeader{Rcode: dns.RcodeSuccess},
|
||||
Answer: []dns.RR{txt},
|
||||
}, nil
|
||||
},
|
||||
}
|
||||
|
||||
err := client.CheckTXT(context.Background(), "example.com", "probo-verification=token")
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []string{"udp", "tcp"}, networks)
|
||||
})
|
||||
}
|
||||
|
||||
func TestCaaPermitsIssuer(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("matching issue permits", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
records := []*dns.CAA{caaRecord("issue", "letsencrypt.org; accounturi=https://example.com", 0)}
|
||||
|
||||
assert.True(t, caaPermitsIssuer(records, "letsencrypt.org"))
|
||||
})
|
||||
|
||||
t.Run("non-matching issue denies", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
records := []*dns.CAA{caaRecord("issue", "letsencrypt.org", 0)}
|
||||
|
||||
assert.False(t, caaPermitsIssuer(records, "digicert.com"))
|
||||
})
|
||||
|
||||
t.Run("empty issue value denies", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
records := []*dns.CAA{caaRecord("issue", ";", 0)}
|
||||
|
||||
assert.False(t, caaPermitsIssuer(records, "letsencrypt.org"))
|
||||
})
|
||||
|
||||
t.Run("issue tag is case insensitive", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
records := []*dns.CAA{caaRecord("ISSUE", "LetsEncrypt.ORG", 0)}
|
||||
|
||||
assert.True(t, caaPermitsIssuer(records, "letsencrypt.org"))
|
||||
})
|
||||
|
||||
t.Run("only issuewild permits non-wildcard", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
records := []*dns.CAA{caaRecord("issuewild", "letsencrypt.org", 0)}
|
||||
|
||||
assert.True(t, caaPermitsIssuer(records, "digicert.com"))
|
||||
})
|
||||
|
||||
t.Run("iodef non-critical ignored with matching issue", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
records := []*dns.CAA{
|
||||
caaRecord("iodef", "mailto:security@example.com", 0),
|
||||
caaRecord("issue", "letsencrypt.org", 0),
|
||||
}
|
||||
|
||||
assert.True(t, caaPermitsIssuer(records, "letsencrypt.org"))
|
||||
})
|
||||
|
||||
t.Run("critical unknown tag denies even with matching issue", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
records := []*dns.CAA{
|
||||
caaRecord("issue", "letsencrypt.org", 0),
|
||||
caaRecord("unknown", "value", 1),
|
||||
}
|
||||
|
||||
assert.False(t, caaPermitsIssuer(records, "letsencrypt.org"))
|
||||
})
|
||||
|
||||
t.Run("critical issuewild is recognized and ignored for non-wildcard", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
records := []*dns.CAA{
|
||||
caaRecord("issuewild", "other.ca", 1),
|
||||
caaRecord("issue", "letsencrypt.org", 0),
|
||||
}
|
||||
|
||||
assert.True(t, caaPermitsIssuer(records, "letsencrypt.org"))
|
||||
})
|
||||
|
||||
t.Run("malformed issue value does not authorize issuer prefix", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
records := []*dns.CAA{
|
||||
caaRecord("issue", "letsencrypt.org; accounturi", 0),
|
||||
}
|
||||
|
||||
assert.False(t, caaPermitsIssuer(records, "letsencrypt.org"))
|
||||
})
|
||||
|
||||
t.Run("malformed issue value alone forbids issuance", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
records := []*dns.CAA{caaRecord("issue", "%%%%%", 0)}
|
||||
|
||||
assert.False(t, caaPermitsIssuer(records, "letsencrypt.org"))
|
||||
})
|
||||
|
||||
t.Run("valid issue alongside malformed still authorizes", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
records := []*dns.CAA{
|
||||
caaRecord("issue", "%%%%%", 0),
|
||||
caaRecord("issue", "letsencrypt.org; accounturi=https://example.com", 0),
|
||||
}
|
||||
|
||||
assert.True(t, caaPermitsIssuer(records, "letsencrypt.org"))
|
||||
})
|
||||
}
|
||||
|
||||
func TestCheckCAA(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("returns error on servfail", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client := &Client{
|
||||
exchange: func(_ context.Context, _ *dns.Msg, _ string) (*dns.Msg, error) {
|
||||
return &dns.Msg{
|
||||
MsgHeader: dns.MsgHeader{Rcode: dns.RcodeServerFailure},
|
||||
}, nil
|
||||
},
|
||||
}
|
||||
|
||||
err := client.CheckCAA(context.Background(), "trust.example.com", "letsencrypt.org")
|
||||
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "SERVFAIL")
|
||||
assert.NotErrorIs(t, err, ErrCAADenied)
|
||||
})
|
||||
|
||||
t.Run("retries over tcp when udp response is truncated", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var networks []string
|
||||
client := &Client{
|
||||
exchange: func(_ context.Context, msg *dns.Msg, network string) (*dns.Msg, error) {
|
||||
networks = append(networks, network)
|
||||
if network == "udp" {
|
||||
return &dns.Msg{
|
||||
MsgHeader: dns.MsgHeader{
|
||||
Rcode: dns.RcodeSuccess,
|
||||
Truncated: true,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
name := msg.Question[0].Header().Name
|
||||
caa := caaRecord("issue", "letsencrypt.org", 0)
|
||||
caa.Hdr.Name = name
|
||||
|
||||
return &dns.Msg{
|
||||
MsgHeader: dns.MsgHeader{Rcode: dns.RcodeSuccess},
|
||||
Answer: []dns.RR{caa},
|
||||
}, nil
|
||||
},
|
||||
}
|
||||
|
||||
err := client.CheckCAA(context.Background(), "trust.example.com", "letsencrypt.org")
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []string{"udp", "tcp"}, networks)
|
||||
})
|
||||
|
||||
t.Run("returns error when truncated after tcp retry", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client := &Client{
|
||||
exchange: func(_ context.Context, _ *dns.Msg, _ string) (*dns.Msg, error) {
|
||||
return &dns.Msg{
|
||||
MsgHeader: dns.MsgHeader{
|
||||
Rcode: dns.RcodeSuccess,
|
||||
Truncated: true,
|
||||
},
|
||||
}, nil
|
||||
},
|
||||
}
|
||||
|
||||
err := client.CheckCAA(context.Background(), "trust.example.com", "letsencrypt.org")
|
||||
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "truncated")
|
||||
})
|
||||
|
||||
t.Run("returns error on nxdomain", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client := &Client{
|
||||
exchange: func(_ context.Context, _ *dns.Msg, _ string) (*dns.Msg, error) {
|
||||
return &dns.Msg{
|
||||
MsgHeader: dns.MsgHeader{Rcode: dns.RcodeNameError},
|
||||
}, nil
|
||||
},
|
||||
}
|
||||
|
||||
err := client.CheckCAA(context.Background(), "trust.example.com", "letsencrypt.org")
|
||||
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "NXDOMAIN")
|
||||
})
|
||||
|
||||
t.Run("permits when first non-empty rrset allows issuer", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var queried []string
|
||||
client := &Client{
|
||||
exchange: func(_ context.Context, msg *dns.Msg, _ string) (*dns.Msg, error) {
|
||||
name := msg.Question[0].Header().Name
|
||||
queried = append(queried, name)
|
||||
if name != "example.com." {
|
||||
return &dns.Msg{MsgHeader: dns.MsgHeader{Rcode: dns.RcodeSuccess}}, nil
|
||||
}
|
||||
|
||||
caa := caaRecord("issue", "letsencrypt.org", 0)
|
||||
caa.Hdr.Name = name
|
||||
|
||||
return &dns.Msg{
|
||||
MsgHeader: dns.MsgHeader{Rcode: dns.RcodeSuccess},
|
||||
Answer: []dns.RR{caa},
|
||||
}, nil
|
||||
},
|
||||
}
|
||||
|
||||
err := client.CheckCAA(context.Background(), "trust.example.com", "letsencrypt.org")
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []string{"trust.example.com.", "example.com."}, queried)
|
||||
})
|
||||
|
||||
t.Run("denies when parent forbids after empty child", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var queried []string
|
||||
client := &Client{
|
||||
exchange: func(_ context.Context, msg *dns.Msg, _ string) (*dns.Msg, error) {
|
||||
name := msg.Question[0].Header().Name
|
||||
queried = append(queried, name)
|
||||
if name == "trust.example.com." {
|
||||
return &dns.Msg{MsgHeader: dns.MsgHeader{Rcode: dns.RcodeSuccess}}, nil
|
||||
}
|
||||
|
||||
caa := caaRecord("issue", "digicert.com", 0)
|
||||
caa.Hdr.Name = name
|
||||
|
||||
return &dns.Msg{
|
||||
MsgHeader: dns.MsgHeader{Rcode: dns.RcodeSuccess},
|
||||
Answer: []dns.RR{caa},
|
||||
}, nil
|
||||
},
|
||||
}
|
||||
|
||||
err := client.CheckCAA(context.Background(), "trust.example.com", "letsencrypt.org")
|
||||
|
||||
require.Error(t, err)
|
||||
assert.ErrorIs(t, err, ErrCAADenied)
|
||||
assert.Equal(t, []string{"trust.example.com.", "example.com."}, queried)
|
||||
})
|
||||
|
||||
t.Run("denies when first non-empty rrset forbids issuer", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client := &Client{
|
||||
exchange: func(_ context.Context, msg *dns.Msg, _ string) (*dns.Msg, error) {
|
||||
name := msg.Question[0].Header().Name
|
||||
caa := caaRecord("issue", "digicert.com", 0)
|
||||
caa.Hdr.Name = name
|
||||
|
||||
return &dns.Msg{
|
||||
MsgHeader: dns.MsgHeader{Rcode: dns.RcodeSuccess},
|
||||
Answer: []dns.RR{caa},
|
||||
}, nil
|
||||
},
|
||||
}
|
||||
|
||||
err := client.CheckCAA(context.Background(), "trust.example.com", "letsencrypt.org")
|
||||
|
||||
require.Error(t, err)
|
||||
assert.ErrorIs(t, err, ErrCAADenied)
|
||||
})
|
||||
}
|
||||
|
||||
func caaRecord(tag, value string, flag uint8) *dns.CAA {
|
||||
caa := &dns.CAA{Hdr: dns.Header{Name: "example.com."}}
|
||||
caa.Flag = flag
|
||||
caa.Tag = tag
|
||||
caa.Value = value
|
||||
|
||||
return caa
|
||||
}
|
||||
94
pkg/dnsclient/client.go
Normal file
94
pkg/dnsclient/client.go
Normal file
@@ -0,0 +1,94 @@
|
||||
// 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 dnsclient
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"codeberg.org/miekg/dns"
|
||||
)
|
||||
|
||||
type (
|
||||
// Client performs DNS lookups used to verify domain ownership and
|
||||
// certificate prerequisites.
|
||||
Client struct {
|
||||
ResolverAddr string
|
||||
exchange exchangeFunc
|
||||
}
|
||||
|
||||
exchangeFunc func(ctx context.Context, msg *dns.Msg, network string) (*dns.Msg, error)
|
||||
)
|
||||
|
||||
// NewClient returns a client that resolves names through resolverAddr.
|
||||
func NewClient(resolverAddr string) *Client {
|
||||
return &Client{ResolverAddr: resolverAddr}
|
||||
}
|
||||
|
||||
func (c *Client) exchangeUDP(ctx context.Context, msg *dns.Msg) (*dns.Msg, error) {
|
||||
if c.exchange != nil {
|
||||
return c.exchange(ctx, msg, "udp")
|
||||
}
|
||||
|
||||
client := dns.NewClient()
|
||||
|
||||
resp, _, err := client.Exchange(ctx, msg, "udp", c.ResolverAddr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func (c *Client) exchangeTCP(ctx context.Context, msg *dns.Msg) (*dns.Msg, error) {
|
||||
if c.exchange != nil {
|
||||
return c.exchange(ctx, msg, "tcp")
|
||||
}
|
||||
|
||||
client := dns.NewClient()
|
||||
|
||||
resp, _, err := client.Exchange(ctx, msg, "tcp", c.ResolverAddr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func (c *Client) query(ctx context.Context, msg *dns.Msg) (*dns.Msg, error) {
|
||||
resp, err := c.exchangeUDP(ctx, msg)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot exchange dns message: %w", err)
|
||||
}
|
||||
|
||||
if resp.Truncated {
|
||||
resp, err = c.exchangeTCP(ctx, msg)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot exchange dns message over TCP: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if resp.Truncated {
|
||||
return nil, fmt.Errorf("cannot exchange dns message: truncated response")
|
||||
}
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
76
pkg/dnsclient/cname.go
Normal file
76
pkg/dnsclient/cname.go
Normal file
@@ -0,0 +1,76 @@
|
||||
// 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 dnsclient
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"codeberg.org/miekg/dns"
|
||||
)
|
||||
|
||||
// CheckCNAME verifies that hostname has a single CNAME record owned by that
|
||||
// name and pointing at expectedTarget.
|
||||
func (c *Client) CheckCNAME(ctx context.Context, hostname, expectedTarget string) error {
|
||||
owner := ToFQDN(hostname)
|
||||
target := ToFQDN(expectedTarget)
|
||||
|
||||
msg := &dns.Msg{MsgHeader: dns.MsgHeader{ID: dns.ID(), RecursionDesired: true}}
|
||||
msg.Question = []dns.RR{&dns.CNAME{Hdr: dns.Header{Name: owner, Class: dns.ClassINET}}}
|
||||
|
||||
resp, err := c.query(ctx, msg)
|
||||
if err != nil {
|
||||
return 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 !EqualNames(resolvedRecord.Hdr.Name, owner) {
|
||||
return fmt.Errorf(
|
||||
"cname owner mismatch: domain %q has record owned by %q",
|
||||
hostname,
|
||||
strings.TrimSuffix(resolvedRecord.Hdr.Name, "."),
|
||||
)
|
||||
}
|
||||
|
||||
if !EqualNames(resolvedRecord.Target, target) {
|
||||
return fmt.Errorf(
|
||||
"cname target mismatch: domain %q resolves to %q, expected %q",
|
||||
hostname,
|
||||
strings.TrimSuffix(resolvedRecord.Target, "."),
|
||||
strings.TrimSuffix(expectedTarget, "."),
|
||||
)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
36
pkg/dnsclient/errors.go
Normal file
36
pkg/dnsclient/errors.go
Normal file
@@ -0,0 +1,36 @@
|
||||
// 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 dnsclient
|
||||
|
||||
import "errors"
|
||||
|
||||
var (
|
||||
// ErrCAADenied means a CAA policy at the hostname or a parent domain
|
||||
// forbids issuance by the requested certificate authority.
|
||||
ErrCAADenied = errors.New("caa records do not permit issuance")
|
||||
|
||||
// ErrTXTNotFound means no TXT record exists at the queried domain name.
|
||||
ErrTXTNotFound = errors.New("domain TXT record not found")
|
||||
|
||||
// ErrTXTMismatch means TXT records exist at the domain but none match the
|
||||
// expected verification value.
|
||||
ErrTXTMismatch = errors.New("domain TXT record mismatch")
|
||||
)
|
||||
@@ -1,4 +1,4 @@
|
||||
// Copyright (c) 2025-2026 Probo Inc <hello@probo.com>.
|
||||
// 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
|
||||
@@ -18,13 +18,11 @@
|
||||
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
// SOFTWARE.
|
||||
|
||||
package dnsverify
|
||||
package dnsclient
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/net/publicsuffix"
|
||||
)
|
||||
|
||||
// ToFQDN normalizes a DNS name to lowercase FQDN form with a trailing dot.
|
||||
@@ -45,33 +43,35 @@ 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) {
|
||||
// HostnamesForCAA returns the DNS names to evaluate for CAA, starting at the
|
||||
// exact hostname being verified and walking each parent label toward the root
|
||||
// (RFC 8659 tree climbing). The walk includes public-suffix / TLD labels and
|
||||
// stops after the final single label (e.g. "com"); the DNS root "." is omitted
|
||||
// because ToFQDN maps an empty name to "." awkwardly for queries. The first
|
||||
// entry is always the requested hostname itself.
|
||||
func HostnamesForCAA(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)
|
||||
if hostname == "" {
|
||||
return nil, fmt.Errorf("cannot build CAA hostnames: empty hostname")
|
||||
}
|
||||
|
||||
names := []string{hostname}
|
||||
current := hostname
|
||||
|
||||
for !strings.EqualFold(current, apex) {
|
||||
for {
|
||||
dot := strings.Index(current, ".")
|
||||
if dot < 0 {
|
||||
break
|
||||
}
|
||||
|
||||
current = current[dot+1:]
|
||||
if current == "" {
|
||||
break
|
||||
}
|
||||
|
||||
names = append(names, current)
|
||||
}
|
||||
|
||||
108
pkg/dnsclient/names_test.go
Normal file
108
pkg/dnsclient/names_test.go
Normal file
@@ -0,0 +1,108 @@
|
||||
// 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 dnsclient_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.probo.inc/probo/pkg/dnsclient"
|
||||
)
|
||||
|
||||
func TestEqualNames(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
assert.True(t, dnsclient.EqualNames("trust.example.com", "trust.example.com."))
|
||||
assert.True(t, dnsclient.EqualNames("Trust.Example.COM", "trust.example.com"))
|
||||
assert.False(t, dnsclient.EqualNames("trust.example.com", "example.com"))
|
||||
}
|
||||
|
||||
func TestHostnamesForCAA(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("subdomain walks past etld plus one through tld", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
names, err := dnsclient.HostnamesForCAA("trust.example.com")
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []string{"trust.example.com", "example.com", "com"}, names)
|
||||
})
|
||||
|
||||
t.Run("apex continues through tld", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
names, err := dnsclient.HostnamesForCAA("example.com")
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []string{"example.com", "com"}, names)
|
||||
})
|
||||
|
||||
t.Run("nested subdomain walks each parent through tld", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
names, err := dnsclient.HostnamesForCAA("portal.trust.example.com")
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(
|
||||
t,
|
||||
[]string{
|
||||
"portal.trust.example.com",
|
||||
"trust.example.com",
|
||||
"example.com",
|
||||
"com",
|
||||
},
|
||||
names,
|
||||
)
|
||||
})
|
||||
|
||||
t.Run("multi-label public suffix continues past etld plus one", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
names, err := dnsclient.HostnamesForCAA("app.example.co.uk")
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(
|
||||
t,
|
||||
[]string{"app.example.co.uk", "example.co.uk", "co.uk", "uk"},
|
||||
names,
|
||||
)
|
||||
})
|
||||
|
||||
t.Run("single label is only entry", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
names, err := dnsclient.HostnamesForCAA("com")
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []string{"com"}, names)
|
||||
})
|
||||
|
||||
t.Run("empty hostname errors", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
names, err := dnsclient.HostnamesForCAA(" ")
|
||||
|
||||
require.Error(t, err)
|
||||
assert.Nil(t, names)
|
||||
})
|
||||
}
|
||||
69
pkg/dnsclient/txt.go
Normal file
69
pkg/dnsclient/txt.go
Normal file
@@ -0,0 +1,69 @@
|
||||
// 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 dnsclient
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"codeberg.org/miekg/dns"
|
||||
)
|
||||
|
||||
// CheckTXT verifies that domain has a TXT record owned by that exact name whose
|
||||
// value equals expectedValue. Parent apex records are ignored.
|
||||
func (c *Client) CheckTXT(ctx context.Context, domain, expectedValue string) error {
|
||||
msg := dns.NewMsg(domain, dns.TypeTXT)
|
||||
|
||||
resp, err := c.query(ctx, msg)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot query TXT record for %q: %w", domain, err)
|
||||
}
|
||||
|
||||
if resp.Rcode == dns.RcodeNameError {
|
||||
return fmt.Errorf("%w for %q", ErrTXTNotFound, domain)
|
||||
}
|
||||
|
||||
if resp.Rcode != dns.RcodeSuccess {
|
||||
return fmt.Errorf(
|
||||
"cannot query TXT record for %q: %s",
|
||||
domain,
|
||||
dns.RcodeToString[resp.Rcode],
|
||||
)
|
||||
}
|
||||
|
||||
if len(resp.Answer) == 0 {
|
||||
return fmt.Errorf("%w for %q", ErrTXTNotFound, domain)
|
||||
}
|
||||
|
||||
for _, answer := range resp.Answer {
|
||||
txt, ok := answer.(*dns.TXT)
|
||||
if !ok || !EqualNames(txt.Hdr.Name, domain) {
|
||||
continue
|
||||
}
|
||||
|
||||
if strings.Join(txt.Txt, "") == expectedValue {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
return fmt.Errorf("%w for %q", ErrTXTMismatch, domain)
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
// 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,
|
||||
)
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user