Skip to content

caa issue/issuewild parameter tags: mixed-case accounturi/validationmethods treated as absent #8614

@1seal

Description

@1seal

summary

in boulder’s caa processing, parameter tags inside issue / issuewild values are parsed without case normalization, but later compared using exact string matches for the standardized parameters accounturi and validationmethods.

as a result, a caa record that uses mixed-case parameter tags (e.g., AccountURI= or ValidationMethods=) will be treated as if those parameters are absent. this is a sharp edge because it can silently defeat operator intent (misconfiguration hazard), rather than failing closed.

pins

  • repo: github.com/letsencrypt/boulder
  • pinned commit: 0bc6a5856338847a7f2a6f2763cc374e9c00b0df
  • as-of: 2026-01-30

impacted code

  • va/caa.go (parseCAARecord, caaAccountURIMatches, caaValidationMethodMatches)

repro (deterministic local harness)

the harness is a single go test file + a 1-line patch.

  1. clone boulder and checkout the pinned commit:
git clone https://github.com/letsencrypt/boulder
cd boulder
git checkout 0bc6a5856338847a7f2a6f2763cc374e9c00b0df
  1. add the following file as va/zz_caa_param_tag_case_test.go:
package va

import (
	"context"
	"strings"
	"testing"

	"github.com/letsencrypt/boulder/bdns"
	"github.com/letsencrypt/boulder/core"
	"github.com/letsencrypt/boulder/identifier"
	"github.com/miekg/dns"
)

type caaParamTagCaseDNS struct{ bdns.Client }

func (d *caaParamTagCaseDNS) LookupCAA(_ context.Context, domain string) (*bdns.Result[*dns.CAA], string, error) {
	var records []*dns.CAA
	trimmed := strings.TrimRight(domain, ".")
	record := &dns.CAA{Tag: "issue"}

	switch trimmed {
	case "caseparam.com":
		// mixed-case accounturi tag: intended to bind issuance to account 123.
		record.Value = "letsencrypt.org; AccountURI=https://letsencrypt.org/acct/reg/123; validationmethods=http-01"
		records = append(records, record)
	case "caseparam-control.com":
		// control: same semantics but with lowercase parameter tags.
		record.Value = "letsencrypt.org; accounturi=https://letsencrypt.org/acct/reg/123; validationmethods=http-01"
		records = append(records, record)
	case "casevm.com":
		// mixed-case validationmethods tag: intended to restrict issuance to dns-01 only.
		record.Value = "letsencrypt.org; ValidationMethods=dns-01"
		records = append(records, record)
	case "casevm-control.com":
		// control: lowercase validationmethods tag (dns-01 only).
		record.Value = "letsencrypt.org; validationmethods=dns-01"
		records = append(records, record)
	default:
		// no records
	}

	return &bdns.Result[*dns.CAA]{Final: records}, "caaParamTagCaseDNS", nil
}

func TestCAAParamTagCase_ControlBaseline_LowercaseAccountURI(t *testing.T) {
	t.Helper()

	// baseline: accounturi binding should fail closed when the account does not
	// match the configured accounturi parameter.
	va, _ := setup(nil, "", nil, &caaParamTagCaseDNS{})
	params := &caaParams{
		accountURIID:     321, // does not match the "acct/reg/123" restriction
		validationMethod: core.ChallengeTypeHTTP01,
	}

	err := va.checkCAA(ctx, identifier.NewDNS("caseparam-control.com"), params)
	if err == nil {
		t.Fatalf("unexpected caa allow for caseparam-control.com with mismatched accounturi (expected fail closed)")
	}

	t.Logf("[NC_MARKER]: lowercase accounturi enforces as expected")
}

func TestCAAParamTagCase_Canonical_AccountURICaseBypass(t *testing.T) {
	t.Helper()
	t.Logf("[CALLSITE_HIT]: checkCAA::parseCAARecord->caaAccountURIMatches")

	// current behavior: mixed-case parameter tags are treated as unknown, so
	// accounturi binding is skipped when the record uses "AccountURI=".
	va, _ := setup(nil, "", nil, &caaParamTagCaseDNS{})
	params := &caaParams{
		accountURIID:     321, // does not match the restriction
		validationMethod: core.ChallengeTypeHTTP01,
	}

	err := va.checkCAA(ctx, identifier.NewDNS("caseparam.com"), params)
	if err != nil {
		t.Fatalf("unexpected caa deny for caseparam.com (this indicates the behavior changed): %v", err)
	}

	t.Logf("[PROOF_MARKER]: mixed_case_accounturi_treated_as_absent=true")
	t.Logf("[IMPACT_MARKER]: account_binding_skipped=true requested_account=321 bound_account=123")
}

func TestCAAParamTagCase_ControlBaseline_LowercaseValidationMethods(t *testing.T) {
	t.Helper()

	// baseline: validationmethods binding should fail closed when the method does
	// not match the configured validationmethods parameter.
	va, _ := setup(nil, "", nil, &caaParamTagCaseDNS{})
	params := &caaParams{
		accountURIID:     123,
		validationMethod: core.ChallengeTypeHTTP01,
	}

	err := va.checkCAA(ctx, identifier.NewDNS("casevm-control.com"), params)
	if err == nil {
		t.Fatalf("unexpected caa allow for casevm-control.com with mismatched validation method (expected fail closed)")
	}

	t.Logf("[NC_MARKER]: lowercase validationmethods enforces as expected")
}

func TestCAAParamTagCase_Canonical_ValidationMethodsCaseBypass(t *testing.T) {
	t.Helper()
	t.Logf("[CALLSITE_HIT]: checkCAA::parseCAARecord->caaValidationMethodMatches")

	// current behavior: mixed-case parameter tags are treated as unknown, so the
	// validation method restriction is skipped when the record uses "ValidationMethods=".
	va, _ := setup(nil, "", nil, &caaParamTagCaseDNS{})
	params := &caaParams{
		accountURIID:     123,
		validationMethod: core.ChallengeTypeHTTP01,
	}

	err := va.checkCAA(ctx, identifier.NewDNS("casevm.com"), params)
	if err != nil {
		t.Fatalf("unexpected caa deny for casevm.com (this indicates the behavior changed): %v", err)
	}

	t.Logf("[PROOF_MARKER]: mixed_case_validationmethods_treated_as_absent=true intended=dns-01 used=http-01")
	t.Logf("[IMPACT_MARKER]: validation_method_restriction_skipped=true intended=dns-01 used=http-01")
}
  1. run:
go test ./va -run '^TestCAAParamTagCase_' -count=1 -v

expected results on the pinned commit: the *_Canonical_* tests pass and emit PROOF_MARKER (mixed-case tags treated as absent), while the *_ControlBaseline_* tests fail closed as expected for lowercase tags.

proposed fix

normalize parsed parameter tags to lowercase in parseCAARecord before matching, so AccountURI= behaves the same as accounturi= (and similarly for ValidationMethods=).

minimal patch (for discussion):

diff --git a/va/caa.go b/va/caa.go
index 475aa57b..0872f067 100644
--- a/va/caa.go
+++ b/va/caa.go
@@ -449,6 +449,8 @@ func parseCAARecord(caa *dns.CAA) (string, []caaParameter, error) {
 			}
 		}

+		tag = strings.ToLower(tag)
+
 		caaParameters = append(caaParameters, caaParameter{
 			tag: tag,
 			val: value,

acceptance criteria

  • decide whether boulder should treat standardized parameter tags (accounturi, validationmethods) case-insensitively (hardening), or keep current behavior but explicitly document that mixed-case parameter tags are ignored
  • add a regression test covering mixed-case accounturi / validationmethods parameter tags

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions