github.com/letsencrypt/boulder@v0.20251208.0/va/caa_test.go (about) 1 package va 2 3 import ( 4 "context" 5 "encoding/json" 6 "errors" 7 "fmt" 8 "net/netip" 9 "regexp" 10 "slices" 11 "strings" 12 "testing" 13 14 "github.com/miekg/dns" 15 "github.com/prometheus/client_golang/prometheus" 16 17 "github.com/letsencrypt/boulder/bdns" 18 "github.com/letsencrypt/boulder/core" 19 berrors "github.com/letsencrypt/boulder/errors" 20 "github.com/letsencrypt/boulder/features" 21 "github.com/letsencrypt/boulder/identifier" 22 "github.com/letsencrypt/boulder/probs" 23 "github.com/letsencrypt/boulder/test" 24 25 blog "github.com/letsencrypt/boulder/log" 26 vapb "github.com/letsencrypt/boulder/va/proto" 27 ) 28 29 // caaMockDNS implements the `dns.DNSClient` interface with a set of useful test 30 // answers for CAA queries. 31 type caaMockDNS struct{} 32 33 func (mock caaMockDNS) LookupTXT(_ context.Context, hostname string) ([]string, bdns.ResolverAddrs, error) { 34 return nil, bdns.ResolverAddrs{"caaMockDNS"}, nil 35 } 36 37 func (mock caaMockDNS) LookupHost(_ context.Context, hostname string) ([]netip.Addr, bdns.ResolverAddrs, error) { 38 return []netip.Addr{netip.MustParseAddr("127.0.0.1")}, bdns.ResolverAddrs{"caaMockDNS"}, nil 39 } 40 41 func (mock caaMockDNS) LookupCAA(_ context.Context, domain string) ([]*dns.CAA, string, bdns.ResolverAddrs, error) { 42 var results []*dns.CAA 43 var record dns.CAA 44 switch strings.TrimRight(domain, ".") { 45 case "caa-timeout.com": 46 return nil, "", bdns.ResolverAddrs{"caaMockDNS"}, fmt.Errorf("error") 47 case "reserved.com": 48 record.Tag = "issue" 49 record.Value = "ca.com" 50 results = append(results, &record) 51 case "mixedcase.com": 52 record.Tag = "iSsUe" 53 record.Value = "ca.com" 54 results = append(results, &record) 55 case "critical.com": 56 record.Flag = 1 57 record.Tag = "issue" 58 record.Value = "ca.com" 59 results = append(results, &record) 60 case "present.com", "present.servfail.com": 61 record.Tag = "issue" 62 record.Value = "letsencrypt.org" 63 results = append(results, &record) 64 case "com": 65 // com has no CAA records. 66 return nil, "", bdns.ResolverAddrs{"caaMockDNS"}, nil 67 case "gonetld": 68 return nil, "", bdns.ResolverAddrs{"caaMockDNS"}, fmt.Errorf("NXDOMAIN") 69 case "servfail.com", "servfail.present.com": 70 return results, "", bdns.ResolverAddrs{"caaMockDNS"}, fmt.Errorf("SERVFAIL") 71 case "multi-crit-present.com": 72 record.Flag = 1 73 record.Tag = "issue" 74 record.Value = "ca.com" 75 results = append(results, &record) 76 secondRecord := record 77 secondRecord.Value = "letsencrypt.org" 78 results = append(results, &secondRecord) 79 case "unknown-critical.com": 80 record.Flag = 128 81 record.Tag = "foo" 82 record.Value = "bar" 83 results = append(results, &record) 84 case "unknown-critical2.com": 85 record.Flag = 1 86 record.Tag = "foo" 87 record.Value = "bar" 88 results = append(results, &record) 89 case "unknown-noncritical.com": 90 record.Flag = 0x7E // all bits we don't treat as meaning "critical" 91 record.Tag = "foo" 92 record.Value = "bar" 93 results = append(results, &record) 94 case "present-with-parameter.com": 95 record.Tag = "issue" 96 record.Value = " letsencrypt.org ;foo=bar;baz=bar" 97 results = append(results, &record) 98 case "present-with-invalid-tag.com": 99 record.Tag = "issue" 100 record.Value = "letsencrypt.org; a_b=123" 101 results = append(results, &record) 102 case "present-with-invalid-value.com": 103 record.Tag = "issue" 104 record.Value = "letsencrypt.org; ab=1 2 3" 105 results = append(results, &record) 106 case "present-dns-only.com": 107 record.Tag = "issue" 108 record.Value = "letsencrypt.org; validationmethods=dns-01" 109 results = append(results, &record) 110 case "present-http-only.com": 111 record.Tag = "issue" 112 record.Value = "letsencrypt.org; validationmethods=http-01" 113 results = append(results, &record) 114 case "present-http-or-dns.com": 115 record.Tag = "issue" 116 record.Value = "letsencrypt.org; validationmethods=http-01,dns-01" 117 results = append(results, &record) 118 case "present-dns-only-correct-accounturi.com": 119 record.Tag = "issue" 120 record.Value = "letsencrypt.org; accounturi=https://letsencrypt.org/acct/reg/123; validationmethods=dns-01" 121 results = append(results, &record) 122 case "present-http-only-correct-accounturi.com": 123 record.Tag = "issue" 124 record.Value = "letsencrypt.org; accounturi=https://letsencrypt.org/acct/reg/123; validationmethods=http-01" 125 results = append(results, &record) 126 case "present-http-only-incorrect-accounturi.com": 127 record.Tag = "issue" 128 record.Value = "letsencrypt.org; accounturi=https://letsencrypt.org/acct/reg/321; validationmethods=http-01" 129 results = append(results, &record) 130 case "present-correct-accounturi.com": 131 record.Tag = "issue" 132 record.Value = "letsencrypt.org; accounturi=https://letsencrypt.org/acct/reg/123" 133 results = append(results, &record) 134 case "present-incorrect-accounturi.com": 135 record.Tag = "issue" 136 record.Value = "letsencrypt.org; accounturi=https://letsencrypt.org/acct/reg/321" 137 results = append(results, &record) 138 case "present-multiple-accounturi.com": 139 record.Tag = "issue" 140 record.Value = "letsencrypt.org; accounturi=https://letsencrypt.org/acct/reg/321" 141 results = append(results, &record) 142 secondRecord := record 143 secondRecord.Tag = "issue" 144 secondRecord.Value = "letsencrypt.org; accounturi=https://letsencrypt.org/acct/reg/123" 145 results = append(results, &secondRecord) 146 case "unsatisfiable.com": 147 record.Tag = "issue" 148 record.Value = ";" 149 results = append(results, &record) 150 case "unsatisfiable-wildcard.com": 151 // Forbidden issuance - issuewild doesn't contain LE 152 record.Tag = "issuewild" 153 record.Value = ";" 154 results = append(results, &record) 155 case "unsatisfiable-wildcard-override.com": 156 // Forbidden issuance - issue allows LE, issuewild overrides and does not 157 record.Tag = "issue" 158 record.Value = "letsencrypt.org" 159 results = append(results, &record) 160 secondRecord := record 161 secondRecord.Tag = "issuewild" 162 secondRecord.Value = "ca.com" 163 results = append(results, &secondRecord) 164 case "satisfiable-wildcard-override.com": 165 // Ok issuance - issue doesn't allow LE, issuewild overrides and does 166 record.Tag = "issue" 167 record.Value = "ca.com" 168 results = append(results, &record) 169 secondRecord := record 170 secondRecord.Tag = "issuewild" 171 secondRecord.Value = "letsencrypt.org" 172 results = append(results, &secondRecord) 173 case "satisfiable-multi-wildcard.com": 174 // Ok issuance - first issuewild doesn't permit LE but second does 175 record.Tag = "issuewild" 176 record.Value = "ca.com" 177 results = append(results, &record) 178 secondRecord := record 179 secondRecord.Tag = "issuewild" 180 secondRecord.Value = "letsencrypt.org" 181 results = append(results, &secondRecord) 182 case "satisfiable-wildcard.com": 183 // Ok issuance - issuewild allows LE 184 record.Tag = "issuewild" 185 record.Value = "letsencrypt.org" 186 results = append(results, &record) 187 } 188 var response string 189 if len(results) > 0 { 190 response = "foo" 191 } 192 return results, response, bdns.ResolverAddrs{"caaMockDNS"}, nil 193 } 194 195 func TestCAATimeout(t *testing.T) { 196 va, _ := setup(nil, "", nil, caaMockDNS{}) 197 198 params := &caaParams{ 199 accountURIID: 12345, 200 validationMethod: core.ChallengeTypeHTTP01, 201 } 202 203 err := va.checkCAA(ctx, identifier.NewDNS("caa-timeout.com"), params) 204 test.AssertErrorIs(t, err, berrors.DNS) 205 test.AssertContains(t, err.Error(), "error") 206 } 207 208 func TestCAAChecking(t *testing.T) { 209 testCases := []struct { 210 Name string 211 Domain string 212 FoundAt string 213 Valid bool 214 }{ 215 { 216 Name: "Bad (Reserved)", 217 Domain: "reserved.com", 218 FoundAt: "reserved.com", 219 Valid: false, 220 }, 221 { 222 Name: "Bad (Reserved, Mixed case Issue)", 223 Domain: "mixedcase.com", 224 FoundAt: "mixedcase.com", 225 Valid: false, 226 }, 227 { 228 Name: "Bad (Critical)", 229 Domain: "critical.com", 230 FoundAt: "critical.com", 231 Valid: false, 232 }, 233 { 234 Name: "Bad (NX Critical)", 235 Domain: "nx.critical.com", 236 FoundAt: "critical.com", 237 Valid: false, 238 }, 239 { 240 Name: "Good (absent)", 241 Domain: "absent.com", 242 FoundAt: "", 243 Valid: true, 244 }, 245 { 246 Name: "Good (example.co.uk, absent)", 247 Domain: "example.co.uk", 248 FoundAt: "", 249 Valid: true, 250 }, 251 { 252 Name: "Good (present and valid)", 253 Domain: "present.com", 254 FoundAt: "present.com", 255 Valid: true, 256 }, 257 { 258 Name: "Good (present on parent)", 259 Domain: "child.present.com", 260 FoundAt: "present.com", 261 Valid: true, 262 }, 263 { 264 Name: "Good (present w/ servfail exception?)", 265 Domain: "present.servfail.com", 266 FoundAt: "present.servfail.com", 267 Valid: true, 268 }, 269 { 270 Name: "Good (multiple critical, one matching)", 271 Domain: "multi-crit-present.com", 272 FoundAt: "multi-crit-present.com", 273 Valid: true, 274 }, 275 { 276 Name: "Bad (unknown critical)", 277 Domain: "unknown-critical.com", 278 FoundAt: "unknown-critical.com", 279 Valid: false, 280 }, 281 { 282 Name: "Bad (unknown critical 2)", 283 Domain: "unknown-critical2.com", 284 FoundAt: "unknown-critical2.com", 285 Valid: false, 286 }, 287 { 288 Name: "Good (unknown non-critical, no issue)", 289 Domain: "unknown-noncritical.com", 290 FoundAt: "unknown-noncritical.com", 291 Valid: true, 292 }, 293 { 294 Name: "Good (unknown non-critical, no issuewild)", 295 Domain: "*.unknown-noncritical.com", 296 FoundAt: "unknown-noncritical.com", 297 Valid: true, 298 }, 299 { 300 Name: "Good (issue rec with unknown params)", 301 Domain: "present-with-parameter.com", 302 FoundAt: "present-with-parameter.com", 303 Valid: true, 304 }, 305 { 306 Name: "Bad (issue rec with invalid tag)", 307 Domain: "present-with-invalid-tag.com", 308 FoundAt: "present-with-invalid-tag.com", 309 Valid: false, 310 }, 311 { 312 Name: "Bad (issue rec with invalid value)", 313 Domain: "present-with-invalid-value.com", 314 FoundAt: "present-with-invalid-value.com", 315 Valid: false, 316 }, 317 { 318 Name: "Bad (restricts to dns-01, but tested with http-01)", 319 Domain: "present-dns-only.com", 320 FoundAt: "present-dns-only.com", 321 Valid: false, 322 }, 323 { 324 Name: "Good (restricts to http-01, tested with http-01)", 325 Domain: "present-http-only.com", 326 FoundAt: "present-http-only.com", 327 Valid: true, 328 }, 329 { 330 Name: "Good (restricts to http-01 or dns-01, tested with http-01)", 331 Domain: "present-http-or-dns.com", 332 FoundAt: "present-http-or-dns.com", 333 Valid: true, 334 }, 335 { 336 Name: "Good (restricts to accounturi, tested with correct account)", 337 Domain: "present-correct-accounturi.com", 338 FoundAt: "present-correct-accounturi.com", 339 Valid: true, 340 }, 341 { 342 Name: "Good (restricts to http-01 and accounturi, tested with correct account)", 343 Domain: "present-http-only-correct-accounturi.com", 344 FoundAt: "present-http-only-correct-accounturi.com", 345 Valid: true, 346 }, 347 { 348 Name: "Bad (restricts to dns-01 and accounturi, tested with http-01)", 349 Domain: "present-dns-only-correct-accounturi.com", 350 FoundAt: "present-dns-only-correct-accounturi.com", 351 Valid: false, 352 }, 353 { 354 Name: "Bad (restricts to http-01 and accounturi, tested with incorrect account)", 355 Domain: "present-http-only-incorrect-accounturi.com", 356 FoundAt: "present-http-only-incorrect-accounturi.com", 357 Valid: false, 358 }, 359 { 360 Name: "Bad (restricts to accounturi, tested with incorrect account)", 361 Domain: "present-incorrect-accounturi.com", 362 FoundAt: "present-incorrect-accounturi.com", 363 Valid: false, 364 }, 365 { 366 Name: "Good (restricts to multiple accounturi, tested with a correct account)", 367 Domain: "present-multiple-accounturi.com", 368 FoundAt: "present-multiple-accounturi.com", 369 Valid: true, 370 }, 371 { 372 Name: "Bad (unsatisfiable issue record)", 373 Domain: "unsatisfiable.com", 374 FoundAt: "unsatisfiable.com", 375 Valid: false, 376 }, 377 { 378 Name: "Bad (unsatisfiable issue, wildcard)", 379 Domain: "*.unsatisfiable.com", 380 FoundAt: "unsatisfiable.com", 381 Valid: false, 382 }, 383 { 384 Name: "Bad (unsatisfiable wildcard)", 385 Domain: "*.unsatisfiable-wildcard.com", 386 FoundAt: "unsatisfiable-wildcard.com", 387 Valid: false, 388 }, 389 { 390 Name: "Bad (unsatisfiable wildcard override)", 391 Domain: "*.unsatisfiable-wildcard-override.com", 392 FoundAt: "unsatisfiable-wildcard-override.com", 393 Valid: false, 394 }, 395 { 396 Name: "Good (satisfiable wildcard)", 397 Domain: "*.satisfiable-wildcard.com", 398 FoundAt: "satisfiable-wildcard.com", 399 Valid: true, 400 }, 401 { 402 Name: "Good (multiple issuewild, one satisfiable)", 403 Domain: "*.satisfiable-multi-wildcard.com", 404 FoundAt: "satisfiable-multi-wildcard.com", 405 Valid: true, 406 }, 407 { 408 Name: "Good (satisfiable wildcard override)", 409 Domain: "*.satisfiable-wildcard-override.com", 410 FoundAt: "satisfiable-wildcard-override.com", 411 Valid: true, 412 }, 413 } 414 415 accountURIID := int64(123) 416 method := core.ChallengeTypeHTTP01 417 params := &caaParams{accountURIID: accountURIID, validationMethod: method} 418 419 va, _ := setup(nil, "", nil, caaMockDNS{}) 420 va.accountURIPrefixes = []string{"https://letsencrypt.org/acct/reg/"} 421 422 for _, caaTest := range testCases { 423 mockLog := va.log.(*blog.Mock) 424 defer mockLog.Clear() 425 t.Run(caaTest.Name, func(t *testing.T) { 426 ident := identifier.NewDNS(caaTest.Domain) 427 foundAt, valid, _, err := va.checkCAARecords(ctx, ident, params) 428 if err != nil { 429 t.Errorf("checkCAARecords error for %s: %s", caaTest.Domain, err) 430 } 431 if foundAt != caaTest.FoundAt { 432 t.Errorf("checkCAARecords presence mismatch for %s: got %q expected %q", caaTest.Domain, foundAt, caaTest.FoundAt) 433 } 434 if valid != caaTest.Valid { 435 t.Errorf("checkCAARecords validity mismatch for %s: got %t expected %t", caaTest.Domain, valid, caaTest.Valid) 436 } 437 }) 438 } 439 } 440 441 func TestCAALogging(t *testing.T) { 442 va, _ := setup(nil, "", nil, caaMockDNS{}) 443 444 testCases := []struct { 445 Name string 446 Domain string 447 AccountURIID int64 448 ChallengeType core.AcmeChallenge 449 ExpectedLogline string 450 }{ 451 { 452 Domain: "reserved.com", 453 AccountURIID: 12345, 454 ChallengeType: core.ChallengeTypeHTTP01, 455 ExpectedLogline: "INFO: [AUDIT] Checked CAA records for reserved.com, [Present: true, Account ID: 12345, Challenge: http-01, Valid for issuance: false, Found at: \"reserved.com\"] Response=\"foo\"", 456 }, 457 { 458 Domain: "reserved.com", 459 AccountURIID: 12345, 460 ChallengeType: core.ChallengeTypeDNS01, 461 ExpectedLogline: "INFO: [AUDIT] Checked CAA records for reserved.com, [Present: true, Account ID: 12345, Challenge: dns-01, Valid for issuance: false, Found at: \"reserved.com\"] Response=\"foo\"", 462 }, 463 { 464 Domain: "mixedcase.com", 465 AccountURIID: 12345, 466 ChallengeType: core.ChallengeTypeHTTP01, 467 ExpectedLogline: "INFO: [AUDIT] Checked CAA records for mixedcase.com, [Present: true, Account ID: 12345, Challenge: http-01, Valid for issuance: false, Found at: \"mixedcase.com\"] Response=\"foo\"", 468 }, 469 { 470 Domain: "critical.com", 471 AccountURIID: 12345, 472 ChallengeType: core.ChallengeTypeHTTP01, 473 ExpectedLogline: "INFO: [AUDIT] Checked CAA records for critical.com, [Present: true, Account ID: 12345, Challenge: http-01, Valid for issuance: false, Found at: \"critical.com\"] Response=\"foo\"", 474 }, 475 { 476 Domain: "present.com", 477 AccountURIID: 12345, 478 ChallengeType: core.ChallengeTypeHTTP01, 479 ExpectedLogline: "INFO: [AUDIT] Checked CAA records for present.com, [Present: true, Account ID: 12345, Challenge: http-01, Valid for issuance: true, Found at: \"present.com\"] Response=\"foo\"", 480 }, 481 { 482 Domain: "not.here.but.still.present.com", 483 AccountURIID: 12345, 484 ChallengeType: core.ChallengeTypeHTTP01, 485 ExpectedLogline: "INFO: [AUDIT] Checked CAA records for not.here.but.still.present.com, [Present: true, Account ID: 12345, Challenge: http-01, Valid for issuance: true, Found at: \"present.com\"] Response=\"foo\"", 486 }, 487 { 488 Domain: "multi-crit-present.com", 489 AccountURIID: 12345, 490 ChallengeType: core.ChallengeTypeHTTP01, 491 ExpectedLogline: "INFO: [AUDIT] Checked CAA records for multi-crit-present.com, [Present: true, Account ID: 12345, Challenge: http-01, Valid for issuance: true, Found at: \"multi-crit-present.com\"] Response=\"foo\"", 492 }, 493 { 494 Domain: "present-with-parameter.com", 495 AccountURIID: 12345, 496 ChallengeType: core.ChallengeTypeHTTP01, 497 ExpectedLogline: "INFO: [AUDIT] Checked CAA records for present-with-parameter.com, [Present: true, Account ID: 12345, Challenge: http-01, Valid for issuance: true, Found at: \"present-with-parameter.com\"] Response=\"foo\"", 498 }, 499 { 500 Domain: "satisfiable-wildcard-override.com", 501 AccountURIID: 12345, 502 ChallengeType: core.ChallengeTypeHTTP01, 503 ExpectedLogline: "INFO: [AUDIT] Checked CAA records for satisfiable-wildcard-override.com, [Present: true, Account ID: 12345, Challenge: http-01, Valid for issuance: false, Found at: \"satisfiable-wildcard-override.com\"] Response=\"foo\"", 504 }, 505 } 506 507 for _, tc := range testCases { 508 t.Run(tc.Domain, func(t *testing.T) { 509 mockLog := va.log.(*blog.Mock) 510 defer mockLog.Clear() 511 512 params := &caaParams{ 513 accountURIID: tc.AccountURIID, 514 validationMethod: tc.ChallengeType, 515 } 516 _ = va.checkCAA(ctx, identifier.NewDNS(tc.Domain), params) 517 518 caaLogLines := mockLog.GetAllMatching(`Checked CAA records for`) 519 if len(caaLogLines) != 1 { 520 t.Errorf("checkCAARecords didn't audit log CAA record info. Instead got:\n%s\n", 521 strings.Join(mockLog.GetAllMatching(`.*`), "\n")) 522 } else { 523 test.AssertEquals(t, caaLogLines[0], tc.ExpectedLogline) 524 } 525 }) 526 } 527 } 528 529 // TestDoCAAErrMessage tests that an error result from `va.IsCAAValid` 530 // includes the domain name that was being checked in the failure detail. 531 func TestDoCAAErrMessage(t *testing.T) { 532 t.Parallel() 533 va, _ := setup(nil, "", nil, caaMockDNS{}) 534 535 // Call the operation with a domain we know fails with a generic error from the 536 // caaMockDNS. 537 domain := "caa-timeout.com" 538 resp, err := va.DoCAA(ctx, &vapb.IsCAAValidRequest{ 539 Identifier: identifier.NewDNS(domain).ToProto(), 540 ValidationMethod: string(core.ChallengeTypeHTTP01), 541 AccountURIID: 12345, 542 }) 543 544 // The lookup itself should not return an error 545 test.AssertNotError(t, err, "Unexpected error calling IsCAAValidRequest") 546 // The result should not be nil 547 test.AssertNotNil(t, resp, "Response to IsCAAValidRequest was nil") 548 // The result's Problem should not be nil 549 test.AssertNotNil(t, resp.Problem, "Response Problem was nil") 550 // The result's Problem should be an error message that includes the domain. 551 test.AssertEquals(t, resp.Problem.Detail, fmt.Sprintf("While processing CAA for %s: error", domain)) 552 } 553 554 // TestDoCAAParams tests that the IsCAAValid method rejects any requests 555 // which do not have the necessary parameters to do CAA Account and Method 556 // Binding checks. 557 func TestDoCAAParams(t *testing.T) { 558 t.Parallel() 559 va, _ := setup(nil, "", nil, caaMockDNS{}) 560 561 // Calling IsCAAValid without a ValidationMethod should fail. 562 _, err := va.DoCAA(ctx, &vapb.IsCAAValidRequest{ 563 Identifier: identifier.NewDNS("present.com").ToProto(), 564 AccountURIID: 12345, 565 }) 566 test.AssertError(t, err, "calling IsCAAValid without a ValidationMethod") 567 568 // Calling IsCAAValid with an invalid ValidationMethod should fail. 569 _, err = va.DoCAA(ctx, &vapb.IsCAAValidRequest{ 570 Identifier: identifier.NewDNS("present.com").ToProto(), 571 ValidationMethod: "tls-sni-01", 572 AccountURIID: 12345, 573 }) 574 test.AssertError(t, err, "calling IsCAAValid with a bad ValidationMethod") 575 576 // Calling IsCAAValid without an AccountURIID should fail. 577 _, err = va.DoCAA(ctx, &vapb.IsCAAValidRequest{ 578 Identifier: identifier.NewDNS("present.com").ToProto(), 579 ValidationMethod: string(core.ChallengeTypeHTTP01), 580 }) 581 test.AssertError(t, err, "calling IsCAAValid without an AccountURIID") 582 583 // Calling IsCAAValid with a non-DNS identifier type should fail. 584 _, err = va.DoCAA(ctx, &vapb.IsCAAValidRequest{ 585 Identifier: identifier.NewIP(netip.MustParseAddr("127.0.0.1")).ToProto(), 586 ValidationMethod: string(core.ChallengeTypeHTTP01), 587 AccountURIID: 12345, 588 }) 589 test.AssertError(t, err, "calling IsCAAValid with a non-DNS identifier type") 590 } 591 592 var errCAABrokenDNSClient = errors.New("dnsClient is broken") 593 594 // caaBrokenDNS implements the `dns.DNSClient` interface, but always returns 595 // errors. 596 type caaBrokenDNS struct{} 597 598 func (b caaBrokenDNS) LookupTXT(_ context.Context, hostname string) ([]string, bdns.ResolverAddrs, error) { 599 return nil, bdns.ResolverAddrs{"caaBrokenDNS"}, errCAABrokenDNSClient 600 } 601 602 func (b caaBrokenDNS) LookupHost(_ context.Context, hostname string) ([]netip.Addr, bdns.ResolverAddrs, error) { 603 return nil, bdns.ResolverAddrs{"caaBrokenDNS"}, errCAABrokenDNSClient 604 } 605 606 func (b caaBrokenDNS) LookupCAA(_ context.Context, domain string) ([]*dns.CAA, string, bdns.ResolverAddrs, error) { 607 return nil, "", bdns.ResolverAddrs{"caaBrokenDNS"}, errCAABrokenDNSClient 608 } 609 610 // caaHijackedDNS implements the `dns.DNSClient` interface with a set of useful 611 // test answers for CAA queries. It returns alternate CAA records than what 612 // caaMockDNS returns simulating either a BGP hijack or DNS records that have 613 // changed while queries were inflight. 614 type caaHijackedDNS struct{} 615 616 func (h caaHijackedDNS) LookupTXT(_ context.Context, hostname string) ([]string, bdns.ResolverAddrs, error) { 617 return nil, bdns.ResolverAddrs{"caaHijackedDNS"}, nil 618 } 619 620 func (h caaHijackedDNS) LookupHost(_ context.Context, hostname string) ([]netip.Addr, bdns.ResolverAddrs, error) { 621 return []netip.Addr{netip.MustParseAddr("127.0.0.1")}, bdns.ResolverAddrs{"caaHijackedDNS"}, nil 622 } 623 func (h caaHijackedDNS) LookupCAA(_ context.Context, domain string) ([]*dns.CAA, string, bdns.ResolverAddrs, error) { 624 // These records are altered from their caaMockDNS counterparts. Use this to 625 // tickle remoteValidationFailures. 626 var results []*dns.CAA 627 var record dns.CAA 628 switch strings.TrimRight(domain, ".") { 629 case "present.com", "present.servfail.com": 630 record.Tag = "issue" 631 record.Value = "other-ca.com" 632 results = append(results, &record) 633 case "present-dns-only.com": 634 return results, "", bdns.ResolverAddrs{"caaHijackedDNS"}, fmt.Errorf("SERVFAIL") 635 case "satisfiable-wildcard.com": 636 record.Tag = "issuewild" 637 record.Value = ";" 638 results = append(results, &record) 639 secondRecord := record 640 secondRecord.Tag = "issue" 641 secondRecord.Value = ";" 642 results = append(results, &secondRecord) 643 } 644 var response string 645 if len(results) > 0 { 646 response = "foo" 647 } 648 return results, response, bdns.ResolverAddrs{"caaHijackedDNS"}, nil 649 } 650 651 // parseValidationLogEvent extracts ... from JSON={ ... } in a ValidateChallenge 652 // audit log and returns it as a validationLogEvent struct. 653 func parseValidationLogEvent(t *testing.T, log []string) validationLogEvent { 654 re := regexp.MustCompile(`JSON=\{.*\}`) 655 var audit validationLogEvent 656 for _, line := range log { 657 match := re.FindString(line) 658 if match != "" { 659 jsonStr := match[len(`JSON=`):] 660 if err := json.Unmarshal([]byte(jsonStr), &audit); err != nil { 661 t.Fatalf("Failed to parse JSON: %v", err) 662 } 663 return audit 664 } 665 } 666 t.Fatal("JSON not found in log") 667 return audit 668 } 669 670 func TestMultiCAARechecking(t *testing.T) { 671 // The remote differential log order is non-deterministic, so let's use 672 // the same UA for all applicable RVAs. 673 const ( 674 localUA = "local" 675 remoteUA = "remote" 676 brokenUA = "broken" 677 hijackedUA = "hijacked" 678 ) 679 680 testCases := []struct { 681 name string 682 ident identifier.ACMEIdentifier 683 remoteVAs []remoteConf 684 expectedProbSubstring string 685 expectedProbType probs.ProblemType 686 expectedDiffLogSubstring string 687 expectedSummary *mpicSummary 688 expectedLabels prometheus.Labels 689 localDNSClient bdns.Client 690 }{ 691 { 692 name: "all VAs functional, no CAA records", 693 ident: identifier.NewDNS("present-dns-only.com"), 694 localDNSClient: caaMockDNS{}, 695 remoteVAs: []remoteConf{ 696 {ua: remoteUA, rir: arin}, 697 {ua: remoteUA, rir: ripe}, 698 {ua: remoteUA, rir: apnic}, 699 }, 700 expectedLabels: prometheus.Labels{ 701 "operation": opCAA, 702 "perspective": allPerspectives, 703 "challenge_type": string(core.ChallengeTypeDNS01), 704 "problem_type": "", 705 "result": pass, 706 }, 707 }, 708 { 709 name: "broken localVA, RVAs functional, no CAA records", 710 ident: identifier.NewDNS("present-dns-only.com"), 711 localDNSClient: caaBrokenDNS{}, 712 expectedProbSubstring: "While processing CAA for present-dns-only.com: dnsClient is broken", 713 expectedProbType: probs.DNSProblem, 714 remoteVAs: []remoteConf{ 715 {ua: remoteUA, rir: arin}, 716 {ua: remoteUA, rir: ripe}, 717 {ua: remoteUA, rir: apnic}, 718 }, 719 expectedLabels: prometheus.Labels{ 720 "operation": opCAA, 721 "perspective": allPerspectives, 722 "challenge_type": string(core.ChallengeTypeDNS01), 723 "problem_type": string(probs.DNSProblem), 724 "result": fail, 725 }, 726 }, 727 { 728 name: "functional localVA, 1 broken RVA, no CAA records", 729 ident: identifier.NewDNS("present-dns-only.com"), 730 localDNSClient: caaMockDNS{}, 731 expectedDiffLogSubstring: `"RemoteSuccesses":2,"RemoteFailures":1`, 732 expectedSummary: &mpicSummary{ 733 Passed: []string{"dc-1-RIPE", "dc-2-APNIC"}, 734 Failed: []string{"dc-0-ARIN"}, 735 PassedRIRs: []string{ripe, apnic}, 736 QuorumResult: "2/3", 737 }, 738 remoteVAs: []remoteConf{ 739 {ua: brokenUA, rir: arin, dns: caaBrokenDNS{}}, 740 {ua: remoteUA, rir: ripe}, 741 {ua: remoteUA, rir: apnic}, 742 }, 743 expectedLabels: prometheus.Labels{ 744 "operation": opCAA, 745 "perspective": allPerspectives, 746 "challenge_type": string(core.ChallengeTypeDNS01), 747 "problem_type": "", 748 "result": pass, 749 }, 750 }, 751 { 752 name: "functional localVA, 2 broken RVA, no CAA records", 753 ident: identifier.NewDNS("present-dns-only.com"), 754 expectedProbSubstring: "During secondary validation: While processing CAA", 755 expectedProbType: probs.DNSProblem, 756 expectedDiffLogSubstring: `"RemoteSuccesses":1,"RemoteFailures":2`, 757 expectedSummary: &mpicSummary{ 758 Passed: []string{"dc-2-APNIC"}, 759 Failed: []string{"dc-0-ARIN", "dc-1-RIPE"}, 760 PassedRIRs: []string{apnic}, 761 QuorumResult: "1/3", 762 }, 763 localDNSClient: caaMockDNS{}, 764 remoteVAs: []remoteConf{ 765 {ua: brokenUA, rir: arin, dns: caaBrokenDNS{}}, 766 {ua: brokenUA, rir: ripe, dns: caaBrokenDNS{}}, 767 {ua: remoteUA, rir: apnic}, 768 }, 769 expectedLabels: prometheus.Labels{ 770 "operation": opCAA, 771 "perspective": allPerspectives, 772 "challenge_type": string(core.ChallengeTypeDNS01), 773 "problem_type": string(probs.DNSProblem), 774 "result": fail, 775 }, 776 }, 777 { 778 name: "functional localVA, all broken RVAs, no CAA records", 779 ident: identifier.NewDNS("present-dns-only.com"), 780 expectedProbSubstring: "During secondary validation: While processing CAA", 781 expectedProbType: probs.DNSProblem, 782 expectedDiffLogSubstring: `"RemoteSuccesses":0,"RemoteFailures":3`, 783 expectedSummary: &mpicSummary{ 784 Passed: []string{}, 785 Failed: []string{"dc-0-ARIN", "dc-1-RIPE", "dc-2-APNIC"}, 786 PassedRIRs: []string{}, 787 QuorumResult: "0/3", 788 }, 789 localDNSClient: caaMockDNS{}, 790 remoteVAs: []remoteConf{ 791 {ua: brokenUA, rir: arin, dns: caaBrokenDNS{}}, 792 {ua: brokenUA, rir: ripe, dns: caaBrokenDNS{}}, 793 {ua: brokenUA, rir: apnic, dns: caaBrokenDNS{}}, 794 }, 795 expectedLabels: prometheus.Labels{ 796 "operation": opCAA, 797 "perspective": allPerspectives, 798 "challenge_type": string(core.ChallengeTypeDNS01), 799 "problem_type": string(probs.DNSProblem), 800 "result": fail, 801 }, 802 }, 803 { 804 name: "all VAs functional, CAA issue type present", 805 ident: identifier.NewDNS("present.com"), 806 localDNSClient: caaMockDNS{}, 807 remoteVAs: []remoteConf{ 808 {ua: remoteUA, rir: arin}, 809 {ua: remoteUA, rir: ripe}, 810 {ua: remoteUA, rir: apnic}, 811 }, 812 expectedLabels: prometheus.Labels{ 813 "operation": opCAA, 814 "perspective": allPerspectives, 815 "challenge_type": string(core.ChallengeTypeDNS01), 816 "problem_type": "", 817 "result": pass, 818 }, 819 }, 820 { 821 name: "functional localVA, 1 broken RVA, CAA issue type present", 822 ident: identifier.NewDNS("present.com"), 823 expectedDiffLogSubstring: `"RemoteSuccesses":2,"RemoteFailures":1`, 824 expectedSummary: &mpicSummary{ 825 Passed: []string{"dc-1-RIPE", "dc-2-APNIC"}, 826 Failed: []string{"dc-0-ARIN"}, 827 PassedRIRs: []string{ripe, apnic}, 828 QuorumResult: "2/3", 829 }, 830 localDNSClient: caaMockDNS{}, 831 remoteVAs: []remoteConf{ 832 {ua: brokenUA, rir: arin, dns: caaBrokenDNS{}}, 833 {ua: remoteUA, rir: ripe}, 834 {ua: remoteUA, rir: apnic}, 835 }, 836 expectedLabels: prometheus.Labels{ 837 "operation": opCAA, 838 "perspective": allPerspectives, 839 "challenge_type": string(core.ChallengeTypeDNS01), 840 "problem_type": "", 841 "result": pass, 842 }, 843 }, 844 { 845 name: "functional localVA, 2 broken RVA, CAA issue type present", 846 ident: identifier.NewDNS("present.com"), 847 expectedProbSubstring: "During secondary validation: While processing CAA", 848 expectedProbType: probs.DNSProblem, 849 expectedDiffLogSubstring: `"RemoteSuccesses":1,"RemoteFailures":2`, 850 expectedSummary: &mpicSummary{ 851 Passed: []string{"dc-2-APNIC"}, 852 Failed: []string{"dc-0-ARIN", "dc-1-RIPE"}, 853 PassedRIRs: []string{apnic}, 854 QuorumResult: "1/3", 855 }, 856 localDNSClient: caaMockDNS{}, 857 remoteVAs: []remoteConf{ 858 {ua: brokenUA, rir: arin, dns: caaBrokenDNS{}}, 859 {ua: brokenUA, rir: ripe, dns: caaBrokenDNS{}}, 860 {ua: remoteUA, rir: apnic}, 861 }, 862 expectedLabels: prometheus.Labels{ 863 "operation": opCAA, 864 "perspective": allPerspectives, 865 "challenge_type": string(core.ChallengeTypeDNS01), 866 "problem_type": string(probs.DNSProblem), 867 "result": fail, 868 }, 869 }, 870 { 871 name: "functional localVA, all broken RVAs, CAA issue type present", 872 ident: identifier.NewDNS("present.com"), 873 expectedProbSubstring: "During secondary validation: While processing CAA", 874 expectedProbType: probs.DNSProblem, 875 expectedDiffLogSubstring: `"RemoteSuccesses":0,"RemoteFailures":3`, 876 expectedSummary: &mpicSummary{ 877 Passed: []string{}, 878 Failed: []string{"dc-0-ARIN", "dc-1-RIPE", "dc-2-APNIC"}, 879 PassedRIRs: []string{}, 880 QuorumResult: "0/3", 881 }, 882 localDNSClient: caaMockDNS{}, 883 remoteVAs: []remoteConf{ 884 {ua: brokenUA, rir: arin, dns: caaBrokenDNS{}}, 885 {ua: brokenUA, rir: ripe, dns: caaBrokenDNS{}}, 886 {ua: brokenUA, rir: apnic, dns: caaBrokenDNS{}}, 887 }, 888 expectedLabels: prometheus.Labels{ 889 "operation": opCAA, 890 "perspective": allPerspectives, 891 "challenge_type": string(core.ChallengeTypeDNS01), 892 "problem_type": string(probs.DNSProblem), 893 "result": fail, 894 }, 895 }, 896 { 897 // The localVA returns early with a problem before kicking off the 898 // remote checks. 899 name: "all VAs functional, CAA issue type forbids issuance", 900 ident: identifier.NewDNS("unsatisfiable.com"), 901 expectedProbSubstring: "CAA record for unsatisfiable.com prevents issuance", 902 expectedProbType: probs.CAAProblem, 903 localDNSClient: caaMockDNS{}, 904 remoteVAs: []remoteConf{ 905 {ua: remoteUA, rir: arin}, 906 {ua: remoteUA, rir: ripe}, 907 {ua: remoteUA, rir: apnic}, 908 }, 909 }, 910 { 911 name: "1 hijacked RVA, CAA issue type present", 912 ident: identifier.NewDNS("present.com"), 913 expectedDiffLogSubstring: `"RemoteSuccesses":2,"RemoteFailures":1`, 914 expectedSummary: &mpicSummary{ 915 Passed: []string{"dc-1-RIPE", "dc-2-APNIC"}, 916 Failed: []string{"dc-0-ARIN"}, 917 PassedRIRs: []string{ripe, apnic}, 918 QuorumResult: "2/3", 919 }, 920 localDNSClient: caaMockDNS{}, 921 remoteVAs: []remoteConf{ 922 {ua: hijackedUA, rir: arin, dns: caaHijackedDNS{}}, 923 {ua: remoteUA, rir: ripe}, 924 {ua: remoteUA, rir: apnic}, 925 }, 926 }, 927 { 928 name: "2 hijacked RVAs, CAA issue type present", 929 ident: identifier.NewDNS("present.com"), 930 expectedProbSubstring: "During secondary validation: While processing CAA", 931 expectedProbType: probs.CAAProblem, 932 expectedDiffLogSubstring: `"RemoteSuccesses":1,"RemoteFailures":2`, 933 expectedSummary: &mpicSummary{ 934 Passed: []string{"dc-2-APNIC"}, 935 Failed: []string{"dc-0-ARIN", "dc-1-RIPE"}, 936 PassedRIRs: []string{apnic}, 937 QuorumResult: "1/3", 938 }, 939 localDNSClient: caaMockDNS{}, 940 remoteVAs: []remoteConf{ 941 {ua: hijackedUA, rir: arin, dns: caaHijackedDNS{}}, 942 {ua: hijackedUA, rir: ripe, dns: caaHijackedDNS{}}, 943 {ua: remoteUA, rir: apnic}, 944 }, 945 }, 946 { 947 name: "3 hijacked RVAs, CAA issue type present", 948 ident: identifier.NewDNS("present.com"), 949 expectedProbSubstring: "During secondary validation: While processing CAA", 950 expectedProbType: probs.CAAProblem, 951 expectedDiffLogSubstring: `"RemoteSuccesses":0,"RemoteFailures":3`, 952 expectedSummary: &mpicSummary{ 953 Passed: []string{}, 954 Failed: []string{"dc-0-ARIN", "dc-1-RIPE", "dc-2-APNIC"}, 955 PassedRIRs: []string{}, 956 QuorumResult: "0/3", 957 }, 958 localDNSClient: caaMockDNS{}, 959 remoteVAs: []remoteConf{ 960 {ua: hijackedUA, rir: arin, dns: caaHijackedDNS{}}, 961 {ua: hijackedUA, rir: ripe, dns: caaHijackedDNS{}}, 962 {ua: hijackedUA, rir: apnic, dns: caaHijackedDNS{}}, 963 }, 964 }, 965 { 966 name: "1 hijacked RVA, CAA issuewild type present", 967 ident: identifier.NewDNS("satisfiable-wildcard.com"), 968 expectedDiffLogSubstring: `"RemoteSuccesses":2,"RemoteFailures":1`, 969 expectedSummary: &mpicSummary{ 970 Passed: []string{"dc-1-RIPE", "dc-2-APNIC"}, 971 Failed: []string{"dc-0-ARIN"}, 972 PassedRIRs: []string{ripe, apnic}, 973 QuorumResult: "2/3", 974 }, 975 localDNSClient: caaMockDNS{}, 976 remoteVAs: []remoteConf{ 977 {ua: hijackedUA, rir: arin, dns: caaHijackedDNS{}}, 978 {ua: remoteUA, rir: ripe}, 979 {ua: remoteUA, rir: apnic}, 980 }, 981 }, 982 { 983 name: "2 hijacked RVAs, CAA issuewild type present", 984 ident: identifier.NewDNS("satisfiable-wildcard.com"), 985 expectedProbSubstring: "During secondary validation: While processing CAA", 986 expectedProbType: probs.CAAProblem, 987 expectedDiffLogSubstring: `"RemoteSuccesses":1,"RemoteFailures":2`, 988 expectedSummary: &mpicSummary{ 989 Passed: []string{"dc-2-APNIC"}, 990 Failed: []string{"dc-0-ARIN", "dc-1-RIPE"}, 991 PassedRIRs: []string{apnic}, 992 QuorumResult: "1/3", 993 }, 994 localDNSClient: caaMockDNS{}, 995 remoteVAs: []remoteConf{ 996 {ua: hijackedUA, rir: arin, dns: caaHijackedDNS{}}, 997 {ua: hijackedUA, rir: ripe, dns: caaHijackedDNS{}}, 998 {ua: remoteUA, rir: apnic}, 999 }, 1000 }, 1001 { 1002 name: "3 hijacked RVAs, CAA issuewild type present", 1003 ident: identifier.NewDNS("satisfiable-wildcard.com"), 1004 expectedProbSubstring: "During secondary validation: While processing CAA", 1005 expectedProbType: probs.CAAProblem, 1006 expectedDiffLogSubstring: `"RemoteSuccesses":0,"RemoteFailures":3`, 1007 expectedSummary: &mpicSummary{ 1008 Passed: []string{}, 1009 Failed: []string{"dc-0-ARIN", "dc-1-RIPE", "dc-2-APNIC"}, 1010 PassedRIRs: []string{}, 1011 QuorumResult: "0/3", 1012 }, 1013 localDNSClient: caaMockDNS{}, 1014 remoteVAs: []remoteConf{ 1015 {ua: hijackedUA, rir: arin, dns: caaHijackedDNS{}}, 1016 {ua: hijackedUA, rir: ripe, dns: caaHijackedDNS{}}, 1017 {ua: hijackedUA, rir: apnic, dns: caaHijackedDNS{}}, 1018 }, 1019 }, 1020 { 1021 name: "1 hijacked RVA, CAA issuewild type present, 1 failure allowed", 1022 ident: identifier.NewDNS("satisfiable-wildcard.com"), 1023 expectedDiffLogSubstring: `"RemoteSuccesses":2,"RemoteFailures":1`, 1024 expectedSummary: &mpicSummary{ 1025 Passed: []string{"dc-1-RIPE", "dc-2-APNIC"}, 1026 Failed: []string{"dc-0-ARIN"}, 1027 PassedRIRs: []string{ripe, apnic}, 1028 QuorumResult: "2/3", 1029 }, 1030 localDNSClient: caaMockDNS{}, 1031 remoteVAs: []remoteConf{ 1032 {ua: hijackedUA, rir: arin, dns: caaHijackedDNS{}}, 1033 {ua: remoteUA, rir: ripe}, 1034 {ua: remoteUA, rir: apnic}, 1035 }, 1036 }, 1037 { 1038 name: "2 hijacked RVAs, CAA issuewild type present, 1 failure allowed", 1039 ident: identifier.NewDNS("satisfiable-wildcard.com"), 1040 expectedProbSubstring: "During secondary validation: While processing CAA", 1041 expectedProbType: probs.CAAProblem, 1042 expectedDiffLogSubstring: `"RemoteSuccesses":1,"RemoteFailures":2`, 1043 expectedSummary: &mpicSummary{ 1044 Passed: []string{"dc-2-APNIC"}, 1045 Failed: []string{"dc-0-ARIN", "dc-1-RIPE"}, 1046 PassedRIRs: []string{apnic}, 1047 QuorumResult: "1/3", 1048 }, 1049 localDNSClient: caaMockDNS{}, 1050 remoteVAs: []remoteConf{ 1051 {ua: hijackedUA, rir: arin, dns: caaHijackedDNS{}}, 1052 {ua: hijackedUA, rir: ripe, dns: caaHijackedDNS{}}, 1053 {ua: remoteUA, rir: apnic}, 1054 }, 1055 }, 1056 { 1057 name: "3 hijacked RVAs, CAA issuewild type present, 1 failure allowed", 1058 ident: identifier.NewDNS("satisfiable-wildcard.com"), 1059 expectedProbSubstring: "During secondary validation: While processing CAA", 1060 expectedProbType: probs.CAAProblem, 1061 expectedDiffLogSubstring: `"RemoteSuccesses":0,"RemoteFailures":3`, 1062 expectedSummary: &mpicSummary{ 1063 Passed: []string{}, 1064 Failed: []string{"dc-0-ARIN", "dc-1-RIPE", "dc-2-APNIC"}, 1065 PassedRIRs: []string{}, 1066 QuorumResult: "0/3", 1067 }, 1068 localDNSClient: caaMockDNS{}, 1069 remoteVAs: []remoteConf{ 1070 {ua: hijackedUA, rir: arin, dns: caaHijackedDNS{}}, 1071 {ua: hijackedUA, rir: ripe, dns: caaHijackedDNS{}}, 1072 {ua: hijackedUA, rir: apnic, dns: caaHijackedDNS{}}, 1073 }, 1074 }, 1075 } 1076 1077 for _, tc := range testCases { 1078 t.Run(tc.name, func(t *testing.T) { 1079 va, mockLog := setupWithRemotes(nil, localUA, tc.remoteVAs, tc.localDNSClient) 1080 defer mockLog.Clear() 1081 1082 features.Set(features.Config{ 1083 EnforceMultiCAA: true, 1084 }) 1085 defer features.Reset() 1086 1087 isValidRes, err := va.DoCAA(context.TODO(), &vapb.IsCAAValidRequest{ 1088 Identifier: tc.ident.ToProto(), 1089 ValidationMethod: string(core.ChallengeTypeDNS01), 1090 AccountURIID: 1, 1091 }) 1092 test.AssertNotError(t, err, "Should not have errored, but did") 1093 1094 if tc.expectedProbSubstring != "" { 1095 test.AssertNotNil(t, isValidRes.Problem, "IsCAAValidRequest returned nil problem, but should not have") 1096 test.AssertContains(t, isValidRes.Problem.Detail, tc.expectedProbSubstring) 1097 } else if isValidRes.Problem != nil { 1098 test.AssertBoxedNil(t, isValidRes.Problem, "IsCAAValidRequest returned a problem, but should not have") 1099 } 1100 1101 if tc.expectedProbType != "" { 1102 test.AssertNotNil(t, isValidRes.Problem, "IsCAAValidRequest returned nil problem, but should not have") 1103 test.AssertEquals(t, string(tc.expectedProbType), isValidRes.Problem.ProblemType) 1104 } 1105 1106 if tc.expectedSummary != nil { 1107 gotAuditLog := parseValidationLogEvent(t, mockLog.GetAllMatching("JSON=.*")) 1108 slices.Sort(tc.expectedSummary.Passed) 1109 slices.Sort(tc.expectedSummary.Failed) 1110 slices.Sort(tc.expectedSummary.PassedRIRs) 1111 test.AssertDeepEquals(t, gotAuditLog.Summary, tc.expectedSummary) 1112 } 1113 1114 gotAnyRemoteFailures := mockLog.GetAllMatching("CAA check failed due to remote failures:") 1115 if len(gotAnyRemoteFailures) >= 1 { 1116 // The primary VA only emits this line once. 1117 test.AssertEquals(t, len(gotAnyRemoteFailures), 1) 1118 } else { 1119 test.AssertEquals(t, len(gotAnyRemoteFailures), 0) 1120 } 1121 1122 if tc.expectedLabels != nil { 1123 test.AssertMetricWithLabelsEquals(t, va.metrics.validationLatency, tc.expectedLabels, 1) 1124 } 1125 1126 }) 1127 } 1128 } 1129 1130 func TestCAAFailure(t *testing.T) { 1131 hs := httpSrv(t, expectedToken, false) 1132 defer hs.Close() 1133 1134 va, _ := setup(hs, "", nil, caaMockDNS{}) 1135 1136 err := va.checkCAA(ctx, identifier.NewDNS("reserved.com"), &caaParams{1, core.ChallengeTypeHTTP01}) 1137 if err == nil { 1138 t.Fatalf("Expected CAA rejection for reserved.com, got success") 1139 } 1140 test.AssertErrorIs(t, err, berrors.CAA) 1141 1142 err = va.checkCAA(ctx, identifier.NewDNS("example.gonetld"), &caaParams{1, core.ChallengeTypeHTTP01}) 1143 if err == nil { 1144 t.Fatalf("Expected CAA rejection for gonetld, got success") 1145 } 1146 prob := detailedError(err) 1147 test.AssertEquals(t, prob.Type, probs.DNSProblem) 1148 test.AssertContains(t, prob.String(), "NXDOMAIN") 1149 } 1150 1151 func TestFilterCAA(t *testing.T) { 1152 testCases := []struct { 1153 name string 1154 input []*dns.CAA 1155 expectedIssueVals []string 1156 expectedWildVals []string 1157 expectedCU bool 1158 }{ 1159 { 1160 name: "recognized non-critical", 1161 input: []*dns.CAA{ 1162 {Tag: "issue", Value: "a"}, 1163 {Tag: "issuewild", Value: "b"}, 1164 {Tag: "iodef", Value: "c"}, 1165 {Tag: "issuemail", Value: "c"}, 1166 {Tag: "issuevmc", Value: "c"}, 1167 }, 1168 expectedIssueVals: []string{"a"}, 1169 expectedWildVals: []string{"b"}, 1170 }, 1171 { 1172 name: "recognized critical", 1173 input: []*dns.CAA{ 1174 {Tag: "issue", Value: "a", Flag: 128}, 1175 {Tag: "issuewild", Value: "b", Flag: 128}, 1176 {Tag: "iodef", Value: "c", Flag: 128}, 1177 {Tag: "issuemail", Value: "c", Flag: 128}, 1178 {Tag: "issuevmc", Value: "c", Flag: 128}, 1179 }, 1180 expectedIssueVals: []string{"a"}, 1181 expectedWildVals: []string{"b"}, 1182 }, 1183 { 1184 name: "unrecognized non-critical", 1185 input: []*dns.CAA{ 1186 {Tag: "unknown", Flag: 2}, 1187 }, 1188 }, 1189 { 1190 name: "unrecognized critical", 1191 input: []*dns.CAA{ 1192 {Tag: "unknown", Flag: 128}, 1193 }, 1194 expectedCU: true, 1195 }, 1196 { 1197 name: "unrecognized improper critical", 1198 input: []*dns.CAA{ 1199 {Tag: "unknown", Flag: 1}, 1200 }, 1201 expectedCU: true, 1202 }, 1203 { 1204 name: "unrecognized very improper critical", 1205 input: []*dns.CAA{ 1206 {Tag: "unknown", Flag: 9}, 1207 }, 1208 expectedCU: true, 1209 }, 1210 } 1211 1212 for _, tc := range testCases { 1213 t.Run(tc.name, func(t *testing.T) { 1214 issue, wild, cu := filterCAA(tc.input) 1215 for _, tag := range issue { 1216 test.AssertSliceContains(t, tc.expectedIssueVals, tag.Value) 1217 } 1218 for _, tag := range wild { 1219 test.AssertSliceContains(t, tc.expectedWildVals, tag.Value) 1220 } 1221 test.AssertEquals(t, tc.expectedCU, cu) 1222 }) 1223 } 1224 } 1225 1226 func TestSelectCAA(t *testing.T) { 1227 expected := dns.CAA{Tag: "issue", Value: "foo"} 1228 1229 // An empty slice of caaResults should return nil, nil 1230 r := []caaResult{} 1231 s, err := selectCAA(r) 1232 test.Assert(t, s == nil, "set is not nil") 1233 test.AssertNotError(t, err, "error is not nil") 1234 1235 // A slice of empty caaResults should return nil, "", nil 1236 r = []caaResult{ 1237 {"", false, nil, nil, false, "", nil, nil}, 1238 {"", false, nil, nil, false, "", nil, nil}, 1239 {"", false, nil, nil, false, "", nil, nil}, 1240 } 1241 s, err = selectCAA(r) 1242 test.Assert(t, s == nil, "set is not nil") 1243 test.AssertNotError(t, err, "error is not nil") 1244 1245 // A slice of caaResults containing an error followed by a CAA 1246 // record should return the error 1247 r = []caaResult{ 1248 {"foo.com", false, nil, nil, false, "", nil, errors.New("oops")}, 1249 {"com", true, []*dns.CAA{&expected}, nil, false, "foo", nil, nil}, 1250 } 1251 s, err = selectCAA(r) 1252 test.Assert(t, s == nil, "set is not nil") 1253 test.AssertError(t, err, "error is nil") 1254 test.AssertEquals(t, err.Error(), "oops") 1255 1256 // A slice of caaResults containing a good record that precedes an 1257 // error, should return that good record, not the error 1258 r = []caaResult{ 1259 {"foo.com", true, []*dns.CAA{&expected}, nil, false, "foo", nil, nil}, 1260 {"com", false, nil, nil, false, "", nil, errors.New("")}, 1261 } 1262 s, err = selectCAA(r) 1263 test.AssertEquals(t, len(s.issue), 1) 1264 test.Assert(t, s.issue[0] == &expected, "Incorrect record returned") 1265 test.AssertEquals(t, s.dig, "foo") 1266 test.Assert(t, err == nil, "error is not nil") 1267 1268 // A slice of caaResults containing multiple CAA records should 1269 // return the first non-empty CAA record 1270 r = []caaResult{ 1271 {"bar.foo.com", false, []*dns.CAA{}, []*dns.CAA{}, false, "", nil, nil}, 1272 {"foo.com", true, []*dns.CAA{&expected}, nil, false, "foo", nil, nil}, 1273 {"com", true, []*dns.CAA{&expected}, nil, false, "bar", nil, nil}, 1274 } 1275 s, err = selectCAA(r) 1276 test.AssertEquals(t, len(s.issue), 1) 1277 test.Assert(t, s.issue[0] == &expected, "Incorrect record returned") 1278 test.AssertEquals(t, s.dig, "foo") 1279 test.AssertNotError(t, err, "expect nil error") 1280 } 1281 1282 func TestAccountURIMatches(t *testing.T) { 1283 t.Parallel() 1284 tests := []struct { 1285 name string 1286 params []caaParameter 1287 prefixes []string 1288 id int64 1289 want bool 1290 }{ 1291 { 1292 name: "empty accounturi", 1293 params: nil, 1294 prefixes: []string{ 1295 "https://acme-v01.api.letsencrypt.org/acme/reg/", 1296 }, 1297 id: 123456, 1298 want: true, 1299 }, 1300 { 1301 name: "no accounturi in rr, but other parameters exist", 1302 params: []caaParameter{{tag: "validationmethods", val: "tls-alpn-01"}}, 1303 prefixes: []string{ 1304 "https://acme-v02.api.letsencrypt.org/acme/reg/", 1305 }, 1306 id: 123456, 1307 want: true, 1308 }, 1309 { 1310 name: "non-uri accounturi", 1311 params: []caaParameter{{tag: "accounturi", val: "\\invalid 😎/123456"}}, 1312 prefixes: []string{ 1313 "\\invalid 😎", 1314 }, 1315 id: 123456, 1316 want: false, 1317 }, 1318 { 1319 name: "simple match", 1320 params: []caaParameter{{tag: "accounturi", val: "https://acme-v01.api.letsencrypt.org/acme/reg/123456"}}, 1321 prefixes: []string{ 1322 "https://acme-v01.api.letsencrypt.org/acme/reg/", 1323 }, 1324 id: 123456, 1325 want: true, 1326 }, 1327 { 1328 name: "simple match, but has a friend", 1329 params: []caaParameter{{tag: "validationmethods", val: "dns-01"}, {tag: "accounturi", val: "https://acme-v01.api.letsencrypt.org/acme/reg/123456"}}, 1330 prefixes: []string{ 1331 "https://acme-v01.api.letsencrypt.org/acme/reg/", 1332 }, 1333 id: 123456, 1334 want: true, 1335 }, 1336 { 1337 name: "accountid mismatch", 1338 params: []caaParameter{{tag: "accounturi", val: "https://acme-v01.api.letsencrypt.org/acme/reg/123456"}}, 1339 prefixes: []string{ 1340 "https://acme-v01.api.letsencrypt.org/acme/reg/", 1341 }, 1342 id: 123457, 1343 want: false, 1344 }, 1345 { 1346 name: "single parameter, no value", 1347 params: []caaParameter{{tag: "accounturi", val: ""}}, 1348 prefixes: []string{ 1349 "https://acme-v02.api.letsencrypt.org/acme/reg/", 1350 }, 1351 id: 123456, 1352 want: false, 1353 }, 1354 { 1355 name: "multiple parameters, each with no value", 1356 params: []caaParameter{{tag: "accounturi", val: ""}, {tag: "accounturi", val: ""}}, 1357 prefixes: []string{ 1358 "https://acme-v02.api.letsencrypt.org/acme/reg/", 1359 }, 1360 id: 123456, 1361 want: false, 1362 }, 1363 { 1364 name: "multiple parameters, one with no value", 1365 params: []caaParameter{{tag: "accounturi", val: ""}, {tag: "accounturi", val: "https://acme-v02.api.letsencrypt.org/acme/reg/123456"}}, 1366 prefixes: []string{ 1367 "https://acme-v02.api.letsencrypt.org/acme/reg/", 1368 }, 1369 id: 123456, 1370 want: false, 1371 }, 1372 { 1373 name: "multiple parameters, each with an identical value", 1374 params: []caaParameter{{tag: "accounturi", val: "https://acme-v02.api.letsencrypt.org/acme/reg/123456"}, {tag: "accounturi", val: "https://acme-v02.api.letsencrypt.org/acme/reg/123456"}}, 1375 prefixes: []string{ 1376 "https://acme-v02.api.letsencrypt.org/acme/reg/", 1377 }, 1378 id: 123456, 1379 want: false, 1380 }, 1381 { 1382 name: "multiple parameters, each with a different value", 1383 params: []caaParameter{{tag: "accounturi", val: "https://acme-v02.api.letsencrypt.org/acme/reg/69"}, {tag: "accounturi", val: "https://acme-v02.api.letsencrypt.org/acme/reg/420"}}, 1384 prefixes: []string{ 1385 "https://acme-v02.api.letsencrypt.org/acme/reg/", 1386 }, 1387 id: 69, 1388 want: false, 1389 }, 1390 { 1391 name: "multiple prefixes, match first", 1392 params: []caaParameter{{tag: "accounturi", val: "https://acme-staging.api.letsencrypt.org/acme/reg/123456"}}, 1393 prefixes: []string{ 1394 "https://acme-staging.api.letsencrypt.org/acme/reg/", 1395 "https://acme-staging-v02.api.letsencrypt.org/acme/acct/", 1396 }, 1397 id: 123456, 1398 want: true, 1399 }, 1400 { 1401 name: "multiple prefixes, match second", 1402 params: []caaParameter{{tag: "accounturi", val: "https://acme-v02.api.letsencrypt.org/acme/acct/123456"}}, 1403 prefixes: []string{ 1404 "https://acme-v01.api.letsencrypt.org/acme/reg/", 1405 "https://acme-v02.api.letsencrypt.org/acme/acct/", 1406 }, 1407 id: 123456, 1408 want: true, 1409 }, 1410 { 1411 name: "multiple prefixes, match none", 1412 params: []caaParameter{{tag: "accounturi", val: "https://acme-v02.api.letsencrypt.org/acme/acct/123456"}}, 1413 prefixes: []string{ 1414 "https://acme-v01.api.letsencrypt.org/acme/acct/", 1415 "https://acme-v03.api.letsencrypt.org/acme/acct/", 1416 }, 1417 id: 123456, 1418 want: false, 1419 }, 1420 { 1421 name: "three prefixes", 1422 params: []caaParameter{{tag: "accounturi", val: "https://acme-v02.api.letsencrypt.org/acme/acct/123456"}}, 1423 prefixes: []string{ 1424 "https://acme-v01.api.letsencrypt.org/acme/reg/", 1425 "https://acme-v02.api.letsencrypt.org/acme/acct/", 1426 "https://acme-v03.api.letsencrypt.org/acme/acct/", 1427 }, 1428 id: 123456, 1429 want: true, 1430 }, 1431 { 1432 name: "multiple prefixes, wrong accountid", 1433 params: []caaParameter{{tag: "accounturi", val: "https://acme-v02.api.letsencrypt.org/acme/acct/123456"}}, 1434 prefixes: []string{ 1435 "https://acme-v01.api.letsencrypt.org/acme/reg/", 1436 "https://acme-v02.api.letsencrypt.org/acme/acct/", 1437 }, 1438 id: 654321, 1439 want: false, 1440 }, 1441 } 1442 1443 for _, tc := range tests { 1444 t.Run(tc.name, func(t *testing.T) { 1445 t.Parallel() 1446 got := caaAccountURIMatches(tc.params, tc.prefixes, tc.id) 1447 test.AssertEquals(t, got, tc.want) 1448 }) 1449 } 1450 } 1451 1452 func TestValidationMethodMatches(t *testing.T) { 1453 t.Parallel() 1454 tests := []struct { 1455 name string 1456 params []caaParameter 1457 method core.AcmeChallenge 1458 want bool 1459 }{ 1460 { 1461 name: "empty validationmethods", 1462 params: nil, 1463 method: core.ChallengeTypeHTTP01, 1464 want: true, 1465 }, 1466 { 1467 name: "no validationmethods in rr, but other parameters exist", // validationmethods is not mandatory 1468 params: []caaParameter{{tag: "accounturi", val: "ph1LwuzHere"}}, 1469 method: core.ChallengeTypeHTTP01, 1470 want: true, 1471 }, 1472 { 1473 name: "no value", 1474 params: []caaParameter{{tag: "validationmethods", val: ""}}, // equivalent to forbidding issuance 1475 method: core.ChallengeTypeHTTP01, 1476 want: false, 1477 }, 1478 { 1479 name: "only comma", 1480 params: []caaParameter{{tag: "validationmethods", val: ","}}, 1481 method: core.ChallengeTypeHTTP01, 1482 want: false, 1483 }, 1484 { 1485 name: "malformed method", 1486 params: []caaParameter{{tag: "validationmethods", val: "howdy !"}}, 1487 method: core.ChallengeTypeHTTP01, 1488 want: false, 1489 }, 1490 { 1491 name: "invalid method", 1492 params: []caaParameter{{tag: "validationmethods", val: "tls-sni-01"}}, 1493 method: core.ChallengeTypeHTTP01, 1494 want: false, 1495 }, 1496 { 1497 name: "simple match", 1498 params: []caaParameter{{tag: "validationmethods", val: "http-01"}}, 1499 method: core.ChallengeTypeHTTP01, 1500 want: true, 1501 }, 1502 { 1503 name: "simple match, but has a friend", 1504 params: []caaParameter{{tag: "accounturi", val: "https://example.org"}, {tag: "validationmethods", val: "http-01"}}, 1505 method: core.ChallengeTypeHTTP01, 1506 want: true, 1507 }, 1508 { 1509 name: "multiple validationmethods, each with no value", 1510 params: []caaParameter{{tag: "validationmethods", val: ""}, {tag: "validationmethods", val: ""}}, 1511 method: core.ChallengeTypeHTTP01, 1512 want: false, 1513 }, 1514 { 1515 name: "multiple validationmethods, one with no value", 1516 params: []caaParameter{{tag: "validationmethods", val: ""}, {tag: "validationmethods", val: "http-01"}}, 1517 method: core.ChallengeTypeHTTP01, 1518 want: false, 1519 }, 1520 { 1521 name: "multiple validationmethods, each with an identical value", 1522 params: []caaParameter{{tag: "validationmethods", val: "http-01"}, {tag: "validationmethods", val: "http-01"}}, 1523 method: core.ChallengeTypeHTTP01, 1524 want: false, 1525 }, 1526 { 1527 name: "multiple validationmethods, each with a different value", 1528 params: []caaParameter{{tag: "validationmethods", val: "http-01"}, {tag: "validationmethods", val: "dns-01"}}, 1529 method: core.ChallengeTypeHTTP01, 1530 want: false, 1531 }, 1532 { 1533 name: "simple mismatch", 1534 params: []caaParameter{{tag: "validationmethods", val: "dns-01"}}, 1535 method: core.ChallengeTypeHTTP01, 1536 want: false, 1537 }, 1538 { 1539 name: "multiple choices, match first", 1540 params: []caaParameter{{tag: "validationmethods", val: "http-01,dns-01"}}, 1541 method: core.ChallengeTypeHTTP01, 1542 want: true, 1543 }, 1544 { 1545 name: "multiple choices, match second", 1546 params: []caaParameter{{tag: "validationmethods", val: "http-01,dns-01"}}, 1547 method: core.ChallengeTypeDNS01, 1548 want: true, 1549 }, 1550 { 1551 name: "multiple choices, match none", 1552 params: []caaParameter{{tag: "validationmethods", val: "http-01,dns-01"}}, 1553 method: core.ChallengeTypeTLSALPN01, 1554 want: false, 1555 }, 1556 } 1557 1558 for _, tc := range tests { 1559 t.Run(tc.name, func(t *testing.T) { 1560 t.Parallel() 1561 got := caaValidationMethodMatches(tc.params, tc.method) 1562 test.AssertEquals(t, got, tc.want) 1563 }) 1564 } 1565 } 1566 1567 func TestExtractIssuerDomainAndParameters(t *testing.T) { 1568 t.Parallel() 1569 tests := []struct { 1570 name string 1571 value string 1572 wantDomain string 1573 wantParameters []caaParameter 1574 expectErrSubstr string 1575 }{ 1576 { 1577 name: "empty record is valid", 1578 value: "", 1579 wantDomain: "", 1580 wantParameters: nil, 1581 expectErrSubstr: "", 1582 }, 1583 { 1584 name: "only semicolon is valid", 1585 value: ";", 1586 wantDomain: "", 1587 wantParameters: nil, 1588 expectErrSubstr: "", 1589 }, 1590 { 1591 name: "only semicolon and whitespace is valid", 1592 value: " ; ", 1593 wantDomain: "", 1594 wantParameters: nil, 1595 expectErrSubstr: "", 1596 }, 1597 { 1598 name: "only domain is valid", 1599 value: "letsencrypt.org", 1600 wantDomain: "letsencrypt.org", 1601 wantParameters: nil, 1602 expectErrSubstr: "", 1603 }, 1604 { 1605 name: "only domain with trailing semicolon is valid", 1606 value: "letsencrypt.org;", 1607 wantDomain: "letsencrypt.org", 1608 wantParameters: nil, 1609 expectErrSubstr: "", 1610 }, 1611 { 1612 name: "only domain with semicolon and trailing whitespace is valid", 1613 value: "letsencrypt.org; ", 1614 wantDomain: "letsencrypt.org", 1615 wantParameters: nil, 1616 expectErrSubstr: "", 1617 }, 1618 { 1619 name: "domain with params and whitespace is valid", 1620 value: " letsencrypt.org ;foo=bar;baz=bar", 1621 wantDomain: "letsencrypt.org", 1622 wantParameters: []caaParameter{{tag: "foo", val: "bar"}, {tag: "baz", val: "bar"}}, 1623 expectErrSubstr: "", 1624 }, 1625 { 1626 name: "domain with params and different whitespace is valid", 1627 value: " letsencrypt.org ;foo=bar;baz=bar", 1628 wantDomain: "letsencrypt.org", 1629 wantParameters: []caaParameter{{tag: "foo", val: "bar"}, {tag: "baz", val: "bar"}}, 1630 expectErrSubstr: "", 1631 }, 1632 { 1633 name: "empty params are valid", 1634 value: "letsencrypt.org; foo=; baz = bar", 1635 wantDomain: "letsencrypt.org", 1636 wantParameters: []caaParameter{{tag: "foo", val: ""}, {tag: "baz", val: "bar"}}, 1637 expectErrSubstr: "", 1638 }, 1639 { 1640 name: "whitespace around params is valid", 1641 value: "letsencrypt.org; foo= ; baz = bar", 1642 wantDomain: "letsencrypt.org", 1643 wantParameters: []caaParameter{{tag: "foo", val: ""}, {tag: "baz", val: "bar"}}, 1644 expectErrSubstr: "", 1645 }, 1646 { 1647 name: "comma-separated param values are valid", 1648 value: "letsencrypt.org; foo=b1,b2,b3 ; baz = a=b ", 1649 wantDomain: "letsencrypt.org", 1650 wantParameters: []caaParameter{{tag: "foo", val: "b1,b2,b3"}, {tag: "baz", val: "a=b"}}, 1651 expectErrSubstr: "", 1652 }, 1653 { 1654 name: "duplicate tags are valid", 1655 value: "letsencrypt.org; foo=b1,b2,b3 ; foo= b1,b2,b3 ", 1656 wantDomain: "letsencrypt.org", 1657 wantParameters: []caaParameter{{tag: "foo", val: "b1,b2,b3"}, {tag: "foo", val: "b1,b2,b3"}}, 1658 expectErrSubstr: "", 1659 }, 1660 { 1661 name: "spaces in param values are invalid", 1662 value: "letsencrypt.org; foo=b1,b2,b3 ; baz = a = b ", 1663 expectErrSubstr: "value contains disallowed character", 1664 }, 1665 { 1666 name: "spaces in param values are still invalid", 1667 value: "letsencrypt.org; foo=b1,b2,b3 ; baz=a= b", 1668 expectErrSubstr: "value contains disallowed character", 1669 }, 1670 { 1671 name: "param without equals sign is invalid", 1672 value: "letsencrypt.org; foo=b1,b2,b3 ; baz = a;b ", 1673 expectErrSubstr: "parameter not formatted as tag=value", 1674 }, 1675 { 1676 name: "hyphens in param values are valid", 1677 value: "letsencrypt.org; 1=2; baz=a-b", 1678 wantDomain: "letsencrypt.org", 1679 wantParameters: []caaParameter{{tag: "1", val: "2"}, {tag: "baz", val: "a-b"}}, 1680 expectErrSubstr: "", 1681 }, 1682 { 1683 name: "underscores in param tags are invalid", 1684 value: "letsencrypt.org; a_b=123", 1685 expectErrSubstr: "tag contains disallowed character", 1686 }, 1687 { 1688 name: "multiple spaces in param values are extra invalid", 1689 value: "letsencrypt.org; ab=1 2 3", 1690 expectErrSubstr: "value contains disallowed character", 1691 }, 1692 { 1693 name: "hyphens in param tags are invalid", 1694 value: "letsencrypt.org; 1=2; a-b=c", 1695 expectErrSubstr: "tag contains disallowed character", 1696 }, 1697 { 1698 name: "high codepoints in params are invalid", 1699 value: "letsencrypt.org; foo=a\u2615b", 1700 expectErrSubstr: "value contains disallowed character", 1701 }, 1702 { 1703 name: "missing semicolons between params are invalid", 1704 value: "letsencrypt.org; foo=b1,b2,b3 baz=a", 1705 expectErrSubstr: "value contains disallowed character", 1706 }, 1707 } 1708 for _, tc := range tests { 1709 t.Run(tc.name, func(t *testing.T) { 1710 t.Parallel() 1711 gotDomain, gotParameters, gotErr := parseCAARecord(&dns.CAA{Value: tc.value}) 1712 1713 if tc.expectErrSubstr == "" { 1714 test.AssertNotError(t, gotErr, "") 1715 } else { 1716 test.AssertError(t, gotErr, "") 1717 test.AssertContains(t, gotErr.Error(), tc.expectErrSubstr) 1718 } 1719 1720 if tc.wantDomain != "" { 1721 test.AssertEquals(t, gotDomain, tc.wantDomain) 1722 } 1723 1724 if tc.wantParameters != nil { 1725 test.AssertDeepEquals(t, gotParameters, tc.wantParameters) 1726 } 1727 }) 1728 } 1729 }