1729 lines
61 KiB
Go
1729 lines
61 KiB
Go
package va
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/base64"
|
|
"errors"
|
|
"fmt"
|
|
mrand "math/rand/v2"
|
|
"net"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/netip"
|
|
"net/url"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
"unicode/utf8"
|
|
|
|
"github.com/miekg/dns"
|
|
|
|
"github.com/letsencrypt/boulder/bdns"
|
|
"github.com/letsencrypt/boulder/core"
|
|
berrors "github.com/letsencrypt/boulder/errors"
|
|
"github.com/letsencrypt/boulder/identifier"
|
|
"github.com/letsencrypt/boulder/must"
|
|
"github.com/letsencrypt/boulder/probs"
|
|
"github.com/letsencrypt/boulder/test"
|
|
|
|
"testing"
|
|
)
|
|
|
|
// TestDialerMismatchError tests that using a preresolvedDialer for one host for
|
|
// a dial to another host produces the expected dialerMismatchError.
|
|
func TestDialerMismatchError(t *testing.T) {
|
|
d := preresolvedDialer{
|
|
ip: netip.MustParseAddr("127.0.0.1"),
|
|
port: 1337,
|
|
hostname: "letsencrypt.org",
|
|
}
|
|
|
|
expectedErr := dialerMismatchError{
|
|
dialerHost: d.hostname,
|
|
dialerIP: d.ip.String(),
|
|
dialerPort: d.port,
|
|
host: "lettuceencrypt.org",
|
|
}
|
|
|
|
_, err := d.DialContext(
|
|
context.Background(),
|
|
"tincan-and-string",
|
|
"lettuceencrypt.org:80")
|
|
test.AssertEquals(t, err.Error(), expectedErr.Error())
|
|
}
|
|
|
|
// dnsMockReturnsUnroutable is a DNSClient mock that always returns an
|
|
// unroutable address for LookupHost. This is useful in testing connect
|
|
// timeouts.
|
|
type dnsMockReturnsUnroutable struct {
|
|
*bdns.MockClient
|
|
}
|
|
|
|
func (mock dnsMockReturnsUnroutable) LookupHost(_ context.Context, hostname string) ([]netip.Addr, bdns.ResolverAddrs, error) {
|
|
return []netip.Addr{netip.MustParseAddr("198.51.100.1")}, bdns.ResolverAddrs{"dnsMockReturnsUnroutable"}, nil
|
|
}
|
|
|
|
// TestDialerTimeout tests that the preresolvedDialer's DialContext
|
|
// will timeout after the expected singleDialTimeout. This ensures timeouts at
|
|
// the TCP level are handled correctly. It also ensures that we show the client
|
|
// the appropriate "Timeout during connect" error message, which helps clients
|
|
// distinguish between firewall problems and server problems.
|
|
func TestDialerTimeout(t *testing.T) {
|
|
va, _ := setup(nil, "", nil, nil)
|
|
// Timeouts below 50ms tend to be flaky.
|
|
va.singleDialTimeout = 50 * time.Millisecond
|
|
|
|
// The context timeout needs to be larger than the singleDialTimeout
|
|
ctxTimeout := 500 * time.Millisecond
|
|
ctx, cancel := context.WithTimeout(context.Background(), ctxTimeout)
|
|
defer cancel()
|
|
|
|
va.dnsClient = dnsMockReturnsUnroutable{&bdns.MockClient{}}
|
|
// NOTE(@jsha): The only method I've found so far to trigger a connect timeout
|
|
// is to connect to an unrouteable IP address. This usually generates
|
|
// a connection timeout, but will rarely return "Network unreachable" instead.
|
|
// If we get that, just retry until we get something other than "Network unreachable".
|
|
var err error
|
|
var took time.Duration
|
|
for range 20 {
|
|
started := time.Now()
|
|
_, _, err = va.processHTTPValidation(ctx, identifier.NewDNS("unroutable.invalid"), "/.well-known/acme-challenge/whatever")
|
|
took = time.Since(started)
|
|
if err != nil && strings.Contains(err.Error(), "network is unreachable") {
|
|
continue
|
|
} else {
|
|
break
|
|
}
|
|
}
|
|
if err == nil {
|
|
t.Fatalf("Connection should've timed out")
|
|
}
|
|
|
|
// Check that the HTTP connection doesn't return too fast, and times
|
|
// out after the expected time
|
|
if took < va.singleDialTimeout {
|
|
t.Fatalf("fetch returned before %s (took: %s) with %q", va.singleDialTimeout, took, err.Error())
|
|
}
|
|
if took > 2*va.singleDialTimeout {
|
|
t.Fatalf("fetch didn't timeout after %s (took: %s)", va.singleDialTimeout, took)
|
|
}
|
|
prob := detailedError(err)
|
|
test.AssertEquals(t, prob.Type, probs.ConnectionProblem)
|
|
test.AssertContains(t, prob.Detail, "Timeout during connect (likely firewall problem)")
|
|
}
|
|
|
|
func TestHTTPTransport(t *testing.T) {
|
|
dummyDialerFunc := func(_ context.Context, _, _ string) (net.Conn, error) {
|
|
return nil, nil
|
|
}
|
|
transport := httpTransport(dummyDialerFunc)
|
|
// The HTTP Transport should have a TLS config that skips verifying
|
|
// certificates.
|
|
test.AssertEquals(t, transport.TLSClientConfig.InsecureSkipVerify, true)
|
|
// Keep alives should be disabled
|
|
test.AssertEquals(t, transport.DisableKeepAlives, true)
|
|
test.AssertEquals(t, transport.MaxIdleConns, 1)
|
|
test.AssertEquals(t, transport.IdleConnTimeout.String(), "1s")
|
|
test.AssertEquals(t, transport.TLSHandshakeTimeout.String(), "10s")
|
|
}
|
|
|
|
func TestHTTPValidationTarget(t *testing.T) {
|
|
// NOTE(@cpu): See `bdns/mocks.go` and the mock `LookupHost` function for the
|
|
// hostnames used in this test.
|
|
testCases := []struct {
|
|
Name string
|
|
Ident identifier.ACMEIdentifier
|
|
ExpectedError error
|
|
ExpectedIPs []string
|
|
}{
|
|
{
|
|
Name: "No IPs for DNS identifier",
|
|
Ident: identifier.NewDNS("always.invalid"),
|
|
ExpectedError: berrors.DNSError("No valid IP addresses found for always.invalid"),
|
|
},
|
|
{
|
|
Name: "Only IPv4 addrs for DNS identifier",
|
|
Ident: identifier.NewDNS("some.example.com"),
|
|
ExpectedIPs: []string{"127.0.0.1"},
|
|
},
|
|
{
|
|
Name: "Only IPv6 addrs for DNS identifier",
|
|
Ident: identifier.NewDNS("ipv6.localhost"),
|
|
ExpectedIPs: []string{"::1"},
|
|
},
|
|
{
|
|
Name: "Both IPv6 and IPv4 addrs for DNS identifier",
|
|
Ident: identifier.NewDNS("ipv4.and.ipv6.localhost"),
|
|
// In this case we expect 1 IPv6 address first, and then 1 IPv4 address
|
|
ExpectedIPs: []string{"::1", "127.0.0.1"},
|
|
},
|
|
{
|
|
Name: "IPv4 IP address identifier",
|
|
Ident: identifier.NewIP(netip.MustParseAddr("127.0.0.1")),
|
|
ExpectedIPs: []string{"127.0.0.1"},
|
|
},
|
|
{
|
|
Name: "IPv6 IP address identifier",
|
|
Ident: identifier.NewIP(netip.MustParseAddr("::1")),
|
|
ExpectedIPs: []string{"::1"},
|
|
},
|
|
}
|
|
|
|
const (
|
|
examplePort = 1234
|
|
examplePath = "/.well-known/path/i/took"
|
|
exampleQuery = "my-path=was&my=own"
|
|
)
|
|
|
|
va, _ := setup(nil, "", nil, nil)
|
|
for _, tc := range testCases {
|
|
t.Run(tc.Name, func(t *testing.T) {
|
|
target, err := va.newHTTPValidationTarget(
|
|
context.Background(),
|
|
tc.Ident,
|
|
examplePort,
|
|
examplePath,
|
|
exampleQuery)
|
|
if err != nil && tc.ExpectedError == nil {
|
|
t.Fatalf("Unexpected error from NewHTTPValidationTarget: %v", err)
|
|
} else if err != nil && tc.ExpectedError != nil {
|
|
test.AssertMarshaledEquals(t, err, tc.ExpectedError)
|
|
} else if err == nil {
|
|
// The target should be populated.
|
|
test.AssertNotEquals(t, target.host, "")
|
|
test.AssertNotEquals(t, target.port, 0)
|
|
test.AssertNotEquals(t, target.path, "")
|
|
// Calling ip() on the target should give the expected IPs in the right
|
|
// order.
|
|
for i, expectedIP := range tc.ExpectedIPs {
|
|
gotIP := target.cur
|
|
if (gotIP == netip.Addr{}) {
|
|
t.Errorf("Expected IP %d to be %s got nil", i, expectedIP)
|
|
} else {
|
|
test.AssertEquals(t, gotIP.String(), expectedIP)
|
|
}
|
|
// Advance to the next IP
|
|
_ = target.nextIP()
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestExtractRequestTarget(t *testing.T) {
|
|
mustURL := func(rawURL string) *url.URL {
|
|
return must.Do(url.Parse(rawURL))
|
|
}
|
|
|
|
testCases := []struct {
|
|
Name string
|
|
Req *http.Request
|
|
ExpectedError error
|
|
ExpectedIdent identifier.ACMEIdentifier
|
|
ExpectedPort int
|
|
}{
|
|
{
|
|
Name: "nil input req",
|
|
ExpectedError: fmt.Errorf("redirect HTTP request was nil"),
|
|
},
|
|
{
|
|
Name: "invalid protocol scheme",
|
|
Req: &http.Request{
|
|
URL: mustURL("gopher://letsencrypt.org"),
|
|
},
|
|
ExpectedError: fmt.Errorf("Invalid protocol scheme in redirect target. " +
|
|
`Only "http" and "https" protocol schemes are supported, ` +
|
|
`not "gopher"`),
|
|
},
|
|
{
|
|
Name: "invalid explicit port",
|
|
Req: &http.Request{
|
|
URL: mustURL("https://weird.port.letsencrypt.org:9999"),
|
|
},
|
|
ExpectedError: fmt.Errorf("Invalid port in redirect target. Only ports 80 " +
|
|
"and 443 are supported, not 9999"),
|
|
},
|
|
{
|
|
Name: "invalid empty host",
|
|
Req: &http.Request{
|
|
URL: mustURL("https:///who/needs/a/hostname?not=me"),
|
|
},
|
|
ExpectedError: errors.New("Invalid empty host in redirect target"),
|
|
},
|
|
{
|
|
Name: "invalid .well-known hostname",
|
|
Req: &http.Request{
|
|
URL: mustURL("https://my.webserver.is.misconfigured.well-known/acme-challenge/xxx"),
|
|
},
|
|
ExpectedError: errors.New(`Invalid host in redirect target "my.webserver.is.misconfigured.well-known". Check webserver config for missing '/' in redirect target.`),
|
|
},
|
|
{
|
|
Name: "invalid non-iana hostname",
|
|
Req: &http.Request{
|
|
URL: mustURL("https://my.tld.is.cpu/pretty/cool/right?yeah=Ithoughtsotoo"),
|
|
},
|
|
ExpectedError: errors.New("Invalid host in redirect target, must end in IANA registered TLD"),
|
|
},
|
|
{
|
|
Name: "malformed wildcard-ish IPv4 address",
|
|
Req: &http.Request{
|
|
URL: mustURL("https://10.10.10.*"),
|
|
},
|
|
ExpectedError: errors.New("Invalid host in redirect target, must end in IANA registered TLD"),
|
|
},
|
|
{
|
|
Name: "malformed too-long IPv6 address",
|
|
Req: &http.Request{
|
|
URL: mustURL("https://[a:b:c:d:e:f:b:a:d]"),
|
|
},
|
|
ExpectedError: errors.New("Invalid host in redirect target, must end in IANA registered TLD"),
|
|
},
|
|
{
|
|
Name: "bare IPv4, implicit port",
|
|
Req: &http.Request{
|
|
URL: mustURL("http://127.0.0.1"),
|
|
},
|
|
ExpectedIdent: identifier.NewIP(netip.MustParseAddr("127.0.0.1")),
|
|
ExpectedPort: 80,
|
|
},
|
|
{
|
|
Name: "bare IPv4, explicit valid port",
|
|
Req: &http.Request{
|
|
URL: mustURL("http://127.0.0.1:80"),
|
|
},
|
|
ExpectedIdent: identifier.NewIP(netip.MustParseAddr("127.0.0.1")),
|
|
ExpectedPort: 80,
|
|
},
|
|
{
|
|
Name: "bare IPv4, explicit invalid port",
|
|
Req: &http.Request{
|
|
URL: mustURL("http://127.0.0.1:9999"),
|
|
},
|
|
ExpectedError: fmt.Errorf("Invalid port in redirect target. Only ports 80 " +
|
|
"and 443 are supported, not 9999"),
|
|
},
|
|
{
|
|
Name: "bare IPv4, HTTPS",
|
|
Req: &http.Request{
|
|
URL: mustURL("https://127.0.0.1"),
|
|
},
|
|
ExpectedIdent: identifier.NewIP(netip.MustParseAddr("127.0.0.1")),
|
|
ExpectedPort: 443,
|
|
},
|
|
{
|
|
Name: "bare IPv4, reserved IP address",
|
|
Req: &http.Request{
|
|
URL: mustURL("http://10.10.10.10"),
|
|
},
|
|
ExpectedError: fmt.Errorf("Invalid host in redirect target: " +
|
|
"IP address is in a reserved address block: RFC 1918: Private-Use"),
|
|
},
|
|
{
|
|
Name: "bare IPv6, implicit port",
|
|
Req: &http.Request{
|
|
URL: mustURL("http://[::1]"),
|
|
},
|
|
ExpectedIdent: identifier.NewIP(netip.MustParseAddr("::1")),
|
|
ExpectedPort: 80,
|
|
},
|
|
{
|
|
Name: "bare IPv6, explicit valid port",
|
|
Req: &http.Request{
|
|
URL: mustURL("http://[::1]:80"),
|
|
},
|
|
ExpectedIdent: identifier.NewIP(netip.MustParseAddr("::1")),
|
|
ExpectedPort: 80,
|
|
},
|
|
{
|
|
Name: "bare IPv6, explicit invalid port",
|
|
Req: &http.Request{
|
|
URL: mustURL("http://[::1]:9999"),
|
|
},
|
|
ExpectedError: fmt.Errorf("Invalid port in redirect target. Only ports 80 " +
|
|
"and 443 are supported, not 9999"),
|
|
},
|
|
{
|
|
Name: "bare IPv6, HTTPS",
|
|
Req: &http.Request{
|
|
URL: mustURL("https://[::1]"),
|
|
},
|
|
ExpectedIdent: identifier.NewIP(netip.MustParseAddr("::1")),
|
|
ExpectedPort: 443,
|
|
},
|
|
{
|
|
Name: "bare IPv6, reserved IP address",
|
|
Req: &http.Request{
|
|
URL: mustURL("http://[3fff:aaa:aaaa:aaaa:abad:0ff1:cec0:ffee]"),
|
|
},
|
|
ExpectedError: fmt.Errorf("Invalid host in redirect target: " +
|
|
"IP address is in a reserved address block: RFC 9637: Documentation"),
|
|
},
|
|
{
|
|
Name: "valid HTTP redirect, explicit port",
|
|
Req: &http.Request{
|
|
URL: mustURL("http://cpu.letsencrypt.org:80"),
|
|
},
|
|
ExpectedIdent: identifier.NewDNS("cpu.letsencrypt.org"),
|
|
ExpectedPort: 80,
|
|
},
|
|
{
|
|
Name: "valid HTTP redirect, implicit port",
|
|
Req: &http.Request{
|
|
URL: mustURL("http://cpu.letsencrypt.org"),
|
|
},
|
|
ExpectedIdent: identifier.NewDNS("cpu.letsencrypt.org"),
|
|
ExpectedPort: 80,
|
|
},
|
|
{
|
|
Name: "valid HTTPS redirect, explicit port",
|
|
Req: &http.Request{
|
|
URL: mustURL("https://cpu.letsencrypt.org:443/hello.world"),
|
|
},
|
|
ExpectedIdent: identifier.NewDNS("cpu.letsencrypt.org"),
|
|
ExpectedPort: 443,
|
|
},
|
|
{
|
|
Name: "valid HTTPS redirect, implicit port",
|
|
Req: &http.Request{
|
|
URL: mustURL("https://cpu.letsencrypt.org/hello.world"),
|
|
},
|
|
ExpectedIdent: identifier.NewDNS("cpu.letsencrypt.org"),
|
|
ExpectedPort: 443,
|
|
},
|
|
}
|
|
|
|
va, _ := setup(nil, "", nil, nil)
|
|
for _, tc := range testCases {
|
|
t.Run(tc.Name, func(t *testing.T) {
|
|
host, port, err := va.extractRequestTarget(tc.Req)
|
|
if err != nil && tc.ExpectedError == nil {
|
|
t.Errorf("Expected nil err got %v", err)
|
|
} else if err != nil && tc.ExpectedError != nil {
|
|
test.AssertEquals(t, err.Error(), tc.ExpectedError.Error())
|
|
} else if err == nil && tc.ExpectedError != nil {
|
|
t.Errorf("Expected err %v, got nil", tc.ExpectedError)
|
|
} else {
|
|
test.AssertEquals(t, host, tc.ExpectedIdent)
|
|
test.AssertEquals(t, port, tc.ExpectedPort)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestHTTPValidationDNSError attempts validation for a domain name that always
|
|
// generates a DNS error, and checks that a log line with the detailed error is
|
|
// generated.
|
|
func TestHTTPValidationDNSError(t *testing.T) {
|
|
va, mockLog := setup(nil, "", nil, nil)
|
|
|
|
_, _, prob := va.processHTTPValidation(ctx, identifier.NewDNS("always.error"), "/.well-known/acme-challenge/whatever")
|
|
test.AssertError(t, prob, "Expected validation fetch to fail")
|
|
matchingLines := mockLog.GetAllMatching(`read udp: some net error`)
|
|
if len(matchingLines) != 1 {
|
|
t.Errorf("Didn't see expected DNS error logged. Instead, got:\n%s",
|
|
strings.Join(mockLog.GetAllMatching(`.*`), "\n"))
|
|
}
|
|
}
|
|
|
|
// TestHTTPValidationDNSIdMismatchError tests that performing an HTTP-01
|
|
// challenge with a domain name that always returns a DNS ID mismatch error from
|
|
// the mock resolver results in valid query/response data being logged in
|
|
// a format we can decode successfully.
|
|
func TestHTTPValidationDNSIdMismatchError(t *testing.T) {
|
|
va, mockLog := setup(nil, "", nil, nil)
|
|
|
|
_, _, prob := va.processHTTPValidation(ctx, identifier.NewDNS("id.mismatch"), "/.well-known/acme-challenge/whatever")
|
|
test.AssertError(t, prob, "Expected validation fetch to fail")
|
|
matchingLines := mockLog.GetAllMatching(`logDNSError ID mismatch`)
|
|
if len(matchingLines) != 1 {
|
|
t.Errorf("Didn't see expected DNS error logged. Instead, got:\n%s",
|
|
strings.Join(mockLog.GetAllMatching(`.*`), "\n"))
|
|
}
|
|
expectedRegex := regexp.MustCompile(
|
|
`INFO: logDNSError ID mismatch ` +
|
|
`chosenServer=\[mock.server\] ` +
|
|
`hostname=\[id\.mismatch\] ` +
|
|
`respHostname=\[id\.mismatch\.\] ` +
|
|
`queryType=\[A\] ` +
|
|
`msg=\[([A-Za-z0-9+=/\=]+)\] ` +
|
|
`resp=\[([A-Za-z0-9+=/\=]+)\] ` +
|
|
`err\=\[dns: id mismatch\]`,
|
|
)
|
|
|
|
matches := expectedRegex.FindAllStringSubmatch(matchingLines[0], -1)
|
|
test.AssertEquals(t, len(matches), 1)
|
|
submatches := matches[0]
|
|
test.AssertEquals(t, len(submatches), 3)
|
|
|
|
msgBytes, err := base64.StdEncoding.DecodeString(submatches[1])
|
|
test.AssertNotError(t, err, "bad base64 encoded query msg")
|
|
msg := new(dns.Msg)
|
|
err = msg.Unpack(msgBytes)
|
|
test.AssertNotError(t, err, "bad packed query msg")
|
|
|
|
respBytes, err := base64.StdEncoding.DecodeString(submatches[2])
|
|
test.AssertNotError(t, err, "bad base64 encoded resp msg")
|
|
resp := new(dns.Msg)
|
|
err = resp.Unpack(respBytes)
|
|
test.AssertNotError(t, err, "bad packed response msg")
|
|
}
|
|
|
|
func TestSetupHTTPValidation(t *testing.T) {
|
|
va, _ := setup(nil, "", nil, nil)
|
|
|
|
mustTarget := func(t *testing.T, host string, port int, path string) *httpValidationTarget {
|
|
target, err := va.newHTTPValidationTarget(
|
|
context.Background(),
|
|
identifier.NewDNS(host),
|
|
port,
|
|
path,
|
|
"")
|
|
if err != nil {
|
|
t.Fatalf("Failed to construct httpValidationTarget for %q", host)
|
|
return nil
|
|
}
|
|
return target
|
|
}
|
|
|
|
httpInputURL := "http://ipv4.and.ipv6.localhost/yellow/brick/road"
|
|
httpsInputURL := "https://ipv4.and.ipv6.localhost/yellow/brick/road"
|
|
|
|
testCases := []struct {
|
|
Name string
|
|
InputURL string
|
|
InputTarget *httpValidationTarget
|
|
ExpectedRecord core.ValidationRecord
|
|
ExpectedDialer *preresolvedDialer
|
|
ExpectedError error
|
|
}{
|
|
{
|
|
Name: "nil target",
|
|
InputURL: httpInputURL,
|
|
ExpectedError: fmt.Errorf("httpValidationTarget can not be nil"),
|
|
},
|
|
{
|
|
Name: "empty input URL",
|
|
InputTarget: &httpValidationTarget{},
|
|
ExpectedError: fmt.Errorf("reqURL can not be nil"),
|
|
},
|
|
{
|
|
Name: "target with no IPs",
|
|
InputURL: httpInputURL,
|
|
InputTarget: &httpValidationTarget{
|
|
host: "ipv4.and.ipv6.localhost",
|
|
port: va.httpPort,
|
|
path: "idk",
|
|
},
|
|
ExpectedRecord: core.ValidationRecord{
|
|
URL: "http://ipv4.and.ipv6.localhost/yellow/brick/road",
|
|
Hostname: "ipv4.and.ipv6.localhost",
|
|
Port: strconv.Itoa(va.httpPort),
|
|
},
|
|
ExpectedError: fmt.Errorf(`host "ipv4.and.ipv6.localhost" has no IP addresses remaining to use`),
|
|
},
|
|
{
|
|
Name: "HTTP input req",
|
|
InputTarget: mustTarget(t, "ipv4.and.ipv6.localhost", va.httpPort, "/yellow/brick/road"),
|
|
InputURL: httpInputURL,
|
|
ExpectedRecord: core.ValidationRecord{
|
|
Hostname: "ipv4.and.ipv6.localhost",
|
|
Port: strconv.Itoa(va.httpPort),
|
|
URL: "http://ipv4.and.ipv6.localhost/yellow/brick/road",
|
|
AddressesResolved: []netip.Addr{netip.MustParseAddr("::1"), netip.MustParseAddr("127.0.0.1")},
|
|
AddressUsed: netip.MustParseAddr("::1"),
|
|
ResolverAddrs: []string{"MockClient"},
|
|
},
|
|
ExpectedDialer: &preresolvedDialer{
|
|
ip: netip.MustParseAddr("::1"),
|
|
port: va.httpPort,
|
|
timeout: va.singleDialTimeout,
|
|
},
|
|
},
|
|
{
|
|
Name: "HTTPS input req",
|
|
InputTarget: mustTarget(t, "ipv4.and.ipv6.localhost", va.httpsPort, "/yellow/brick/road"),
|
|
InputURL: httpsInputURL,
|
|
ExpectedRecord: core.ValidationRecord{
|
|
Hostname: "ipv4.and.ipv6.localhost",
|
|
Port: strconv.Itoa(va.httpsPort),
|
|
URL: "https://ipv4.and.ipv6.localhost/yellow/brick/road",
|
|
AddressesResolved: []netip.Addr{netip.MustParseAddr("::1"), netip.MustParseAddr("127.0.0.1")},
|
|
AddressUsed: netip.MustParseAddr("::1"),
|
|
ResolverAddrs: []string{"MockClient"},
|
|
},
|
|
ExpectedDialer: &preresolvedDialer{
|
|
ip: netip.MustParseAddr("::1"),
|
|
port: va.httpsPort,
|
|
timeout: va.singleDialTimeout,
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, tc := range testCases {
|
|
t.Run(tc.Name, func(t *testing.T) {
|
|
outDialer, outRecord, err := va.setupHTTPValidation(tc.InputURL, tc.InputTarget)
|
|
if err != nil && tc.ExpectedError == nil {
|
|
t.Errorf("Expected nil error, got %v", err)
|
|
} else if err == nil && tc.ExpectedError != nil {
|
|
t.Errorf("Expected %v error, got nil", tc.ExpectedError)
|
|
} else if err != nil && tc.ExpectedError != nil {
|
|
test.AssertEquals(t, err.Error(), tc.ExpectedError.Error())
|
|
}
|
|
if tc.ExpectedDialer == nil && outDialer != nil {
|
|
t.Errorf("Expected nil dialer, got %v", outDialer)
|
|
} else if tc.ExpectedDialer != nil {
|
|
test.AssertMarshaledEquals(t, outDialer, tc.ExpectedDialer)
|
|
}
|
|
// In all cases we expect there to have been a validation record
|
|
test.AssertMarshaledEquals(t, outRecord, tc.ExpectedRecord)
|
|
})
|
|
}
|
|
}
|
|
|
|
// A more concise version of httpSrv() that supports http.go tests
|
|
func httpTestSrv(t *testing.T, ipv6 bool) *httptest.Server {
|
|
t.Helper()
|
|
mux := http.NewServeMux()
|
|
server := httptest.NewUnstartedServer(mux)
|
|
|
|
if ipv6 {
|
|
l, err := net.Listen("tcp", "[::1]:0")
|
|
if err != nil {
|
|
panic(fmt.Sprintf("httptest: failed to listen on a port: %v", err))
|
|
}
|
|
server.Listener = l
|
|
}
|
|
|
|
server.Start()
|
|
httpPort := getPort(server)
|
|
|
|
// A path that always returns an OK response
|
|
mux.HandleFunc("/ok", func(resp http.ResponseWriter, req *http.Request) {
|
|
resp.WriteHeader(http.StatusOK)
|
|
fmt.Fprint(resp, "ok")
|
|
})
|
|
|
|
// A path that always times out by sleeping longer than the validation context
|
|
// allows
|
|
mux.HandleFunc("/timeout", func(resp http.ResponseWriter, req *http.Request) {
|
|
time.Sleep(time.Second)
|
|
resp.WriteHeader(http.StatusOK)
|
|
fmt.Fprint(resp, "sorry, I'm a slow server")
|
|
})
|
|
|
|
// A path that always redirects to itself, creating a loop that will terminate
|
|
// when detected.
|
|
mux.HandleFunc("/loop", func(resp http.ResponseWriter, req *http.Request) {
|
|
http.Redirect(
|
|
resp,
|
|
req,
|
|
fmt.Sprintf("http://example.com:%d/loop", httpPort),
|
|
http.StatusMovedPermanently)
|
|
})
|
|
|
|
// A path that sequentially redirects, creating an incrementing redirect
|
|
// that will terminate when the redirect limit is reached and ensures each
|
|
// URL is different than the last.
|
|
for i := range maxRedirect + 2 {
|
|
mux.HandleFunc(fmt.Sprintf("/max-redirect/%d", i),
|
|
func(resp http.ResponseWriter, req *http.Request) {
|
|
http.Redirect(
|
|
resp,
|
|
req,
|
|
fmt.Sprintf("http://example.com:%d/max-redirect/%d", httpPort, i+1),
|
|
http.StatusMovedPermanently,
|
|
)
|
|
})
|
|
}
|
|
|
|
// A path that always redirects to a URL with a non-HTTP/HTTPs protocol scheme
|
|
mux.HandleFunc("/redir-bad-proto", func(resp http.ResponseWriter, req *http.Request) {
|
|
http.Redirect(
|
|
resp,
|
|
req,
|
|
"gopher://example.com",
|
|
http.StatusMovedPermanently,
|
|
)
|
|
})
|
|
|
|
// A path that always redirects to a URL with a port other than the configured
|
|
// HTTP/HTTPS port
|
|
mux.HandleFunc("/redir-bad-port", func(resp http.ResponseWriter, req *http.Request) {
|
|
http.Redirect(
|
|
resp,
|
|
req,
|
|
"https://example.com:1987",
|
|
http.StatusMovedPermanently,
|
|
)
|
|
})
|
|
|
|
// A path that always redirects to a URL with a bare IP address
|
|
mux.HandleFunc("/redir-bare-ipv4", func(resp http.ResponseWriter, req *http.Request) {
|
|
http.Redirect(
|
|
resp,
|
|
req,
|
|
"http://127.0.0.1/ok",
|
|
http.StatusMovedPermanently,
|
|
)
|
|
})
|
|
|
|
mux.HandleFunc("/redir-bare-ipv6", func(resp http.ResponseWriter, req *http.Request) {
|
|
http.Redirect(
|
|
resp,
|
|
req,
|
|
"http://[::1]/ok",
|
|
http.StatusMovedPermanently,
|
|
)
|
|
})
|
|
|
|
mux.HandleFunc("/bad-status-code", func(resp http.ResponseWriter, req *http.Request) {
|
|
resp.WriteHeader(http.StatusGone)
|
|
fmt.Fprint(resp, "sorry, I'm gone")
|
|
})
|
|
|
|
// A path that always responds with a 303 redirect
|
|
mux.HandleFunc("/303-see-other", func(resp http.ResponseWriter, req *http.Request) {
|
|
http.Redirect(
|
|
resp,
|
|
req,
|
|
"http://example.org/303-see-other",
|
|
http.StatusSeeOther,
|
|
)
|
|
})
|
|
|
|
tooLargeBuf := bytes.NewBuffer([]byte{})
|
|
for range maxResponseSize + 10 {
|
|
tooLargeBuf.WriteByte(byte(97))
|
|
}
|
|
mux.HandleFunc("/resp-too-big", func(resp http.ResponseWriter, req *http.Request) {
|
|
resp.WriteHeader(http.StatusOK)
|
|
fmt.Fprint(resp, tooLargeBuf)
|
|
})
|
|
|
|
// Create a buffer that starts with invalid UTF8 and is bigger than
|
|
// maxResponseSize
|
|
tooLargeInvalidUTF8 := bytes.NewBuffer([]byte{})
|
|
tooLargeInvalidUTF8.WriteString("f\xffoo")
|
|
tooLargeInvalidUTF8.Write(tooLargeBuf.Bytes())
|
|
// invalid-utf8-body Responds with body that is larger than
|
|
// maxResponseSize and starts with an invalid UTF8 string. This is to
|
|
// test the codepath where invalid UTF8 is converted to valid UTF8
|
|
// that can be passed as an error message via grpc.
|
|
mux.HandleFunc("/invalid-utf8-body", func(resp http.ResponseWriter, req *http.Request) {
|
|
resp.WriteHeader(http.StatusOK)
|
|
fmt.Fprint(resp, tooLargeInvalidUTF8)
|
|
})
|
|
|
|
mux.HandleFunc("/redir-path-too-long", func(resp http.ResponseWriter, req *http.Request) {
|
|
http.Redirect(
|
|
resp,
|
|
req,
|
|
"https://example.com/this-is-too-long-01234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789",
|
|
http.StatusMovedPermanently)
|
|
})
|
|
|
|
// A path that redirects to an uppercase public suffix (#4215)
|
|
mux.HandleFunc("/redir-uppercase-publicsuffix", func(resp http.ResponseWriter, req *http.Request) {
|
|
http.Redirect(
|
|
resp,
|
|
req,
|
|
"http://example.COM/ok",
|
|
http.StatusMovedPermanently)
|
|
})
|
|
|
|
// A path that returns a body containing printf formatting verbs
|
|
mux.HandleFunc("/printf-verbs", func(resp http.ResponseWriter, req *http.Request) {
|
|
resp.WriteHeader(http.StatusOK)
|
|
fmt.Fprint(resp, "%"+"2F.well-known%"+"2F"+tooLargeBuf.String())
|
|
})
|
|
|
|
return server
|
|
}
|
|
|
|
type testNetErr struct{}
|
|
|
|
func (e *testNetErr) Error() string {
|
|
return "testNetErr"
|
|
}
|
|
|
|
func (e *testNetErr) Temporary() bool {
|
|
return false
|
|
}
|
|
|
|
func (e *testNetErr) Timeout() bool {
|
|
return false
|
|
}
|
|
|
|
func TestFallbackErr(t *testing.T) {
|
|
untypedErr := errors.New("the least interesting kind of error")
|
|
berr := berrors.InternalServerError("code violet: class neptune")
|
|
netOpErr := &net.OpError{
|
|
Op: "siphon",
|
|
Err: fmt.Errorf("port was clogged. please empty packets"),
|
|
}
|
|
netDialOpErr := &net.OpError{
|
|
Op: "dial",
|
|
Err: fmt.Errorf("your call is important to us - please stay on the line"),
|
|
}
|
|
netErr := &testNetErr{}
|
|
|
|
testCases := []struct {
|
|
Name string
|
|
Err error
|
|
ExpectFallback bool
|
|
}{
|
|
{
|
|
Name: "Nil error",
|
|
Err: nil,
|
|
},
|
|
{
|
|
Name: "Standard untyped error",
|
|
Err: untypedErr,
|
|
},
|
|
{
|
|
Name: "A Boulder error instance",
|
|
Err: berr,
|
|
},
|
|
{
|
|
Name: "A non-dial net.OpError instance",
|
|
Err: netOpErr,
|
|
},
|
|
{
|
|
Name: "A dial net.OpError instance",
|
|
Err: netDialOpErr,
|
|
ExpectFallback: true,
|
|
},
|
|
{
|
|
Name: "A generic net.Error instance",
|
|
Err: netErr,
|
|
},
|
|
{
|
|
Name: "A URL error wrapping a standard error",
|
|
Err: &url.Error{
|
|
Op: "ivy",
|
|
URL: "https://en.wikipedia.org/wiki/Operation_Ivy_(band)",
|
|
Err: errors.New("take warning"),
|
|
},
|
|
},
|
|
{
|
|
Name: "A URL error wrapping a nil error",
|
|
Err: &url.Error{
|
|
Err: nil,
|
|
},
|
|
},
|
|
{
|
|
Name: "A URL error wrapping a Boulder error instance",
|
|
Err: &url.Error{
|
|
Err: berr,
|
|
},
|
|
},
|
|
{
|
|
Name: "A URL error wrapping a non-dial net OpError",
|
|
Err: &url.Error{
|
|
Err: netOpErr,
|
|
},
|
|
},
|
|
{
|
|
Name: "A URL error wrapping a dial net.OpError",
|
|
Err: &url.Error{
|
|
Err: netDialOpErr,
|
|
},
|
|
ExpectFallback: true,
|
|
},
|
|
{
|
|
Name: "A URL error wrapping a generic net Error",
|
|
Err: &url.Error{
|
|
Err: netErr,
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, tc := range testCases {
|
|
t.Run(tc.Name, func(t *testing.T) {
|
|
if isFallback := fallbackErr(tc.Err); isFallback != tc.ExpectFallback {
|
|
t.Errorf(
|
|
"Expected fallbackErr for %t to be %v was %v\n",
|
|
tc.Err, tc.ExpectFallback, isFallback)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestFetchHTTP(t *testing.T) {
|
|
// Create test servers
|
|
testSrvIPv4 := httpTestSrv(t, false)
|
|
defer testSrvIPv4.Close()
|
|
testSrvIPv6 := httpTestSrv(t, true)
|
|
defer testSrvIPv6.Close()
|
|
|
|
// Setup VAs. By providing the testSrv to setup the VA will use the testSrv's
|
|
// randomly assigned port as its HTTP port.
|
|
vaIPv4, _ := setup(testSrvIPv4, "", nil, nil)
|
|
vaIPv6, _ := setup(testSrvIPv6, "", nil, nil)
|
|
|
|
// We need to know the randomly assigned HTTP port for testcases as well
|
|
httpPortIPv4 := getPort(testSrvIPv4)
|
|
httpPortIPv6 := getPort(testSrvIPv6)
|
|
|
|
// For the looped test case we expect one validation record per redirect
|
|
// until boulder detects that a url has been used twice indicating a
|
|
// redirect loop. Because it is hitting the /loop endpoint it will encounter
|
|
// this scenario after the base url and fail on the second time hitting the
|
|
// redirect with a port definition. On i=0 it will encounter the first
|
|
// redirect to the url with a port definition and on i=1 it will encounter
|
|
// the second redirect to the url with the port and get an expected error.
|
|
expectedLoopRecords := []core.ValidationRecord{}
|
|
for i := range 2 {
|
|
// The first request will not have a port # in the URL.
|
|
url := "http://example.com/loop"
|
|
if i != 0 {
|
|
url = fmt.Sprintf("http://example.com:%d/loop", httpPortIPv4)
|
|
}
|
|
expectedLoopRecords = append(expectedLoopRecords,
|
|
core.ValidationRecord{
|
|
Hostname: "example.com",
|
|
Port: strconv.Itoa(httpPortIPv4),
|
|
URL: url,
|
|
AddressesResolved: []netip.Addr{netip.MustParseAddr("127.0.0.1")},
|
|
AddressUsed: netip.MustParseAddr("127.0.0.1"),
|
|
ResolverAddrs: []string{"MockClient"},
|
|
})
|
|
}
|
|
|
|
// For the too many redirect test case we expect one validation record per
|
|
// redirect up to maxRedirect (inclusive). There is also +1 record for the
|
|
// base lookup, giving a termination criteria of > maxRedirect+1
|
|
expectedTooManyRedirRecords := []core.ValidationRecord{}
|
|
for i := range maxRedirect + 2 {
|
|
// The first request will not have a port # in the URL.
|
|
url := "http://example.com/max-redirect/0"
|
|
if i != 0 {
|
|
url = fmt.Sprintf("http://example.com:%d/max-redirect/%d", httpPortIPv4, i)
|
|
}
|
|
expectedTooManyRedirRecords = append(expectedTooManyRedirRecords,
|
|
core.ValidationRecord{
|
|
Hostname: "example.com",
|
|
Port: strconv.Itoa(httpPortIPv4),
|
|
URL: url,
|
|
AddressesResolved: []netip.Addr{netip.MustParseAddr("127.0.0.1")},
|
|
AddressUsed: netip.MustParseAddr("127.0.0.1"),
|
|
ResolverAddrs: []string{"MockClient"},
|
|
})
|
|
}
|
|
|
|
expectedTruncatedResp := bytes.NewBuffer([]byte{})
|
|
for range maxResponseSize {
|
|
expectedTruncatedResp.WriteByte(byte(97))
|
|
}
|
|
|
|
testCases := []struct {
|
|
Name string
|
|
IPv6 bool
|
|
Ident identifier.ACMEIdentifier
|
|
Path string
|
|
ExpectedBody string
|
|
ExpectedRecords []core.ValidationRecord
|
|
ExpectedProblem *probs.ProblemDetails
|
|
}{
|
|
{
|
|
Name: "No IPs for host",
|
|
Ident: identifier.NewDNS("always.invalid"),
|
|
Path: "/.well-known/whatever",
|
|
ExpectedProblem: probs.DNS(
|
|
"No valid IP addresses found for always.invalid"),
|
|
// There are no validation records in this case because the base record
|
|
// is only constructed once a URL is made.
|
|
ExpectedRecords: nil,
|
|
},
|
|
{
|
|
Name: "Timeout for host with standard ACME allowed port",
|
|
Ident: identifier.NewDNS("example.com"),
|
|
Path: "/timeout",
|
|
ExpectedProblem: probs.Connection(
|
|
"127.0.0.1: Fetching http://example.com/timeout: " +
|
|
"Timeout after connect (your server may be slow or overloaded)"),
|
|
ExpectedRecords: []core.ValidationRecord{
|
|
{
|
|
Hostname: "example.com",
|
|
Port: strconv.Itoa(httpPortIPv4),
|
|
URL: "http://example.com/timeout",
|
|
AddressesResolved: []netip.Addr{netip.MustParseAddr("127.0.0.1")},
|
|
AddressUsed: netip.MustParseAddr("127.0.0.1"),
|
|
ResolverAddrs: []string{"MockClient"},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
Name: "Redirect loop",
|
|
Ident: identifier.NewDNS("example.com"),
|
|
Path: "/loop",
|
|
ExpectedProblem: probs.Connection(fmt.Sprintf(
|
|
"127.0.0.1: Fetching http://example.com:%d/loop: Redirect loop detected", httpPortIPv4)),
|
|
ExpectedRecords: expectedLoopRecords,
|
|
},
|
|
{
|
|
Name: "Too many redirects",
|
|
Ident: identifier.NewDNS("example.com"),
|
|
Path: "/max-redirect/0",
|
|
ExpectedProblem: probs.Connection(fmt.Sprintf(
|
|
"127.0.0.1: Fetching http://example.com:%d/max-redirect/12: Too many redirects", httpPortIPv4)),
|
|
ExpectedRecords: expectedTooManyRedirRecords,
|
|
},
|
|
{
|
|
Name: "Redirect to bad protocol",
|
|
Ident: identifier.NewDNS("example.com"),
|
|
Path: "/redir-bad-proto",
|
|
ExpectedProblem: probs.Connection(
|
|
"127.0.0.1: Fetching gopher://example.com: Invalid protocol scheme in " +
|
|
`redirect target. Only "http" and "https" protocol schemes ` +
|
|
`are supported, not "gopher"`),
|
|
ExpectedRecords: []core.ValidationRecord{
|
|
{
|
|
Hostname: "example.com",
|
|
Port: strconv.Itoa(httpPortIPv4),
|
|
URL: "http://example.com/redir-bad-proto",
|
|
AddressesResolved: []netip.Addr{netip.MustParseAddr("127.0.0.1")},
|
|
AddressUsed: netip.MustParseAddr("127.0.0.1"),
|
|
ResolverAddrs: []string{"MockClient"},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
Name: "Redirect to bad port",
|
|
Ident: identifier.NewDNS("example.com"),
|
|
Path: "/redir-bad-port",
|
|
ExpectedProblem: probs.Connection(fmt.Sprintf(
|
|
"127.0.0.1: Fetching https://example.com:1987: Invalid port in redirect target. "+
|
|
"Only ports %d and 443 are supported, not 1987", httpPortIPv4)),
|
|
ExpectedRecords: []core.ValidationRecord{
|
|
{
|
|
Hostname: "example.com",
|
|
Port: strconv.Itoa(httpPortIPv4),
|
|
URL: "http://example.com/redir-bad-port",
|
|
AddressesResolved: []netip.Addr{netip.MustParseAddr("127.0.0.1")},
|
|
AddressUsed: netip.MustParseAddr("127.0.0.1"),
|
|
ResolverAddrs: []string{"MockClient"},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
Name: "Redirect to bare IPv4 address",
|
|
Ident: identifier.NewDNS("example.com"),
|
|
Path: "/redir-bare-ipv4",
|
|
ExpectedBody: "ok",
|
|
ExpectedRecords: []core.ValidationRecord{
|
|
{
|
|
Hostname: "example.com",
|
|
Port: strconv.Itoa(httpPortIPv4),
|
|
URL: "http://example.com/redir-bare-ipv4",
|
|
AddressesResolved: []netip.Addr{netip.MustParseAddr("127.0.0.1")},
|
|
AddressUsed: netip.MustParseAddr("127.0.0.1"),
|
|
ResolverAddrs: []string{"MockClient"},
|
|
},
|
|
{
|
|
Hostname: "127.0.0.1",
|
|
Port: strconv.Itoa(httpPortIPv4),
|
|
URL: "http://127.0.0.1/ok",
|
|
AddressesResolved: []netip.Addr{netip.MustParseAddr("127.0.0.1")},
|
|
AddressUsed: netip.MustParseAddr("127.0.0.1"),
|
|
},
|
|
},
|
|
}, {
|
|
Name: "Redirect to bare IPv6 address",
|
|
IPv6: true,
|
|
Ident: identifier.NewDNS("ipv6.localhost"),
|
|
Path: "/redir-bare-ipv6",
|
|
ExpectedBody: "ok",
|
|
ExpectedRecords: []core.ValidationRecord{
|
|
{
|
|
Hostname: "ipv6.localhost",
|
|
Port: strconv.Itoa(httpPortIPv6),
|
|
URL: "http://ipv6.localhost/redir-bare-ipv6",
|
|
AddressesResolved: []netip.Addr{netip.MustParseAddr("::1")},
|
|
AddressUsed: netip.MustParseAddr("::1"),
|
|
ResolverAddrs: []string{"MockClient"},
|
|
},
|
|
{
|
|
Hostname: "::1",
|
|
Port: strconv.Itoa(httpPortIPv6),
|
|
URL: "http://[::1]/ok",
|
|
AddressesResolved: []netip.Addr{netip.MustParseAddr("::1")},
|
|
AddressUsed: netip.MustParseAddr("::1"),
|
|
},
|
|
},
|
|
},
|
|
{
|
|
Name: "Redirect to long path",
|
|
Ident: identifier.NewDNS("example.com"),
|
|
Path: "/redir-path-too-long",
|
|
ExpectedProblem: probs.Connection(
|
|
"127.0.0.1: Fetching https://example.com/this-is-too-long-01234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789: Redirect target too long"),
|
|
ExpectedRecords: []core.ValidationRecord{
|
|
{
|
|
Hostname: "example.com",
|
|
Port: strconv.Itoa(httpPortIPv4),
|
|
URL: "http://example.com/redir-path-too-long",
|
|
AddressesResolved: []netip.Addr{netip.MustParseAddr("127.0.0.1")},
|
|
AddressUsed: netip.MustParseAddr("127.0.0.1"),
|
|
ResolverAddrs: []string{"MockClient"},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
Name: "Wrong HTTP status code",
|
|
Ident: identifier.NewDNS("example.com"),
|
|
Path: "/bad-status-code",
|
|
ExpectedProblem: probs.Unauthorized(
|
|
"127.0.0.1: Invalid response from http://example.com/bad-status-code: 410"),
|
|
ExpectedRecords: []core.ValidationRecord{
|
|
{
|
|
Hostname: "example.com",
|
|
Port: strconv.Itoa(httpPortIPv4),
|
|
URL: "http://example.com/bad-status-code",
|
|
AddressesResolved: []netip.Addr{netip.MustParseAddr("127.0.0.1")},
|
|
AddressUsed: netip.MustParseAddr("127.0.0.1"),
|
|
ResolverAddrs: []string{"MockClient"},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
Name: "HTTP status code 303 redirect",
|
|
Ident: identifier.NewDNS("example.com"),
|
|
Path: "/303-see-other",
|
|
ExpectedProblem: probs.Connection(
|
|
"127.0.0.1: Fetching http://example.org/303-see-other: received disallowed redirect status code"),
|
|
ExpectedRecords: []core.ValidationRecord{
|
|
{
|
|
Hostname: "example.com",
|
|
Port: strconv.Itoa(httpPortIPv4),
|
|
URL: "http://example.com/303-see-other",
|
|
AddressesResolved: []netip.Addr{netip.MustParseAddr("127.0.0.1")},
|
|
AddressUsed: netip.MustParseAddr("127.0.0.1"),
|
|
ResolverAddrs: []string{"MockClient"},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
Name: "Response too large",
|
|
Ident: identifier.NewDNS("example.com"),
|
|
Path: "/resp-too-big",
|
|
ExpectedProblem: probs.Unauthorized(fmt.Sprintf(
|
|
"127.0.0.1: Invalid response from http://example.com/resp-too-big: %q", expectedTruncatedResp.String(),
|
|
)),
|
|
ExpectedRecords: []core.ValidationRecord{
|
|
{
|
|
Hostname: "example.com",
|
|
Port: strconv.Itoa(httpPortIPv4),
|
|
URL: "http://example.com/resp-too-big",
|
|
AddressesResolved: []netip.Addr{netip.MustParseAddr("127.0.0.1")},
|
|
AddressUsed: netip.MustParseAddr("127.0.0.1"),
|
|
ResolverAddrs: []string{"MockClient"},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
Name: "Broken IPv6 only",
|
|
Ident: identifier.NewDNS("ipv6.localhost"),
|
|
Path: "/ok",
|
|
ExpectedProblem: probs.Connection(
|
|
"::1: Fetching http://ipv6.localhost/ok: Connection refused"),
|
|
ExpectedRecords: []core.ValidationRecord{
|
|
{
|
|
Hostname: "ipv6.localhost",
|
|
Port: strconv.Itoa(httpPortIPv4),
|
|
URL: "http://ipv6.localhost/ok",
|
|
AddressesResolved: []netip.Addr{netip.MustParseAddr("::1")},
|
|
AddressUsed: netip.MustParseAddr("::1"),
|
|
ResolverAddrs: []string{"MockClient"},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
Name: "Dual homed w/ broken IPv6, working IPv4",
|
|
Ident: identifier.NewDNS("ipv4.and.ipv6.localhost"),
|
|
Path: "/ok",
|
|
ExpectedBody: "ok",
|
|
ExpectedRecords: []core.ValidationRecord{
|
|
{
|
|
Hostname: "ipv4.and.ipv6.localhost",
|
|
Port: strconv.Itoa(httpPortIPv4),
|
|
URL: "http://ipv4.and.ipv6.localhost/ok",
|
|
AddressesResolved: []netip.Addr{netip.MustParseAddr("::1"), netip.MustParseAddr("127.0.0.1")},
|
|
// The first validation record should have used the IPv6 addr
|
|
AddressUsed: netip.MustParseAddr("::1"),
|
|
ResolverAddrs: []string{"MockClient"},
|
|
},
|
|
{
|
|
Hostname: "ipv4.and.ipv6.localhost",
|
|
Port: strconv.Itoa(httpPortIPv4),
|
|
URL: "http://ipv4.and.ipv6.localhost/ok",
|
|
AddressesResolved: []netip.Addr{netip.MustParseAddr("::1"), netip.MustParseAddr("127.0.0.1")},
|
|
// The second validation record should have used the IPv4 addr as a fallback
|
|
AddressUsed: netip.MustParseAddr("127.0.0.1"),
|
|
ResolverAddrs: []string{"MockClient"},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
Name: "Working IPv4 only",
|
|
Ident: identifier.NewDNS("example.com"),
|
|
Path: "/ok",
|
|
ExpectedBody: "ok",
|
|
ExpectedRecords: []core.ValidationRecord{
|
|
{
|
|
Hostname: "example.com",
|
|
Port: strconv.Itoa(httpPortIPv4),
|
|
URL: "http://example.com/ok",
|
|
AddressesResolved: []netip.Addr{netip.MustParseAddr("127.0.0.1")},
|
|
AddressUsed: netip.MustParseAddr("127.0.0.1"),
|
|
ResolverAddrs: []string{"MockClient"},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
Name: "Redirect to uppercase Public Suffix",
|
|
Ident: identifier.NewDNS("example.com"),
|
|
Path: "/redir-uppercase-publicsuffix",
|
|
ExpectedBody: "ok",
|
|
ExpectedRecords: []core.ValidationRecord{
|
|
{
|
|
Hostname: "example.com",
|
|
Port: strconv.Itoa(httpPortIPv4),
|
|
URL: "http://example.com/redir-uppercase-publicsuffix",
|
|
AddressesResolved: []netip.Addr{netip.MustParseAddr("127.0.0.1")},
|
|
AddressUsed: netip.MustParseAddr("127.0.0.1"),
|
|
ResolverAddrs: []string{"MockClient"},
|
|
},
|
|
{
|
|
Hostname: "example.com",
|
|
Port: strconv.Itoa(httpPortIPv4),
|
|
URL: "http://example.com/ok",
|
|
AddressesResolved: []netip.Addr{netip.MustParseAddr("127.0.0.1")},
|
|
AddressUsed: netip.MustParseAddr("127.0.0.1"),
|
|
ResolverAddrs: []string{"MockClient"},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
Name: "Reflected response body containing printf verbs",
|
|
Ident: identifier.NewDNS("example.com"),
|
|
Path: "/printf-verbs",
|
|
ExpectedProblem: &probs.ProblemDetails{
|
|
Type: probs.UnauthorizedProblem,
|
|
Detail: fmt.Sprintf("127.0.0.1: Invalid response from http://example.com/printf-verbs: %q",
|
|
("%2F.well-known%2F" + expectedTruncatedResp.String())[:maxResponseSize]),
|
|
HTTPStatus: http.StatusForbidden,
|
|
},
|
|
ExpectedRecords: []core.ValidationRecord{
|
|
{
|
|
Hostname: "example.com",
|
|
Port: strconv.Itoa(httpPortIPv4),
|
|
URL: "http://example.com/printf-verbs",
|
|
AddressesResolved: []netip.Addr{netip.MustParseAddr("127.0.0.1")},
|
|
AddressUsed: netip.MustParseAddr("127.0.0.1"),
|
|
ResolverAddrs: []string{"MockClient"},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, tc := range testCases {
|
|
t.Run(tc.Name, func(t *testing.T) {
|
|
ctx, cancel := context.WithTimeout(context.Background(), time.Millisecond*500)
|
|
defer cancel()
|
|
var body []byte
|
|
var records []core.ValidationRecord
|
|
var err error
|
|
if tc.IPv6 {
|
|
body, records, err = vaIPv6.processHTTPValidation(ctx, tc.Ident, tc.Path)
|
|
} else {
|
|
body, records, err = vaIPv4.processHTTPValidation(ctx, tc.Ident, tc.Path)
|
|
}
|
|
if tc.ExpectedProblem == nil {
|
|
test.AssertNotError(t, err, "expected nil prob")
|
|
} else {
|
|
test.AssertError(t, err, "expected non-nil prob")
|
|
prob := detailedError(err)
|
|
test.AssertMarshaledEquals(t, prob, tc.ExpectedProblem)
|
|
}
|
|
if tc.ExpectedBody != "" {
|
|
test.AssertEquals(t, string(body), tc.ExpectedBody)
|
|
}
|
|
// in all cases we expect validation records to be present and matching expected
|
|
test.AssertMarshaledEquals(t, records, tc.ExpectedRecords)
|
|
})
|
|
}
|
|
}
|
|
|
|
// All paths that get assigned to tokens MUST be valid tokens
|
|
const pathWrongToken = "i6lNAC4lOOLYCl-A08VJt9z_tKYvVk63Dumo8icsBjQ"
|
|
const path404 = "404"
|
|
const path500 = "500"
|
|
const pathFound = "GBq8SwWq3JsbREFdCamk5IX3KLsxW5ULeGs98Ajl_UM"
|
|
const pathMoved = "5J4FIMrWNfmvHZo-QpKZngmuhqZGwRm21-oEgUDstJM"
|
|
const pathRedirectInvalidPort = "port-redirect"
|
|
const pathWait = "wait"
|
|
const pathWaitLong = "wait-long"
|
|
const pathReLookup = "7e-P57coLM7D3woNTp_xbJrtlkDYy6PWf3mSSbLwCr4"
|
|
const pathReLookupInvalid = "re-lookup-invalid"
|
|
const pathRedirectToFailingURL = "re-to-failing-url"
|
|
const pathLooper = "looper"
|
|
const pathValid = "valid"
|
|
const rejectUserAgent = "rejectMe"
|
|
|
|
func httpSrv(t *testing.T, token string, ipv6 bool) *httptest.Server {
|
|
m := http.NewServeMux()
|
|
|
|
server := httptest.NewUnstartedServer(m)
|
|
|
|
defaultToken := token
|
|
currentToken := defaultToken
|
|
|
|
m.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
|
if strings.HasSuffix(r.URL.Path, path404) {
|
|
t.Logf("HTTPSRV: Got a 404 req\n")
|
|
http.NotFound(w, r)
|
|
} else if strings.HasSuffix(r.URL.Path, path500) {
|
|
t.Logf("HTTPSRV: Got a 500 req\n")
|
|
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
|
} else if strings.HasSuffix(r.URL.Path, pathMoved) {
|
|
t.Logf("HTTPSRV: Got a http.StatusMovedPermanently redirect req\n")
|
|
if currentToken == defaultToken {
|
|
currentToken = pathMoved
|
|
}
|
|
http.Redirect(w, r, pathValid, http.StatusMovedPermanently)
|
|
} else if strings.HasSuffix(r.URL.Path, pathFound) {
|
|
t.Logf("HTTPSRV: Got a http.StatusFound redirect req\n")
|
|
if currentToken == defaultToken {
|
|
currentToken = pathFound
|
|
}
|
|
http.Redirect(w, r, pathMoved, http.StatusFound)
|
|
} else if strings.HasSuffix(r.URL.Path, pathWait) {
|
|
t.Logf("HTTPSRV: Got a wait req\n")
|
|
time.Sleep(time.Second * 3)
|
|
} else if strings.HasSuffix(r.URL.Path, pathWaitLong) {
|
|
t.Logf("HTTPSRV: Got a wait-long req\n")
|
|
time.Sleep(time.Second * 10)
|
|
} else if strings.HasSuffix(r.URL.Path, pathReLookup) {
|
|
t.Logf("HTTPSRV: Got a redirect req to a valid hostname\n")
|
|
if currentToken == defaultToken {
|
|
currentToken = pathReLookup
|
|
}
|
|
port := getPort(server)
|
|
http.Redirect(w, r, fmt.Sprintf("http://other.valid.com:%d/path", port), http.StatusFound)
|
|
} else if strings.HasSuffix(r.URL.Path, pathReLookupInvalid) {
|
|
t.Logf("HTTPSRV: Got a redirect req to an invalid host\n")
|
|
http.Redirect(w, r, "http://invalid.invalid/path", http.StatusFound)
|
|
} else if strings.HasSuffix(r.URL.Path, pathRedirectToFailingURL) {
|
|
t.Logf("HTTPSRV: Redirecting to a URL that will fail\n")
|
|
port := getPort(server)
|
|
http.Redirect(w, r, fmt.Sprintf("http://other.valid.com:%d/%s", port, path500), http.StatusMovedPermanently)
|
|
} else if strings.HasSuffix(r.URL.Path, pathLooper) {
|
|
t.Logf("HTTPSRV: Got a loop req\n")
|
|
http.Redirect(w, r, r.URL.String(), http.StatusMovedPermanently)
|
|
} else if strings.HasSuffix(r.URL.Path, pathRedirectInvalidPort) {
|
|
t.Logf("HTTPSRV: Got a port redirect req\n")
|
|
// Port 8080 is not the VA's httpPort or httpsPort and should be rejected
|
|
http.Redirect(w, r, "http://other.valid.com:8080/path", http.StatusFound)
|
|
} else if r.Header.Get("User-Agent") == rejectUserAgent {
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
w.Write([]byte("found trap User-Agent"))
|
|
} else {
|
|
t.Logf("HTTPSRV: Got a valid req\n")
|
|
t.Logf("HTTPSRV: Path = %s\n", r.URL.Path)
|
|
|
|
ch := core.Challenge{Token: currentToken}
|
|
keyAuthz, _ := ch.ExpectedKeyAuthorization(accountKey)
|
|
t.Logf("HTTPSRV: Key Authz = '%s%s'\n", keyAuthz, "\\n\\r \\t")
|
|
|
|
fmt.Fprint(w, keyAuthz, "\n\r \t")
|
|
currentToken = defaultToken
|
|
}
|
|
})
|
|
|
|
if ipv6 {
|
|
l, err := net.Listen("tcp", "[::1]:0")
|
|
if err != nil {
|
|
panic(fmt.Sprintf("httptest: failed to listen on a port: %v", err))
|
|
}
|
|
server.Listener = l
|
|
}
|
|
|
|
server.Start()
|
|
return server
|
|
}
|
|
|
|
func TestHTTPBadPort(t *testing.T) {
|
|
hs := httpSrv(t, expectedToken, false)
|
|
defer hs.Close()
|
|
|
|
va, _ := setup(hs, "", nil, nil)
|
|
|
|
// Pick a random port between 40000 and 65000 - with great certainty we won't
|
|
// have an HTTP server listening on this port and the test will fail as
|
|
// intended
|
|
badPort := 40000 + mrand.IntN(25000)
|
|
va.httpPort = badPort
|
|
|
|
_, err := va.validateHTTP01(ctx, identifier.NewDNS("localhost"), expectedToken, expectedKeyAuthorization)
|
|
if err == nil {
|
|
t.Fatalf("Server's down; expected refusal. Where did we connect?")
|
|
}
|
|
prob := detailedError(err)
|
|
test.AssertEquals(t, prob.Type, probs.ConnectionProblem)
|
|
if !strings.Contains(prob.Detail, "Connection refused") {
|
|
t.Errorf("Expected a connection refused error, got %q", prob.Detail)
|
|
}
|
|
}
|
|
|
|
func TestHTTPBadIdentifier(t *testing.T) {
|
|
hs := httpSrv(t, expectedToken, false)
|
|
defer hs.Close()
|
|
|
|
va, _ := setup(hs, "", nil, nil)
|
|
|
|
_, err := va.validateHTTP01(ctx, identifier.ACMEIdentifier{Type: "smime", Value: "dobber@bad.horse"}, expectedToken, expectedKeyAuthorization)
|
|
if err == nil {
|
|
t.Fatalf("Server accepted a hypothetical S/MIME identifier")
|
|
}
|
|
prob := detailedError(err)
|
|
test.AssertEquals(t, prob.Type, probs.MalformedProblem)
|
|
if !strings.Contains(prob.Detail, "Identifier type for HTTP-01 challenge was not DNS or IP") {
|
|
t.Errorf("Expected an identifier type error, got %q", prob.Detail)
|
|
}
|
|
}
|
|
|
|
func TestHTTPKeyAuthorizationFileMismatch(t *testing.T) {
|
|
m := http.NewServeMux()
|
|
hs := httptest.NewUnstartedServer(m)
|
|
m.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
|
w.Write([]byte("\xef\xffAABBCC"))
|
|
})
|
|
hs.Start()
|
|
|
|
va, _ := setup(hs, "", nil, nil)
|
|
_, err := va.validateHTTP01(ctx, identifier.NewDNS("localhost.com"), expectedToken, expectedKeyAuthorization)
|
|
|
|
if err == nil {
|
|
t.Fatalf("Expected validation to fail when file mismatched.")
|
|
}
|
|
expected := fmt.Sprintf(`The key authorization file from the server did not match this challenge. Expected "%s" (got "\xef\xffAABBCC")`, expectedKeyAuthorization)
|
|
if err.Error() != expected {
|
|
t.Errorf("validation failed with %s, expected %s", err, expected)
|
|
}
|
|
}
|
|
|
|
func TestHTTP(t *testing.T) {
|
|
hs := httpSrv(t, expectedToken, false)
|
|
defer hs.Close()
|
|
|
|
va, log := setup(hs, "", nil, nil)
|
|
|
|
_, err := va.validateHTTP01(ctx, identifier.NewDNS("localhost.com"), expectedToken, expectedKeyAuthorization)
|
|
if err != nil {
|
|
t.Errorf("Unexpected failure in HTTP validation for DNS: %s", err)
|
|
}
|
|
test.AssertEquals(t, len(log.GetAllMatching(`\[AUDIT\] `)), 1)
|
|
|
|
log.Clear()
|
|
_, err = va.validateHTTP01(ctx, identifier.NewIP(netip.MustParseAddr("127.0.0.1")), expectedToken, expectedKeyAuthorization)
|
|
if err != nil {
|
|
t.Errorf("Unexpected failure in HTTP validation for IPv4: %s", err)
|
|
}
|
|
test.AssertEquals(t, len(log.GetAllMatching(`\[AUDIT\] `)), 1)
|
|
|
|
log.Clear()
|
|
_, err = va.validateHTTP01(ctx, identifier.NewDNS("localhost.com"), path404, ka(path404))
|
|
if err == nil {
|
|
t.Fatalf("Should have found a 404 for the challenge.")
|
|
}
|
|
test.AssertErrorIs(t, err, berrors.Unauthorized)
|
|
test.AssertEquals(t, len(log.GetAllMatching(`\[AUDIT\] `)), 1)
|
|
|
|
log.Clear()
|
|
// The "wrong token" will actually be the expectedToken. It's wrong
|
|
// because it doesn't match pathWrongToken.
|
|
_, err = va.validateHTTP01(ctx, identifier.NewDNS("localhost.com"), pathWrongToken, ka(pathWrongToken))
|
|
if err == nil {
|
|
t.Fatalf("Should have found the wrong token value.")
|
|
}
|
|
prob := detailedError(err)
|
|
test.AssertEquals(t, prob.Type, probs.UnauthorizedProblem)
|
|
test.AssertEquals(t, len(log.GetAllMatching(`\[AUDIT\] `)), 1)
|
|
|
|
log.Clear()
|
|
_, err = va.validateHTTP01(ctx, identifier.NewDNS("localhost.com"), pathMoved, ka(pathMoved))
|
|
if err != nil {
|
|
t.Fatalf("Failed to follow http.StatusMovedPermanently redirect")
|
|
}
|
|
redirectValid := `following redirect to host "" url "http://localhost.com/.well-known/acme-challenge/` + pathValid + `"`
|
|
matchedValidRedirect := log.GetAllMatching(redirectValid)
|
|
test.AssertEquals(t, len(matchedValidRedirect), 1)
|
|
|
|
log.Clear()
|
|
_, err = va.validateHTTP01(ctx, identifier.NewDNS("localhost.com"), pathFound, ka(pathFound))
|
|
if err != nil {
|
|
t.Fatalf("Failed to follow http.StatusFound redirect")
|
|
}
|
|
redirectMoved := `following redirect to host "" url "http://localhost.com/.well-known/acme-challenge/` + pathMoved + `"`
|
|
matchedMovedRedirect := log.GetAllMatching(redirectMoved)
|
|
test.AssertEquals(t, len(matchedValidRedirect), 1)
|
|
test.AssertEquals(t, len(matchedMovedRedirect), 1)
|
|
|
|
_, err = va.validateHTTP01(ctx, identifier.NewDNS("always.invalid"), pathFound, ka(pathFound))
|
|
if err == nil {
|
|
t.Fatalf("Domain name is invalid.")
|
|
}
|
|
prob = detailedError(err)
|
|
test.AssertEquals(t, prob.Type, probs.DNSProblem)
|
|
}
|
|
|
|
func TestHTTPIPv6(t *testing.T) {
|
|
hs := httpSrv(t, expectedToken, true)
|
|
defer hs.Close()
|
|
|
|
va, log := setup(hs, "", nil, nil)
|
|
|
|
_, err := va.validateHTTP01(ctx, identifier.NewIP(netip.MustParseAddr("::1")), expectedToken, expectedKeyAuthorization)
|
|
if err != nil {
|
|
t.Errorf("Unexpected failure in HTTP validation for IPv6: %s", err)
|
|
}
|
|
test.AssertEquals(t, len(log.GetAllMatching(`\[AUDIT\] `)), 1)
|
|
}
|
|
|
|
func TestHTTPTimeout(t *testing.T) {
|
|
hs := httpSrv(t, expectedToken, false)
|
|
defer hs.Close()
|
|
|
|
va, _ := setup(hs, "", nil, nil)
|
|
|
|
started := time.Now()
|
|
timeout := 250 * time.Millisecond
|
|
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
|
defer cancel()
|
|
_, err := va.validateHTTP01(ctx, identifier.NewDNS("localhost"), pathWaitLong, ka(pathWaitLong))
|
|
if err == nil {
|
|
t.Fatalf("Connection should've timed out")
|
|
}
|
|
|
|
took := time.Since(started)
|
|
// Check that the HTTP connection doesn't return before a timeout, and times
|
|
// out after the expected time
|
|
if took < timeout-200*time.Millisecond {
|
|
t.Fatalf("HTTP timed out before %s: %s with %s", timeout, took, err)
|
|
}
|
|
if took > 2*timeout {
|
|
t.Fatalf("HTTP connection didn't timeout after %s", timeout)
|
|
}
|
|
prob := detailedError(err)
|
|
test.AssertEquals(t, prob.Type, probs.ConnectionProblem)
|
|
test.AssertEquals(t, prob.Detail, "127.0.0.1: Fetching http://localhost/.well-known/acme-challenge/wait-long: Timeout after connect (your server may be slow or overloaded)")
|
|
}
|
|
|
|
func TestHTTPRedirectLookup(t *testing.T) {
|
|
hs := httpSrv(t, expectedToken, false)
|
|
defer hs.Close()
|
|
va, log := setup(hs, "", nil, nil)
|
|
|
|
_, err := va.validateHTTP01(ctx, identifier.NewDNS("localhost.com"), pathMoved, ka(pathMoved))
|
|
if err != nil {
|
|
t.Fatalf("Unexpected failure in redirect (%s): %s", pathMoved, err)
|
|
}
|
|
redirectValid := `following redirect to host "" url "http://localhost.com/.well-known/acme-challenge/` + pathValid + `"`
|
|
matchedValidRedirect := log.GetAllMatching(redirectValid)
|
|
test.AssertEquals(t, len(matchedValidRedirect), 1)
|
|
test.AssertEquals(t, len(log.GetAllMatching(`Resolved addresses for localhost.com: \[127.0.0.1\]`)), 2)
|
|
|
|
log.Clear()
|
|
_, err = va.validateHTTP01(ctx, identifier.NewDNS("localhost.com"), pathFound, ka(pathFound))
|
|
if err != nil {
|
|
t.Fatalf("Unexpected failure in redirect (%s): %s", pathFound, err)
|
|
}
|
|
redirectMoved := `following redirect to host "" url "http://localhost.com/.well-known/acme-challenge/` + pathMoved + `"`
|
|
matchedMovedRedirect := log.GetAllMatching(redirectMoved)
|
|
test.AssertEquals(t, len(matchedMovedRedirect), 1)
|
|
test.AssertEquals(t, len(log.GetAllMatching(`Resolved addresses for localhost.com: \[127.0.0.1\]`)), 3)
|
|
|
|
log.Clear()
|
|
_, err = va.validateHTTP01(ctx, identifier.NewDNS("localhost.com"), pathReLookupInvalid, ka(pathReLookupInvalid))
|
|
test.AssertError(t, err, "error for pathReLookupInvalid should not be nil")
|
|
test.AssertEquals(t, len(log.GetAllMatching(`Resolved addresses for localhost.com: \[127.0.0.1\]`)), 1)
|
|
prob := detailedError(err)
|
|
test.AssertDeepEquals(t, prob, probs.Connection(`127.0.0.1: Fetching http://invalid.invalid/path: Invalid host in redirect target, must end in IANA registered TLD`))
|
|
|
|
log.Clear()
|
|
_, err = va.validateHTTP01(ctx, identifier.NewDNS("localhost.com"), pathReLookup, ka(pathReLookup))
|
|
if err != nil {
|
|
t.Fatalf("Unexpected error in redirect (%s): %s", pathReLookup, err)
|
|
}
|
|
redirectPattern := `following redirect to host "" url "http://other.valid.com:\d+/path"`
|
|
test.AssertEquals(t, len(log.GetAllMatching(redirectPattern)), 1)
|
|
test.AssertEquals(t, len(log.GetAllMatching(`Resolved addresses for localhost.com: \[127.0.0.1\]`)), 1)
|
|
test.AssertEquals(t, len(log.GetAllMatching(`Resolved addresses for other.valid.com: \[127.0.0.1\]`)), 1)
|
|
|
|
log.Clear()
|
|
_, err = va.validateHTTP01(ctx, identifier.NewDNS("localhost.com"), pathRedirectInvalidPort, ka(pathRedirectInvalidPort))
|
|
test.AssertNotNil(t, err, "error for pathRedirectInvalidPort should not be nil")
|
|
prob = detailedError(err)
|
|
test.AssertEquals(t, prob.Detail, fmt.Sprintf(
|
|
"127.0.0.1: Fetching http://other.valid.com:8080/path: Invalid port in redirect target. "+
|
|
"Only ports %d and %d are supported, not 8080", va.httpPort, va.httpsPort))
|
|
|
|
// This case will redirect from a valid host to a host that is throwing
|
|
// HTTP 500 errors. The test case is ensuring that the connection error
|
|
// is referencing the redirected to host, instead of the original host.
|
|
log.Clear()
|
|
_, err = va.validateHTTP01(ctx, identifier.NewDNS("localhost.com"), pathRedirectToFailingURL, ka(pathRedirectToFailingURL))
|
|
test.AssertNotNil(t, err, "err should not be nil")
|
|
prob = detailedError(err)
|
|
test.AssertDeepEquals(t, prob,
|
|
probs.Unauthorized(
|
|
fmt.Sprintf("127.0.0.1: Invalid response from http://other.valid.com:%d/500: 500",
|
|
va.httpPort)))
|
|
}
|
|
|
|
func TestHTTPRedirectLoop(t *testing.T) {
|
|
hs := httpSrv(t, expectedToken, false)
|
|
defer hs.Close()
|
|
va, _ := setup(hs, "", nil, nil)
|
|
|
|
_, prob := va.validateHTTP01(ctx, identifier.NewDNS("localhost"), "looper", ka("looper"))
|
|
if prob == nil {
|
|
t.Fatalf("Challenge should have failed for looper")
|
|
}
|
|
}
|
|
|
|
func TestHTTPRedirectUserAgent(t *testing.T) {
|
|
hs := httpSrv(t, expectedToken, false)
|
|
defer hs.Close()
|
|
va, _ := setup(hs, "", nil, nil)
|
|
va.userAgent = rejectUserAgent
|
|
|
|
_, prob := va.validateHTTP01(ctx, identifier.NewDNS("localhost"), pathMoved, ka(pathMoved))
|
|
if prob == nil {
|
|
t.Fatalf("Challenge with rejectUserAgent should have failed (%s).", pathMoved)
|
|
}
|
|
|
|
_, prob = va.validateHTTP01(ctx, identifier.NewDNS("localhost"), pathFound, ka(pathFound))
|
|
if prob == nil {
|
|
t.Fatalf("Challenge with rejectUserAgent should have failed (%s).", pathFound)
|
|
}
|
|
}
|
|
|
|
func getPort(hs *httptest.Server) int {
|
|
url, err := url.Parse(hs.URL)
|
|
if err != nil {
|
|
panic(fmt.Sprintf("Failed to parse hs URL: %q - %s", hs.URL, err.Error()))
|
|
}
|
|
_, portString, err := net.SplitHostPort(url.Host)
|
|
if err != nil {
|
|
panic(fmt.Sprintf("Failed to split hs URL host: %q - %s", url.Host, err.Error()))
|
|
}
|
|
port, err := strconv.ParseInt(portString, 10, 64)
|
|
if err != nil {
|
|
panic(fmt.Sprintf("Failed to parse hs URL port: %q - %s", portString, err.Error()))
|
|
}
|
|
return int(port)
|
|
}
|
|
|
|
func TestValidateHTTP(t *testing.T) {
|
|
token := core.NewToken()
|
|
|
|
hs := httpSrv(t, token, false)
|
|
defer hs.Close()
|
|
|
|
va, _ := setup(hs, "", nil, nil)
|
|
|
|
_, prob := va.validateHTTP01(ctx, identifier.NewDNS("localhost"), token, ka(token))
|
|
test.Assert(t, prob == nil, "validation failed")
|
|
}
|
|
|
|
func TestLimitedReader(t *testing.T) {
|
|
token := core.NewToken()
|
|
|
|
hs := httpSrv(t, "012345\xff67890123456789012345678901234567890123456789012345678901234567890123456789", false)
|
|
va, _ := setup(hs, "", nil, nil)
|
|
defer hs.Close()
|
|
|
|
_, err := va.validateHTTP01(ctx, identifier.NewDNS("localhost"), token, ka(token))
|
|
|
|
prob := detailedError(err)
|
|
test.AssertEquals(t, prob.Type, probs.UnauthorizedProblem)
|
|
test.Assert(t, strings.HasPrefix(prob.Detail, "127.0.0.1: Invalid response from "),
|
|
"Expected failure due to truncation")
|
|
|
|
if !utf8.ValidString(err.Error()) {
|
|
t.Errorf("Problem Detail contained an invalid UTF-8 string")
|
|
}
|
|
}
|
|
|
|
type hostHeaderHandler struct {
|
|
host string
|
|
}
|
|
|
|
func (handler *hostHeaderHandler) ServeHTTP(resp http.ResponseWriter, req *http.Request) {
|
|
handler.host = req.Host
|
|
}
|
|
|
|
// TestHTTPHostHeader tests compliance with RFC 8555, Sec. 8.3 & RFC 8738, Sec.
|
|
// 5.
|
|
func TestHTTPHostHeader(t *testing.T) {
|
|
testCases := []struct {
|
|
Name string
|
|
Ident identifier.ACMEIdentifier
|
|
IPv6 bool
|
|
want string
|
|
}{
|
|
{
|
|
Name: "DNS name",
|
|
Ident: identifier.NewDNS("example.com"),
|
|
want: "example.com",
|
|
},
|
|
{
|
|
Name: "IPv4 address",
|
|
Ident: identifier.NewIP(netip.MustParseAddr("127.0.0.1")),
|
|
want: "127.0.0.1",
|
|
},
|
|
{
|
|
Name: "IPv6 address",
|
|
Ident: identifier.NewIP(netip.MustParseAddr("::1")),
|
|
IPv6: true,
|
|
want: "[::1]",
|
|
},
|
|
}
|
|
|
|
for _, tc := range testCases {
|
|
t.Run(tc.Name, func(t *testing.T) {
|
|
ctx, cancel := context.WithTimeout(context.Background(), time.Millisecond*500)
|
|
defer cancel()
|
|
|
|
handler := hostHeaderHandler{}
|
|
testSrv := httptest.NewUnstartedServer(&handler)
|
|
|
|
if tc.IPv6 {
|
|
l, err := net.Listen("tcp", "[::1]:0")
|
|
if err != nil {
|
|
panic(fmt.Sprintf("httptest: failed to listen on a port: %v", err))
|
|
}
|
|
testSrv.Listener = l
|
|
}
|
|
|
|
testSrv.Start()
|
|
defer testSrv.Close()
|
|
|
|
// Setup VA. By providing the testSrv to setup the VA will use the
|
|
// testSrv's randomly assigned port as its HTTP port.
|
|
va, _ := setup(testSrv, "", nil, nil)
|
|
|
|
var got string
|
|
_, _, _ = va.processHTTPValidation(ctx, tc.Ident, "/ok")
|
|
got = handler.host
|
|
if got != tc.want {
|
|
t.Errorf("Got host %#v, but want %#v", got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|