Files
probo/pkg/dnsclient/caa.go
Cursor Agent f844933b7d Fix wsl blank lines in DNS check paths
Separate cancel calls, error checks, and declarations so
golangci wsl_v5 accepts the cuddling rules.

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

Co-authored-by: Bryan FRIMIN <bryan@frimin.fr>
2026-07-24 23:08:38 +02:00

295 lines
6.7 KiB
Go

// 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}}}
// Each label gets its own exchange budget so a slow empty answer at a
// child name cannot starve the parent lookup that holds the policy.
queryCtx, cancel := c.withExchangeTimeout(ctx)
resp, err := c.query(queryCtx, msg)
cancel()
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.SplitSeq(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:]
}