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

288
pkg/dnsclient/caa.go Normal file
View 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:]
}

View 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
View 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
View 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
View 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")
)

79
pkg/dnsclient/names.go Normal file
View File

@@ -0,0 +1,79 @@
// 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 (
"fmt"
"strings"
)
// 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)
}
// 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 CAA hostnames: empty hostname")
}
names := []string{hostname}
current := hostname
for {
dot := strings.Index(current, ".")
if dot < 0 {
break
}
current = current[dot+1:]
if current == "" {
break
}
names = append(names, current)
}
return names, nil
}

108
pkg/dnsclient/names_test.go Normal file
View 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
View 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)
}