Merge pull request #1121 from letsencrypt/no-500-dns

Don't serve 500's on DNS timeout.
This commit is contained in:
Jeff Hodges 2015-11-12 12:23:28 -08:00
commit af83b734eb
7 changed files with 201 additions and 79 deletions

29
dns/problem.go Normal file
View File

@ -0,0 +1,29 @@
package dns
import (
"net"
"github.com/letsencrypt/boulder/core"
)
const detailDNSTimeout = "DNS query timed out"
const detailDNSNetFailure = "DNS networking error"
const detailServerFailure = "Server failure at resolver"
// ProblemDetailsFromDNSError checks the error returned from Lookup...
// methods and tests if the error was an underlying net.OpError or an error
// caused by resolver returning SERVFAIL or other invalid Rcodes and returns
// the relevant core.ProblemDetails.
func ProblemDetailsFromDNSError(err error) *core.ProblemDetails {
problem := &core.ProblemDetails{Type: core.ConnectionProblem}
if netErr, ok := err.(*net.OpError); ok {
if netErr.Timeout() {
problem.Detail = detailDNSTimeout
} else {
problem.Detail = detailDNSNetFailure
}
} else {
problem.Detail = detailServerFailure
}
return problem
}

37
dns/problem_test.go Normal file
View File

@ -0,0 +1,37 @@
package dns
import (
"errors"
"net"
"testing"
"github.com/letsencrypt/boulder/core"
"github.com/letsencrypt/boulder/mocks"
)
func TestProblemDetailsFromDNSError(t *testing.T) {
testCases := []struct {
err error
expected string
}{
{
mocks.TimeoutError(),
detailDNSTimeout,
}, {
errors.New("other failure"),
detailServerFailure,
}, {
&net.OpError{Err: errors.New("some net error")},
detailDNSNetFailure,
},
}
for _, tc := range testCases {
err := ProblemDetailsFromDNSError(tc.err)
if err.Type != core.ConnectionProblem {
t.Errorf("ProblemDetailsFromDNSError(%q).Type = %q, expected %q", tc.err, err.Type, core.ConnectionProblem)
}
if err.Detail != tc.expected {
t.Errorf("ProblemDetailsFromDNSError(%q).Detail = %q, expected %q", tc.err, err.Detail, tc.expected)
}
}
}

View File

@ -12,6 +12,7 @@ import (
"fmt"
"io/ioutil"
"net"
"os"
"strings"
"time"
@ -44,11 +45,35 @@ func (mock *DNSResolver) LookupTXT(hostname string) ([]string, time.Duration, er
return []string{"hostname"}, 0, nil
}
// TimeoutError returns a a net.OpError for which Timeout() returns true.
func TimeoutError() *net.OpError {
return &net.OpError{
Err: os.NewSyscallError("ugh timeout", timeoutError{}),
}
}
type timeoutError struct{}
func (t timeoutError) Error() string {
return "so sloooow"
}
func (t timeoutError) Timeout() bool {
return true
}
// LookupHost is a mock
func (mock *DNSResolver) LookupHost(hostname string) ([]net.IP, time.Duration, error) {
if hostname == "always.invalid" || hostname == "invalid.invalid" {
return []net.IP{}, 0, nil
}
if hostname == "always.timeout" {
return []net.IP{}, 0, TimeoutError()
}
if hostname == "always.error" {
return []net.IP{}, 0, &net.OpError{
Err: errors.New("some net error"),
}
}
ip := net.ParseIP("127.0.0.1")
return []net.IP{ip}, 0, nil
}
@ -58,6 +83,8 @@ func (mock *DNSResolver) LookupCAA(domain string) ([]*dns.CAA, time.Duration, er
var results []*dns.CAA
var record dns.CAA
switch strings.TrimRight(domain, ".") {
case "caa-timeout.com":
return nil, 0, TimeoutError()
case "reserved.com":
record.Tag = "issue"
record.Value = "symantec.com"

View File

@ -23,6 +23,7 @@ import (
"github.com/letsencrypt/boulder/cmd"
"github.com/letsencrypt/boulder/core"
"github.com/letsencrypt/boulder/dns"
blog "github.com/letsencrypt/boulder/log"
)
@ -77,19 +78,23 @@ func NewRegistrationAuthorityImpl(clk clock.Clock, logger *blog.AuditLogger, sta
return ra
}
var errUnparseableEmail = errors.New("not a valid e-mail address")
var errEmptyDNSResponse = errors.New("empty DNS response")
func validateEmail(address string, resolver core.DNSResolver) (rtt time.Duration, err error) {
_, err = mail.ParseAddress(address)
if err != nil {
err = core.MalformedRequestError(fmt.Sprintf("%s is not a valid e-mail address", address))
return
return time.Duration(0), errUnparseableEmail
}
splitEmail := strings.SplitN(address, "@", -1)
domain := strings.ToLower(splitEmail[len(splitEmail)-1])
var mx []string
mx, rtt, err = resolver.LookupMX(domain)
if err != nil || len(mx) == 0 {
err = core.MalformedRequestError(fmt.Sprintf("No MX record for domain %s", domain))
return
result, rtt, err := resolver.LookupHost(domain)
if err == nil && len(result) == 0 {
err = errEmptyDNSResponse
}
if err != nil {
problem := dns.ProblemDetailsFromDNSError(err)
err = core.MalformedRequestError(problem.Detail)
}
return
@ -216,10 +221,11 @@ func (ra *RegistrationAuthorityImpl) validateContacts(contacts []*core.AcmeURL)
continue
case "mailto":
rtt, err := validateEmail(contact.Opaque, ra.DNSResolver)
ra.stats.TimingDuration("RA.DNS.RTT.MX", rtt, 1.0)
ra.stats.TimingDuration("RA.DNS.RTT.A", rtt, 1.0)
ra.stats.Inc("RA.DNS.Rate", 1, 1.0)
if err != nil {
return err
return core.MalformedRequestError(fmt.Sprintf(
"Validation of contact %s failed: %s", contact, err))
}
default:
err = core.MalformedRequestError(fmt.Sprintf("Contact method %s is not supported", contact.Scheme))
@ -281,18 +287,6 @@ func (ra *RegistrationAuthorityImpl) NewAuthorization(request core.Authorization
}
}
// Check CAA records for the requested identifier
present, valid, err := ra.VA.CheckCAARecords(identifier)
if err != nil {
return authz, err
}
// AUDIT[ Certificate Requests ] 11917fa4-10ef-4e0d-9105-bacbe7836a3c
ra.log.Audit(fmt.Sprintf("Checked CAA records for %s, registration ID %d [Present: %t, Valid for issuance: %t]", identifier.Value, regID, present, valid))
if !valid {
err = errors.New("CAA check for identifier failed")
return authz, err
}
// Create validations. The WFE will update them with URIs before sending them out.
challenges, combinations, err := ra.PA.ChallengesFor(identifier, &reg.Key)

View File

@ -279,7 +279,6 @@ func TestValidateContacts(t *testing.T) {
tel, _ := core.ParseAcmeURL("tel:")
ansible, _ := core.ParseAcmeURL("ansible:earth.sol.milkyway.laniakea/letsencrypt")
validEmail, _ := core.ParseAcmeURL("mailto:admin@email.com")
invalidEmail, _ := core.ParseAcmeURL("mailto:admin@example.com")
malformedEmail, _ := core.ParseAcmeURL("mailto:admin.com")
err := ra.validateContacts([]*core.AcmeURL{})
@ -294,30 +293,33 @@ func TestValidateContacts(t *testing.T) {
err = ra.validateContacts([]*core.AcmeURL{validEmail})
test.AssertNotError(t, err, "Valid Email")
err = ra.validateContacts([]*core.AcmeURL{invalidEmail})
test.AssertError(t, err, "Invalid Email")
err = ra.validateContacts([]*core.AcmeURL{malformedEmail})
test.AssertError(t, err, "Malformed Email")
err = ra.validateContacts([]*core.AcmeURL{ansible})
test.AssertError(t, err, "Unknown scehme")
test.AssertError(t, err, "Unknown scheme")
}
func TestValidateEmail(t *testing.T) {
_, err := validateEmail("an email`", &mocks.DNSResolver{})
test.AssertError(t, err, "Malformed")
_, err = validateEmail("a@not.a.domain", &mocks.DNSResolver{})
test.AssertError(t, err, "Cannot resolve")
t.Logf("No Resolve: %s", err)
_, err = validateEmail("a@example.com", &mocks.DNSResolver{})
test.AssertError(t, err, "No MX Record")
t.Logf("No MX: %s", err)
_, err = validateEmail("a@email.com", &mocks.DNSResolver{})
test.AssertNotError(t, err, "Valid")
testCases := []struct {
input string
expected string
}{
{"an email`", errUnparseableEmail.Error()},
{"a@always.invalid", "Server failure at resolver"},
{"a@always.timeout", "DNS query timed out"},
{"a@always.error", "DNS networking error"},
}
for _, tc := range testCases {
_, err := validateEmail(tc.input, &mocks.DNSResolver{})
if err.Error() != tc.expected {
t.Errorf("validateEmail(%q): got %#v, expected %#v",
tc.input, err, tc.expected)
}
}
if _, err := validateEmail("a@email.com", &mocks.DNSResolver{}); err != nil {
t.Errorf("Expected a@email.com to validate, but it failed: %s", err)
}
}
func TestNewRegistration(t *testing.T) {

View File

@ -11,7 +11,6 @@ import (
"crypto/tls"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io/ioutil"
"net"
@ -28,18 +27,14 @@ import (
"github.com/letsencrypt/boulder/Godeps/_workspace/src/github.com/miekg/dns"
"github.com/letsencrypt/boulder/core"
bdns "github.com/letsencrypt/boulder/dns"
blog "github.com/letsencrypt/boulder/log"
)
const maxCNAME = 16 // Prevents infinite loops. Same limit as BIND.
const maxRedirect = 10
var validationTimeout = time.Second * 5
// ErrTooManyCNAME is returned by CheckCAARecords if it has to follow too many
// consecutive CNAME lookups.
var ErrTooManyCNAME = errors.New("too many CNAME/DNAME lookups")
// ValidationAuthorityImpl represents a VA
type ValidationAuthorityImpl struct {
RA core.RegistrationAuthority
@ -124,24 +119,6 @@ func verifyValidationJWS(validation *jose.JsonWebSignature, accountKey *jose.Jso
return nil
}
// problemDetailsFromDNSError checks the error returned from Lookup...
// methods and tests if the error was an underlying net.OpError or an error
// caused by resolver returning SERVFAIL or other invalid Rcodes and returns
// the relevant core.ProblemDetails.
func problemDetailsFromDNSError(err error) *core.ProblemDetails {
problem := &core.ProblemDetails{Type: core.ConnectionProblem}
if netErr, ok := err.(*net.OpError); ok {
if netErr.Timeout() {
problem.Detail = "DNS query timed out"
} else if netErr.Temporary() {
problem.Detail = "Temporary network connectivity error"
}
} else {
problem.Detail = "Server failure at resolver"
}
return problem
}
// getAddr will query for all A records associated with hostname and return the
// prefered address, the first net.IP in the addrs slice, and all addresses resolved.
// This is the same choice made by the Go internal resolution library used by
@ -150,7 +127,7 @@ func problemDetailsFromDNSError(err error) *core.ProblemDetails {
func (va ValidationAuthorityImpl) getAddr(hostname string) (addr net.IP, addrs []net.IP, problem *core.ProblemDetails) {
addrs, rtt, err := va.DNSResolver.LookupHost(hostname)
if err != nil {
problem = problemDetailsFromDNSError(err)
problem = bdns.ProblemDetailsFromDNSError(err)
va.log.Debug(fmt.Sprintf("%s DNS failure: %s", hostname, err))
return
}
@ -634,7 +611,7 @@ func (va *ValidationAuthorityImpl) validateDNS01(identifier core.AcmeIdentifier,
if err != nil {
challenge.Status = core.StatusInvalid
challenge.Error = problemDetailsFromDNSError(err)
challenge.Error = bdns.ProblemDetailsFromDNSError(err)
va.log.Debug(fmt.Sprintf("%s [%s] DNS failure: %s", challenge.Type, identifier, err))
return challenge, challenge.Error
}
@ -654,6 +631,24 @@ func (va *ValidationAuthorityImpl) validateDNS01(identifier core.AcmeIdentifier,
return challenge, challenge.Error
}
func (va *ValidationAuthorityImpl) checkCAA(identifier core.AcmeIdentifier, regID int64) *core.ProblemDetails {
// Check CAA records for the requested identifier
present, valid, err := va.CheckCAARecords(identifier)
if err != nil {
va.log.Warning(fmt.Sprintf("Problem checking CAA: %s", err))
return bdns.ProblemDetailsFromDNSError(err)
}
// AUDIT[ Certificate Requests ] 11917fa4-10ef-4e0d-9105-bacbe7836a3c
va.log.Audit(fmt.Sprintf("Checked CAA records for %s, registration ID %d [Present: %t, Valid for issuance: %t]", identifier.Value, regID, present, valid))
if !valid {
return &core.ProblemDetails{
Type: core.ConnectionProblem,
Detail: "CAA check for identifier failed",
}
}
return nil
}
// Overall validation process
func (va *ValidationAuthorityImpl) validate(authz core.Authorization, challengeIndex int) {
@ -673,20 +668,7 @@ func (va *ValidationAuthorityImpl) validate(authz core.Authorization, challengeI
var err error
vStart := va.clk.Now()
switch authz.Challenges[challengeIndex].Type {
case core.ChallengeTypeSimpleHTTP:
// TODO(https://github.com/letsencrypt/boulder/issues/894): Delete this case
authz.Challenges[challengeIndex], err = va.validateSimpleHTTP(authz.Identifier, authz.Challenges[challengeIndex])
case core.ChallengeTypeDVSNI:
// TODO(https://github.com/letsencrypt/boulder/issues/894): Delete this case
authz.Challenges[challengeIndex], err = va.validateDvsni(authz.Identifier, authz.Challenges[challengeIndex])
case core.ChallengeTypeHTTP01:
authz.Challenges[challengeIndex], err = va.validateHTTP01(authz.Identifier, authz.Challenges[challengeIndex])
case core.ChallengeTypeTLSSNI01:
authz.Challenges[challengeIndex], err = va.validateTLSSNI01(authz.Identifier, authz.Challenges[challengeIndex])
case core.ChallengeTypeDNS01:
authz.Challenges[challengeIndex], err = va.validateDNS01(authz.Identifier, authz.Challenges[challengeIndex])
}
authz.Challenges[challengeIndex], err = va.validateChallengeAndCAA(authz.Identifier, authz.Challenges[challengeIndex], authz.RegistrationID)
va.stats.TimingDuration(fmt.Sprintf("VA.Validations.%s.%s", authz.Challenges[challengeIndex].Type, authz.Challenges[challengeIndex].Status), time.Since(vStart), 1.0)
if err != nil {
@ -709,6 +691,42 @@ func (va *ValidationAuthorityImpl) validate(authz core.Authorization, challengeI
va.RA.OnValidationUpdate(authz)
}
func (va *ValidationAuthorityImpl) validateChallengeAndCAA(identifier core.AcmeIdentifier, challenge core.Challenge, regID int64) (core.Challenge, error) {
result, err := va.validateChallenge(identifier, challenge)
if err != nil {
return result, err
}
// Checking CAA happens after challenge validation because DNS errors affect
// both, and giving a DNS error on validation makes more sense than a DNS
// error on CAA.
problemDetails := va.checkCAA(identifier, regID)
if problemDetails != nil {
challenge.Error = problemDetails
challenge.Status = core.StatusInvalid
return result, problemDetails
}
return result, nil
}
func (va *ValidationAuthorityImpl) validateChallenge(identifier core.AcmeIdentifier, challenge core.Challenge) (core.Challenge, error) {
switch challenge.Type {
case core.ChallengeTypeSimpleHTTP:
// TODO(https://github.com/letsencrypt/boulder/issues/894): Delete this case
return va.validateSimpleHTTP(identifier, challenge)
case core.ChallengeTypeDVSNI:
// TODO(https://github.com/letsencrypt/boulder/issues/894): Delete this case
return va.validateDvsni(identifier, challenge)
case core.ChallengeTypeHTTP01:
return va.validateHTTP01(identifier, challenge)
case core.ChallengeTypeTLSSNI01:
return va.validateTLSSNI01(identifier, challenge)
case core.ChallengeTypeDNS01:
return va.validateDNS01(identifier, challenge)
}
return core.Challenge{}, fmt.Errorf("invalid challenge type %s", challenge.Type)
}
// UpdateValidations runs the validate() method asynchronously using goroutines.
func (va *ValidationAuthorityImpl) UpdateValidations(authz core.Authorization, challengeIndex int) error {
go va.validate(authz, challengeIndex)

View File

@ -1061,6 +1061,21 @@ func TestUpdateValidations(t *testing.T) {
test.Assert(t, (took < (time.Second * 3)), "UpdateValidations blocked")
}
func TestCAATimeout(t *testing.T) {
stats, _ := statsd.NewNoopClient()
va := NewValidationAuthorityImpl(&PortConfig{}, nil, stats, clock.Default())
va.DNSResolver = &mocks.DNSResolver{}
va.IssuerDomain = "letsencrypt.org"
err := va.checkCAA(core.AcmeIdentifier{Type: core.IdentifierDNS, Value: "caa-timeout.com"}, 101)
if err.Type != core.ConnectionProblem {
t.Errorf("Expected timeout error type %s, got %s", core.ConnectionProblem, err.Type)
}
expected := "DNS query timed out"
if err.Detail != expected {
t.Errorf("checkCAA: got %s, expected %s", err.Detail, expected)
}
}
func TestCAAChecking(t *testing.T) {
type CAATest struct {
Domain string