Implement legacy form of CAA (#3075)

This implements the pre-erratum 5065 version of CAA, behind a feature flag.

This involved refactoring DNSClient.LookupCAA to return a list of CNAMEs in addition to the CAA records, and adding an alternate lookuper that does tree-climbing on single-depth aliases.
This commit is contained in:
Jacob Hoffman-Andrews 2017-09-13 07:16:12 -07:00 committed by Daniel McCarney
parent 2fb247488f
commit 4266853092
8 changed files with 224 additions and 25 deletions

View File

@ -147,7 +147,7 @@ var (
type DNSClient interface {
LookupTXT(context.Context, string) (txts []string, authorities []string, err error)
LookupHost(context.Context, string) ([]net.IP, error)
LookupCAA(context.Context, string) ([]*dns.CAA, error)
LookupCAA(context.Context, string) ([]*dns.CAA, []*dns.CNAME, error)
LookupMX(context.Context, string) ([]string, error)
}
@ -438,12 +438,13 @@ func (dnsClient *DNSClientImpl) LookupHost(ctx context.Context, hostname string)
}
// LookupCAA sends a DNS query to find all CAA records associated with
// the provided hostname.
func (dnsClient *DNSClientImpl) LookupCAA(ctx context.Context, hostname string) ([]*dns.CAA, error) {
// the provided hostname. It also returns all CNAMEs that were part of the
// response, for using in CAA tree climbing.
func (dnsClient *DNSClientImpl) LookupCAA(ctx context.Context, hostname string) ([]*dns.CAA, []*dns.CNAME, error) {
dnsType := dns.TypeCAA
r, err := dnsClient.exchangeOne(ctx, hostname, dnsType)
if err != nil {
return nil, &DNSError{dnsType, hostname, err, -1}
return nil, nil, &DNSError{dnsType, hostname, err, -1}
}
// If the resolver returns SERVFAIL for a certain list of FQDNs, return an
@ -452,24 +453,27 @@ func (dnsClient *DNSClientImpl) LookupCAA(ctx context.Context, hostname string)
// That is since fixed, but we have a handful of other domains that still return
// SERVFAIL, but will need certificate renewals. After a suitable notice
// period we will remove these exceptions.
var CAAs []*dns.CAA
if r.Rcode == dns.RcodeServerFailure {
if dnsClient.caaSERVFAILExceptions == nil ||
dnsClient.caaSERVFAILExceptions[hostname] {
return nil, nil
return nil, nil, nil
} else {
return nil, &DNSError{dnsType, hostname, nil, r.Rcode}
return nil, nil, &DNSError{dnsType, hostname, nil, r.Rcode}
}
}
var CAAs []*dns.CAA
var CNAMEs []*dns.CNAME
for _, answer := range r.Answer {
if answer.Header().Rrtype == dnsType {
if caaR, ok := answer.(*dns.CAA); ok {
CAAs = append(CAAs, caaR)
} else if cnameR, ok := answer.(*dns.CNAME); ok {
CNAMEs = append(CNAMEs, cnameR)
}
}
}
return CAAs, nil
return CAAs, CNAMEs, nil
}
// LookupMX sends a DNS query to find a MX record associated hostname and returns the

View File

@ -257,7 +257,7 @@ func TestDNSLookupsNoServer(t *testing.T) {
_, err = obj.LookupHost(context.Background(), "letsencrypt.org")
test.AssertError(t, err, "No servers")
_, err = obj.LookupCAA(context.Background(), "letsencrypt.org")
_, _, err = obj.LookupCAA(context.Background(), "letsencrypt.org")
test.AssertError(t, err, "No servers")
}
@ -273,18 +273,18 @@ func TestDNSServFail(t *testing.T) {
// CAA lookup ignores validation failures from the resolver for now
// and returns an empty list of CAA records.
emptyCaa, err := obj.LookupCAA(context.Background(), bad)
emptyCaa, _, err := obj.LookupCAA(context.Background(), bad)
test.Assert(t, len(emptyCaa) == 0, "Query returned non-empty list of CAA records")
test.AssertNotError(t, err, "LookupCAA returned an error")
// When we turn on enforceCAASERVFAIL, such lookups should fail.
obj.caaSERVFAILExceptions = map[string]bool{"servfailexception.example.com": true}
emptyCaa, err = obj.LookupCAA(context.Background(), bad)
emptyCaa, _, err = obj.LookupCAA(context.Background(), bad)
test.Assert(t, len(emptyCaa) == 0, "Query returned non-empty list of CAA records")
test.AssertError(t, err, "LookupCAA should have returned an error")
// Unless they are on the exception list
emptyCaa, err = obj.LookupCAA(context.Background(), "servfailexception.example.com")
emptyCaa, _, err = obj.LookupCAA(context.Background(), "servfailexception.example.com")
test.Assert(t, len(emptyCaa) == 0, "Query returned non-empty list of CAA records")
test.AssertNotError(t, err, "LookupCAA for servfail exception returned an error")
}
@ -390,15 +390,15 @@ func TestDNSNXDOMAIN(t *testing.T) {
func TestDNSLookupCAA(t *testing.T) {
obj := NewTestDNSClientImpl(time.Second*10, []string{dnsLoopbackAddr}, testStats, clock.NewFake(), 1)
caas, err := obj.LookupCAA(context.Background(), "bracewel.net")
caas, _, err := obj.LookupCAA(context.Background(), "bracewel.net")
test.AssertNotError(t, err, "CAA lookup failed")
test.Assert(t, len(caas) > 0, "Should have CAA records")
caas, err = obj.LookupCAA(context.Background(), "nonexistent.letsencrypt.org")
caas, _, err = obj.LookupCAA(context.Background(), "nonexistent.letsencrypt.org")
test.AssertNotError(t, err, "CAA lookup failed")
test.Assert(t, len(caas) == 0, "Shouldn't have CAA records")
caas, err = obj.LookupCAA(context.Background(), "cname.example.com")
caas, _, err = obj.LookupCAA(context.Background(), "cname.example.com")
test.AssertNotError(t, err, "CAA lookup failed")
test.Assert(t, len(caas) > 0, "Should follow CNAME to find CAA")
}

View File

@ -90,12 +90,61 @@ func (mock *MockDNSClient) LookupHost(_ context.Context, hostname string) ([]net
}
// LookupCAA returns mock records for use in tests.
func (mock *MockDNSClient) LookupCAA(_ context.Context, domain string) ([]*dns.CAA, error) {
func (mock *MockDNSClient) LookupCAA(_ context.Context, domain string) ([]*dns.CAA, []*dns.CNAME, error) {
var results []*dns.CAA
var record dns.CAA
switch strings.TrimRight(domain, ".") {
case "caa-timeout.com":
return nil, &DNSError{dns.TypeCAA, "always.timeout", MockTimeoutError(), -1}
return nil, nil, &DNSError{dns.TypeCAA, "always.timeout", MockTimeoutError(), -1}
case "deep-cname.not-present.com":
cnameRecord := new(dns.CNAME)
cnameRecord.Hdr = dns.RR_Header{Name: domain}
cnameRecord.Target = "target.not-present.com"
return nil, []*dns.CNAME{cnameRecord}, nil
case "deep-cname.present-with-parameter.com":
cnameRecord := new(dns.CNAME)
cnameRecord.Hdr = dns.RR_Header{Name: domain}
cnameRecord.Target = "cname-to-reserved.com"
return []*dns.CAA{
&dns.CAA{
Tag: "issue",
Value: "ca.com",
},
}, []*dns.CNAME{
&dns.CNAME{
Hdr: dns.RR_Header{Name: domain},
Target: "cname-to-reserved.com",
},
&dns.CNAME{
Hdr: dns.RR_Header{Name: "cname-to-reserved.com"},
Target: "reserved.com",
},
}, nil
case "blog.cname-to-subdomain.com":
cnameRecord := new(dns.CNAME)
cnameRecord.Hdr = dns.RR_Header{Name: domain}
cnameRecord.Target = "www.blog.cname-to-subdomain.com"
return nil, []*dns.CNAME{cnameRecord}, nil
case "cname-to-reserved.com":
cnameRecord := new(dns.CNAME)
cnameRecord.Hdr = dns.RR_Header{Name: domain}
cnameRecord.Target = "reserved.com"
return []*dns.CAA{
&dns.CAA{
Tag: "issue",
Value: "ca.com",
},
}, []*dns.CNAME{
&dns.CNAME{
Hdr: dns.RR_Header{Name: domain},
Target: "reserved.com",
},
}, nil
case "cname-to-child-of-reserved.com":
cnameRecord := new(dns.CNAME)
cnameRecord.Hdr = dns.RR_Header{Name: domain}
cnameRecord.Target = "www.reserved.com"
return nil, []*dns.CNAME{cnameRecord}, nil
case "reserved.com":
record.Tag = "issue"
record.Value = "ca.com"
@ -111,9 +160,9 @@ func (mock *MockDNSClient) LookupCAA(_ context.Context, domain string) ([]*dns.C
results = append(results, &record)
case "com":
// com has no CAA records.
return nil, nil
return nil, nil, nil
case "servfail.com", "servfail.present.com":
return results, fmt.Errorf("SERVFAIL")
return results, nil, fmt.Errorf("SERVFAIL")
case "multi-crit-present.com":
record.Flag = 1
record.Tag = "issue"
@ -146,9 +195,9 @@ func (mock *MockDNSClient) LookupCAA(_ context.Context, domain string) ([]*dns.C
record.Value = ";"
results = append(results, &record)
case "bad-local-resolver.com":
return nil, &DNSError{dns.TypeCAA, domain, MockTimeoutError(), -1}
return nil, nil, &DNSError{dns.TypeCAA, domain, MockTimeoutError(), -1}
}
return results, nil
return results, nil, nil
}
// LookupMX is a mock

View File

@ -4,9 +4,9 @@ package features
import "fmt"
const _FeatureFlag_name = "unusedAllowAccountDeactivationAllowKeyRolloverResubmitMissingSCTsOnlyUseAIAIssuerURLAllowTLS02ChallengesGenerateOCSPEarlyReusePendingAuthzCountCertificatesExactRandomDirectoryEntryIPv6FirstDirectoryMetaAllowRenewalFirstRLRecheckCAA"
const _FeatureFlag_name = "unusedAllowAccountDeactivationAllowKeyRolloverResubmitMissingSCTsOnlyUseAIAIssuerURLAllowTLS02ChallengesGenerateOCSPEarlyReusePendingAuthzCountCertificatesExactRandomDirectoryEntryIPv6FirstDirectoryMetaAllowRenewalFirstRLRecheckCAALegacyCAA"
var _FeatureFlag_index = [...]uint8{0, 6, 30, 46, 69, 84, 104, 121, 138, 160, 180, 189, 202, 221, 231}
var _FeatureFlag_index = [...]uint8{0, 6, 30, 46, 69, 84, 104, 121, 138, 160, 180, 189, 202, 221, 231, 240}
func (i FeatureFlag) String() string {
if i < 0 || i >= FeatureFlag(len(_FeatureFlag_index)-1) {

View File

@ -27,6 +27,7 @@ const (
DirectoryMeta
AllowRenewalFirstRL
RecheckCAA
LegacyCAA
)
// List of features and their default value, protected by fMu
@ -45,6 +46,7 @@ var features = map[FeatureFlag]bool{
DirectoryMeta: false,
AllowRenewalFirstRL: false,
RecheckCAA: false,
LegacyCAA: false,
}
var fMu = new(sync.RWMutex)

View File

@ -28,6 +28,7 @@
"ServerURL": "http://boulder:6000"
},
"features": {
"LegacyCAA": true,
"IPv6First": true
},
"remoteVAs": [

View File

@ -7,6 +7,7 @@ import (
"github.com/letsencrypt/boulder/core"
corepb "github.com/letsencrypt/boulder/core/proto"
"github.com/letsencrypt/boulder/features"
"github.com/letsencrypt/boulder/probs"
vapb "github.com/letsencrypt/boulder/va/proto"
"github.com/miekg/dns"
@ -116,7 +117,65 @@ func parseResults(results []caaResult) (*CAASet, error) {
return nil, nil
}
func (va *ValidationAuthorityImpl) parallelCAALookup(ctx context.Context, name string, lookuper func(context.Context, string) ([]*dns.CAA, error)) []caaResult {
// parentsDomains returns a list of all parent domains, in order from longest to
// shortest.
func parentDomains(fqdn string) []string {
var result []string
labels := strings.Split(strings.TrimRight(fqdn, "."), ".")
for i := 1; i < len(labels); i++ {
result = append(result, strings.Join(labels[i:], "."))
}
return result
}
// Implement pre-erratum 5065 style tree-climbing CAA. Note: a strict
// interpretation of pre-5065 indicates a linear lookup path - if there is any
// CNAME at all, that precludes further tree-climbing on the original FQDN. This
// is clearly wrong. We implement a hybrid approach that is strictly more
// conservative: We always do full tree-climbing on the original FQDN (by virtue
// of parallelCAALookup. When the LegacyCAA flag is enabled, we also
// do linear tree climbing on single-level aliases.
func (va *ValidationAuthorityImpl) treeClimbingLookupCAA(ctx context.Context, fqdn string) ([]*dns.CAA, error) {
// We will do an (arbitrary) maximum of 15 tree-climbing queries to avoid CNAME/CAA
// hybrid loops
maxAttempts := 15
return va.treeClimbingLookupCAAWithCount(ctx, fqdn, &maxAttempts)
}
func (va *ValidationAuthorityImpl) treeClimbingLookupCAAWithCount(ctx context.Context, fqdn string, attemptsRemaining *int) ([]*dns.CAA, error) {
if *attemptsRemaining < 1 {
return nil, fmt.Errorf("too many CNAMEs when looking up CAA")
}
*attemptsRemaining--
caas, cnames, err := va.dnsClient.LookupCAA(ctx, fqdn)
if err != nil {
return nil, err
} else if len(caas) > 0 {
return caas, nil
} else if len(cnames) > 0 {
// CNAMEs are returned in order from the original fqdn to the ultimate
// target. However, CAA wants us to check them in order from the ultimate
// target back to the original FQDN.
for i := len(cnames) - 1; i >= 0; i-- {
// Start the tree climbing directly with the parent domains of each
// target, because the target itself has already been queried by Unbound
// as part of the original LookupCAA, and any CNAMEs are already in this
// list.
newTargets := parentDomains(cnames[i].Target)
for _, newTarget := range newTargets {
caas, err := va.treeClimbingLookupCAAWithCount(ctx, newTarget, attemptsRemaining)
if len(caas) != 0 || err != nil {
return caas, err
}
}
}
}
return nil, nil
}
type lookuperFunc func(context.Context, string) ([]*dns.CAA, error)
func (va *ValidationAuthorityImpl) parallelCAALookup(ctx context.Context, name string, lookuper lookuperFunc) []caaResult {
labels := strings.Split(name, ".")
results := make([]caaResult, len(labels))
var wg sync.WaitGroup
@ -137,6 +196,14 @@ func (va *ValidationAuthorityImpl) parallelCAALookup(ctx context.Context, name s
func (va *ValidationAuthorityImpl) getCAASet(ctx context.Context, hostname string) (*CAASet, error) {
hostname = strings.TrimRight(hostname, ".")
lookuper := func(ctx context.Context, fqdn string) ([]*dns.CAA, error) {
caas, _, err := va.dnsClient.LookupCAA(ctx, fqdn)
return caas, err
}
if features.Enabled(features.LegacyCAA) {
lookuper = va.treeClimbingLookupCAA
}
// See RFC 6844 "Certification Authority Processing" for pseudocode.
// Essentially: check CAA records for the FDQN to be issued, and all
// parent domains.
@ -145,7 +212,7 @@ func (va *ValidationAuthorityImpl) getCAASet(ctx context.Context, hostname strin
// the RPC call.
//
// We depend on our resolver to snap CNAME and DNAME records.
results := va.parallelCAALookup(ctx, hostname, va.dnsClient.LookupCAA)
results := va.parallelCAALookup(ctx, hostname, lookuper)
return parseResults(results)
}

View File

@ -7,10 +7,86 @@ import (
"github.com/miekg/dns"
"github.com/letsencrypt/boulder/core"
"github.com/letsencrypt/boulder/features"
"github.com/letsencrypt/boulder/probs"
"github.com/letsencrypt/boulder/test"
)
func TestParentDomains(t *testing.T) {
pd := parentDomains("")
if len(pd) != 0 {
t.Errorf("Incorrect result from parentDomains(%q): %s", "", pd)
}
pd = parentDomains("com")
if len(pd) != 0 {
t.Errorf("Incorrect result from parentDomains(%q): %s", "com", pd)
}
pd = parentDomains("blog.example.com")
if len(pd) != 2 || pd[0] != "example.com" || pd[1] != "com" {
t.Errorf("Incorrect result from parentDomains(%q): %s", "blog.example.com", pd)
}
}
func TestTreeClimbNotPresent(t *testing.T) {
target := "deep-cname.not-present.com"
_ = features.Set(map[string]bool{"LegacyCAA": true})
va, _ := setup(nil, 0)
prob := va.checkCAA(ctx, core.AcmeIdentifier{Type: core.IdentifierDNS, Value: target})
if prob != nil {
t.Fatalf("Expected success for %q, got %s", target, prob)
}
}
func TestDeepTreeClimb(t *testing.T) {
// The ultimate target of the CNAME has a CAA record preventing issuance, but
// the parent of the FQDN has a CAA record permitting. The target of the CNAME
// takes precedence.
target := "deep-cname.present-with-parameter.com"
_ = features.Set(map[string]bool{"LegacyCAA": true})
va, _ := setup(nil, 0)
prob := va.checkCAA(ctx, core.AcmeIdentifier{Type: core.IdentifierDNS, Value: target})
if prob == nil {
t.Fatalf("Expected error for %q, got none", target)
}
}
func TestTreeClimbingLookupCAASimpleSuccess(t *testing.T) {
target := "www.present-with-parameter.com"
_ = features.Set(map[string]bool{"LegacyCAA": true})
va, _ := setup(nil, 0)
prob := va.checkCAA(ctx, core.AcmeIdentifier{Type: core.IdentifierDNS, Value: target})
if prob != nil {
t.Fatalf("Expected success for %q, got %s", target, prob)
}
}
func TestTreeClimbingLookupCAALimitHit(t *testing.T) {
target := "blog.cname-to-subdomain.com"
_ = features.Set(map[string]bool{"LegacyCAA": true})
va, _ := setup(nil, 0)
prob := va.checkCAA(ctx, core.AcmeIdentifier{Type: core.IdentifierDNS, Value: target})
if prob == nil {
t.Fatalf("Expected failure for %q, got success", target)
}
}
func TestCNAMEToReserved(t *testing.T) {
target := "cname-to-reserved.com"
_ = features.Set(map[string]bool{"LegacyCAA": true})
va, _ := setup(nil, 0)
prob := va.checkCAA(ctx, core.AcmeIdentifier{Type: core.IdentifierDNS, Value: target})
if prob == nil {
t.Fatalf("Expected error for cname-to-reserved.com, got success")
}
if prob.Type != probs.ConnectionProblem {
t.Errorf("Expected timeout error type %s, got %s", probs.ConnectionProblem, prob.Type)
}
expected := "CAA record for cname-to-reserved.com prevents issuance"
if prob.Detail != expected {
t.Errorf("checkCAA: got %#v, expected %#v", prob.Detail, expected)
}
}
func TestCAATimeout(t *testing.T) {
va, _ := setup(nil, 0)
err := va.checkCAA(ctx, core.AcmeIdentifier{Type: core.IdentifierDNS, Value: "caa-timeout.com"})