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
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")
|
||||
)
|
||||
79
pkg/dnsclient/names.go
Normal file
79
pkg/dnsclient/names.go
Normal 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
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)
|
||||
}
|
||||
Reference in New Issue
Block a user