-
-
Notifications
You must be signed in to change notification settings - Fork 635
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.
- clone boulder and checkout the pinned commit:
git clone https://github.com/letsencrypt/boulder
cd boulder
git checkout 0bc6a5856338847a7f2a6f2763cc374e9c00b0df- 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")
}- run:
go test ./va -run '^TestCAAParamTagCase_' -count=1 -vexpected 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/validationmethodsparameter tags