letsencrypt / letsencrypt/boulder

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

Open
#8,614 4 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

Dominant language
Go
Stars
5.8k
Forks
649
Avg merge
3d 23h
Merged PRs (30d)
24

Description

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

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start in va/caa.go at parseCAARecord, then trace caaAccountURIMatches and caaValidationMethodMatches. Add va/zz_caa_param_tag_case_test.go from the issue and run go test ./va -run '^TestCAAParamTagCase_' -count=1 -v; done means mixed-case accounturi and validationmethods enforce the same restrictions as lowercase tags.

Written by the indexing model from the issue text.

Assessment

Tech stack
go
Domain
security
Issue type
Bug
Difficulty
2/5
Estimated time
1-3 hours
Activity status
Stale
Clarity
Clearly specified
Newbie friendliness
52/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.