diff --git a/certificates.go b/certificates.go index f92d9b8f..e7cfa5fe 100644 --- a/certificates.go +++ b/certificates.go @@ -651,6 +651,11 @@ func isInternalIP(addr string) bool { func hostOnly(hostport string) string { host, _, err := net.SplitHostPort(hostport) if err != nil { + // May be a bare IPv6 address in brackets without a port (e.g. "[::1]"). + // net.SplitHostPort requires a port when brackets are present, so strip them. + if len(hostport) > 1 && hostport[0] == '[' && hostport[len(hostport)-1] == ']' { + return hostport[1 : len(hostport)-1] + } return hostport // OK; probably had no port to begin with } return host @@ -665,6 +670,8 @@ func hostOnly(hostport string) string { // It uses DNS wildcard matching logic and is case-insensitive. // https://tools.ietf.org/html/rfc2818#section-3.1 func MatchWildcard(subject, wildcard string) bool { + // Strip brackets from IPv6 addresses (e.g. "[::1]" from HTTP Host headers). + subject = hostOnly(subject) subject, wildcard = strings.ToLower(subject), strings.ToLower(wildcard) if subject == wildcard { return true diff --git a/certificates_test.go b/certificates_test.go index f8ecc3df..a53abcb5 100644 --- a/certificates_test.go +++ b/certificates_test.go @@ -189,6 +189,37 @@ func TestSubjectQualifiesForPublicCert(t *testing.T) { } } +func TestHostOnly(t *testing.T) { + for i, test := range []struct { + input string + expect string + }{ + // hostname without port + {"example.com", "example.com"}, + // hostname with port + {"example.com:443", "example.com"}, + // IPv4 without port + {"1.2.3.4", "1.2.3.4"}, + // IPv4 with port + {"1.2.3.4:80", "1.2.3.4"}, + // IPv6 without port and without brackets + {"::1", "::1"}, + // IPv6 with port (brackets required by RFC 7230) + {"[::1]:80", "::1"}, + // IPv6 without port but with brackets (Go's HTTP server format for host-only) + {"[::1]", "::1"}, + // full IPv6 without port but with brackets + {"[2001:db8::1]", "2001:db8::1"}, + // full IPv6 with port + {"[2001:db8::1]:8080", "2001:db8::1"}, + } { + actual := hostOnly(test.input) + if actual != test.expect { + t.Errorf("Test %d: hostOnly(%q) = %q, want %q", i, test.input, actual, test.expect) + } + } +} + func TestMatchWildcard(t *testing.T) { for i, test := range []struct { subject, wildcard string @@ -217,6 +248,10 @@ func TestMatchWildcard(t *testing.T) { {"1.2.3.4.5.6", "*.*.*.*.*.*", true}, {"0.1.2.3.4.5.6", "*.*.*.*.*.*", false}, {"1.2.3.4", "1.2.3.*", false}, // https://tools.ietf.org/html/rfc2818#section-3.1 + // Bracketed IPv6 subjects (from HTTP Host headers) must match bare IPv6 wildcards. + {"[::1]", "::1", true}, + {"[2001:db8::1]", "2001:db8::1", true}, + {"[::1]", "::2", false}, } { actual := MatchWildcard(test.subject, test.wildcard) if actual != test.expect { diff --git a/solvers.go b/solvers.go index 677fad3f..b4801303 100644 --- a/solvers.go +++ b/solvers.go @@ -768,6 +768,8 @@ func dialTCPSocket(addr string) error { // GetACMEChallenge returns an active ACME challenge for the given identifier, // or false if no active challenge for that identifier is known. func GetACMEChallenge(identifier string) (Challenge, bool) { + // Strip brackets from IPv6 addresses (e.g. "[::1]" from HTTP Host headers). + identifier = hostOnly(identifier) activeChallengesMu.Lock() chalData, ok := activeChallenges[identifier] activeChallengesMu.Unlock() diff --git a/solvers_test.go b/solvers_test.go index d30ce66d..c9fc1974 100644 --- a/solvers_test.go +++ b/solvers_test.go @@ -155,3 +155,25 @@ func Test_challengeKey(t *testing.T) { }) } } + +func TestGetACMEChallenge_IPv6Brackets(t *testing.T) { + // Store a challenge under a bare IPv6 identifier (as CertMagic does internally). + bare := "::1" + activeChallengesMu.Lock() + activeChallenges[bare] = Challenge{} + activeChallengesMu.Unlock() + defer func() { + activeChallengesMu.Lock() + delete(activeChallenges, bare) + activeChallengesMu.Unlock() + }() + + // Lookup with bracketed IPv6 (as received from Go's HTTP server via r.Host). + if _, ok := GetACMEChallenge("[::1]"); !ok { + t.Error("GetACMEChallenge(\"[::1]\") should find challenge stored under \"::1\"") + } + // Lookup with bare IPv6 should still work. + if _, ok := GetACMEChallenge("::1"); !ok { + t.Error("GetACMEChallenge(\"::1\") should find challenge stored under \"::1\"") + } +}