Repository: letsencrypt/boulder (Go)

Issue:
Remove CAA NXDOMAIN carveout for TLDs
We have a carveout for when we get NXDOMAIN responses during CAA checking:
https://github.com/letsencrypt/boulder/blob/66a4c11fced9af96a5f52fbb22d4f0dd22f68624/bdns/dns.go#L533-L540

This is to allow the following situation:
1) User wants to use DNS-01 to issue for a non-publicly-visible subdomain of `example.org`
2) They create `_acme_challenge.secret.example.org` and complete DNS-01 validation
3) They remove the challenge, and wait more than 8 hours so CAA records expire
4) They finalize their order, causing CAA rechecking to get NXDOMAIN when it queries for `secret.example.com`

This is a very niche situation. It also means that our CAA checks will "succeed" for something like `tld.that.does.not.exist`, which feels incorrect.

We should add logging to see when/why/how often this carveout is hit, to determine if we should remove it or how we should modify it.


Discussion:
Actually, re-opening this issue and moving it out of the milestone, so that we can keep all the context in one place. We'll use this to track investigating the logs, and then potentially removing or modifying the carve-out if the logs indicate doing so would be fine.
Ah, of course, we're getting the new log line logged for _every_ CAA NXDOMAIN... including the ones that are associated with validations that failed because they too (whether DNS-01 or HTTP-01 or otherwise) failed to look up the target DNS name.

I'm going to work on https://github.com/letsencrypt/boulder/issues/7058 and that'll make this a lot clearer
Upon investigation of the improved logs after #7058, we have determined that we still _frequently_ get NXDOMAIN for registered domains which have successfully completed validation (i.e. either the domain only ever had a DNS-01 TXT record which has been removed since primary validation occurred, or the domain has been fully decommissioned since primary validation occurred). However, we never printed that log line for a TLD, indicating that it is safe to remove the carve-out for TLDs.



Patch:
diff --git a/bdns/dns.go b/bdns/dns.go
index 8d5facfcb6a..7294ebd8b34 100644
--- a/bdns/dns.go
+++ b/bdns/dns.go
@@ -530,13 +530,13 @@ func (dnsClient *impl) LookupCAA(ctx context.Context, hostname string) ([]*dns.C
 	dnsType := dns.TypeCAA
 	r, err := dnsClient.exchangeOne(ctx, hostname, dnsType)
 
-	// Special case: for CAA, treat NXDOMAIN as a successful response
-	// containing an empty set of records. This can come up in
-	// situations where records were provisioned for validation (e.g.
-	// TXT records for DNS-01 challenge) and then removed after
-	// validation but before CAA rechecking.
-	if err == nil && r.Rcode == dns.RcodeNameError {
-		dnsClient.log.Infof("LookupCAA: got NXDOMAIN looking up %q", hostname)
+	// Special case: when checking CAA for non-TLD names, treat NXDOMAIN as a
+	// successful response containing an empty set of records. This can come up in
+	// situations where records were provisioned for validation (e.g. TXT records
+	// for DNS-01 challenge) and then removed after validation but before CAA
+	// rechecking. But allow NXDOMAIN for TLDs to fall through to the error code
+	// below, so we don't issue for gTLDs that have been removed by ICANN.
+	if err == nil && r.Rcode == dns.RcodeNameError && strings.Contains(hostname, ".") {
 		return nil, "", nil
 	}
 


Test patch:
diff --git a/bdns/dns_test.go b/bdns/dns_test.go
index 5def32d6e7a..cdaf497e4dd 100644
--- a/bdns/dns_test.go
+++ b/bdns/dns_test.go
@@ -142,6 +142,9 @@ func mockDNSQuery(w dns.ResponseWriter, r *dns.Msg) {
 				record.Flag = 1
 				appendAnswer(record)
 			}
+			if q.Name == "gonetld." {
+				m.SetRcode(r, dns.RcodeNameError)
+			}
 		case dns.TypeTXT:
 			if q.Name == "split-txt.letsencrypt.org." {
 				record := new(dns.TXT)
@@ -444,6 +447,10 @@ bracewel.net.	0	IN	CAA	1 issue "letsencrypt.org"
 caa.example.com.	0	IN	CAA	1 issue "letsencrypt.org"
 `
 	test.AssertEquals(t, removeIDExp.ReplaceAllString(resp, " id: XXXX"), expectedResp)
+
+	_, _, err = obj.LookupCAA(context.Background(), "gonetld")
+	test.AssertError(t, err, "should fail for TLD NXDOMAIN")
+	test.AssertContains(t, err.Error(), "NXDOMAIN")
 }
 
 func TestIsPrivateIP(t *testing.T) {
diff --git a/va/caa_test.go b/va/caa_test.go
index c7b8810be65..639a462e666 100644
--- a/va/caa_test.go
+++ b/va/caa_test.go
@@ -58,6 +58,8 @@ func (mock caaMockDNS) LookupCAA(_ context.Context, domain string) ([]*dns.CAA,
 	case "com":
 		// com has no CAA records.
 		return nil, "", nil
+	case "gonetld":
+		return nil, "", fmt.Errorf("NXDOMAIN")
 	case "servfail.com", "servfail.present.com":
 		return results, "", fmt.Errorf("SERVFAIL")
 	case "multi-crit-present.com":
@@ -589,6 +591,13 @@ func TestCAAFailure(t *testing.T) {
 		t.Fatalf("Expected CAA rejection for reserved.com, got success")
 	}
 	test.AssertEquals(t, prob.Type, probs.CAAProblem)
+
+	_, prob = va.validate(ctx, dnsi("example.gonetld"), 1, chall)
+	if prob == nil {
+		t.Fatalf("Expected CAA rejection for gonetld, got success")
+	}
+	test.AssertEquals(t, prob.Type, probs.DNSProblem)
+	test.AssertContains(t, prob.Error(), "NXDOMAIN")
 }
 
 func TestFilterCAA(t *testing.T) {