diff --git a/registry/api/v2/headerparser.go b/registry/api/v2/headerparser.go new file mode 100644 index 00000000..9bc41a3a --- /dev/null +++ b/registry/api/v2/headerparser.go @@ -0,0 +1,161 @@ +package v2 + +import ( + "fmt" + "regexp" + "strings" + "unicode" +) + +var ( + // according to rfc7230 + reToken = regexp.MustCompile(`^[^"(),/:;<=>?@[\]{}[:space:][:cntrl:]]+`) + reQuotedValue = regexp.MustCompile(`^[^\\"]+`) + reEscapedCharacter = regexp.MustCompile(`^[[:blank:][:graph:]]`) +) + +// parseForwardedHeader is a benevolent parser of Forwarded header defined in rfc7239. The header contains +// a comma-separated list of forwarding key-value pairs. Each list element is set by single proxy. The +// function parses only the first element of the list, which is set by the very first proxy. It returns a map +// of corresponding key-value pairs and an unparsed slice of the input string. +// +// Examples of Forwarded header values: +// +// 1. Forwarded: For=192.0.2.43; Proto=https,For="[2001:db8:cafe::17]",For=unknown +// 2. Forwarded: for="192.0.2.43:443"; host="registry.example.org", for="10.10.05.40:80" +// +// The first will be parsed into {"for": "192.0.2.43", "proto": "https"} while the second into +// {"for": "192.0.2.43:443", "host": "registry.example.org"}. +func parseForwardedHeader(forwarded string) (map[string]string, string, error) { + // Following are states of forwarded header parser. Any state could transition to a failure. + const ( + // terminating state; can transition to Parameter + stateElement = iota + // terminating state; can transition to KeyValueDelimiter + stateParameter + // can transition to Value + stateKeyValueDelimiter + // can transition to one of { QuotedValue, PairEnd } + stateValue + // can transition to one of { EscapedCharacter, PairEnd } + stateQuotedValue + // can transition to one of { QuotedValue } + stateEscapedCharacter + // terminating state; can transition to one of { Parameter, Element } + statePairEnd + ) + + var ( + parameter string + value string + parse = forwarded[:] + res = map[string]string{} + state = stateElement + ) + +Loop: + for { + // skip spaces unless in quoted value + if state != stateQuotedValue && state != stateEscapedCharacter { + parse = strings.TrimLeftFunc(parse, unicode.IsSpace) + } + + if len(parse) == 0 { + if state != stateElement && state != statePairEnd && state != stateParameter { + return nil, parse, fmt.Errorf("unexpected end of input") + } + // terminating + break + } + + switch state { + // terminate at list element delimiter + case stateElement: + if parse[0] == ',' { + parse = parse[1:] + break Loop + } + state = stateParameter + + // parse parameter (the key of key-value pair) + case stateParameter: + match := reToken.FindString(parse) + if len(match) == 0 { + return nil, parse, fmt.Errorf("failed to parse token at position %d", len(forwarded)-len(parse)) + } + parameter = strings.ToLower(match) + parse = parse[len(match):] + state = stateKeyValueDelimiter + + // parse '=' + case stateKeyValueDelimiter: + if parse[0] != '=' { + return nil, parse, fmt.Errorf("expected '=', not '%c' at position %d", parse[0], len(forwarded)-len(parse)) + } + parse = parse[1:] + state = stateValue + + // parse value or quoted value + case stateValue: + if parse[0] == '"' { + parse = parse[1:] + state = stateQuotedValue + } else { + value = reToken.FindString(parse) + if len(value) == 0 { + return nil, parse, fmt.Errorf("failed to parse value at position %d", len(forwarded)-len(parse)) + } + if _, exists := res[parameter]; exists { + return nil, parse, fmt.Errorf("duplicate parameter %q at position %d", parameter, len(forwarded)-len(parse)) + } + res[parameter] = value + parse = parse[len(value):] + value = "" + state = statePairEnd + } + + // parse a part of quoted value until the first backslash + case stateQuotedValue: + match := reQuotedValue.FindString(parse) + value += match + parse = parse[len(match):] + switch { + case len(parse) == 0: + return nil, parse, fmt.Errorf("unterminated quoted string") + case parse[0] == '"': + res[parameter] = value + value = "" + parse = parse[1:] + state = statePairEnd + case parse[0] == '\\': + parse = parse[1:] + state = stateEscapedCharacter + } + + // parse escaped character in a quoted string, ignore the backslash + // transition back to QuotedValue state + case stateEscapedCharacter: + c := reEscapedCharacter.FindString(parse) + if len(c) == 0 { + return nil, parse, fmt.Errorf("invalid escape sequence at position %d", len(forwarded)-len(parse)-1) + } + value += c + parse = parse[1:] + state = stateQuotedValue + + // expect either a new key-value pair, new list or end of input + case statePairEnd: + switch parse[0] { + case ';': + parse = parse[1:] + state = stateParameter + case ',': + state = stateElement + default: + return nil, parse, fmt.Errorf("expected ',' or ';', not %c at position %d", parse[0], len(forwarded)-len(parse)) + } + } + } + + return res, parse, nil +} diff --git a/registry/api/v2/headerparser_test.go b/registry/api/v2/headerparser_test.go new file mode 100644 index 00000000..b8c37490 --- /dev/null +++ b/registry/api/v2/headerparser_test.go @@ -0,0 +1,161 @@ +package v2 + +import ( + "testing" +) + +func TestParseForwardedHeader(t *testing.T) { + for _, tc := range []struct { + name string + raw string + expected map[string]string + expectedRest string + expectedError bool + }{ + { + name: "empty", + raw: "", + }, + { + name: "one pair", + raw: " key = value ", + expected: map[string]string{"key": "value"}, + }, + { + name: "two pairs", + raw: " key1 = value1; key2=value2", + expected: map[string]string{"key1": "value1", "key2": "value2"}, + }, + { + name: "uppercase parameter", + raw: "KeY=VaL", + expected: map[string]string{"key": "VaL"}, + }, + { + name: "missing key=value pair - be tolerant", + raw: "key=val;", + expected: map[string]string{"key": "val"}, + }, + { + name: "quoted values", + raw: `key="val";param = "[[ $((1 + 1)) == 3 ]] && echo panic!;" ; p=" abcd "`, + expected: map[string]string{"key": "val", "param": "[[ $((1 + 1)) == 3 ]] && echo panic!;", "p": " abcd "}, + }, + { + name: "empty quoted value", + raw: `key=""`, + expected: map[string]string{"key": ""}, + }, + { + name: "quoted double quotes", + raw: `key="\"value\""`, + expected: map[string]string{"key": `"value"`}, + }, + { + name: "quoted backslash", + raw: `key="\"\\\""`, + expected: map[string]string{"key": `"\"`}, + }, + { + name: "ignore subsequent elements", + raw: "key=a, param= b", + expected: map[string]string{"key": "a"}, + expectedRest: " param= b", + }, + { + name: "empty element - be tolerant", + raw: " , key=val", + expectedRest: " key=val", + }, + { + name: "obscure key", + raw: `ob₷C&r€ = value`, + expected: map[string]string{`ob₷c&r€`: "value"}, + }, + { + name: "duplicate parameter", + raw: "key=a; p=b; key=c", + expectedError: true, + }, + { + name: "empty parameter", + raw: "=value", + expectedError: true, + }, + { + name: "empty value", + raw: "key= ", + expectedError: true, + }, + { + name: "empty value before a new element ", + raw: "key=,", + expectedError: true, + }, + { + name: "empty value before a new pair", + raw: "key=;", + expectedError: true, + }, + { + name: "just parameter", + raw: "key", + expectedError: true, + }, + { + name: "missing key-value", + raw: "a=b;;", + expectedError: true, + }, + { + name: "unclosed quoted value", + raw: `key="value`, + expectedError: true, + }, + { + name: "escaped terminating dquote", + raw: `key="value\"`, + expectedError: true, + }, + { + name: "just a quoted value", + raw: `"key=val"`, + expectedError: true, + }, + { + name: "quoted key", + raw: `"key"=val`, + expectedError: true, + }, + } { + parsed, rest, err := parseForwardedHeader(tc.raw) + if err != nil && !tc.expectedError { + t.Errorf("[%s] got unexpected error: %v", tc.name, err) + } + if err == nil && tc.expectedError { + t.Errorf("[%s] got unexpected non-error", tc.name) + } + if err != nil || tc.expectedError { + continue + } + for key, value := range tc.expected { + v, exists := parsed[key] + if !exists { + t.Errorf("[%s] missing expected parameter %q", tc.name, key) + continue + } + if v != value { + t.Errorf("[%s] got unexpected value for parameter %q: %q != %q", tc.name, key, v, value) + } + } + for key, value := range parsed { + if _, exists := tc.expected[key]; !exists { + t.Errorf("[%s] got unexpected key/value pair: %q=%q", tc.name, key, value) + } + } + + if rest != tc.expectedRest { + t.Errorf("[%s] got unexpected unparsed string: %q != %q", tc.name, rest, tc.expectedRest) + } + } +} diff --git a/registry/api/v2/urls.go b/registry/api/v2/urls.go index a959aaa8..e2e242ea 100644 --- a/registry/api/v2/urls.go +++ b/registry/api/v2/urls.go @@ -1,8 +1,10 @@ package v2 import ( + "net" "net/http" "net/url" + "strconv" "strings" "github.com/docker/distribution/reference" @@ -49,10 +51,14 @@ func NewURLBuilderFromRequest(r *http.Request, relative bool) *URLBuilder { var scheme string forwardedProto := r.Header.Get("X-Forwarded-Proto") + // TODO: log the error + forwardedHeader, _, _ := parseForwardedHeader(r.Header.Get("Forwarded")) switch { case len(forwardedProto) > 0: scheme = forwardedProto + case len(forwardedHeader["proto"]) > 0: + scheme = forwardedHeader["proto"] case r.TLS != nil: scheme = "https" case len(r.URL.Scheme) > 0: @@ -62,14 +68,46 @@ func NewURLBuilderFromRequest(r *http.Request, relative bool) *URLBuilder { } host := r.Host - forwardedHost := r.Header.Get("X-Forwarded-Host") - if len(forwardedHost) > 0 { + + if forwardedHost := r.Header.Get("X-Forwarded-Host"); len(forwardedHost) > 0 { // According to the Apache mod_proxy docs, X-Forwarded-Host can be a // comma-separated list of hosts, to which each proxy appends the // requested host. We want to grab the first from this comma-separated // list. hosts := strings.SplitN(forwardedHost, ",", 2) host = strings.TrimSpace(hosts[0]) + } else if addr, exists := forwardedHeader["for"]; exists { + host = addr + } else if h, exists := forwardedHeader["host"]; exists { + host = h + } + + portLessHost, port := host, "" + if !isIPv6Address(portLessHost) { + // with go 1.6, this would treat the last part of IPv6 address as a port + portLessHost, port, _ = net.SplitHostPort(host) + } + if forwardedPort := r.Header.Get("X-Forwarded-Port"); len(port) == 0 && len(forwardedPort) > 0 { + ports := strings.SplitN(forwardedPort, ",", 2) + forwardedPort = strings.TrimSpace(ports[0]) + if _, err := strconv.ParseInt(forwardedPort, 10, 32); err == nil { + port = forwardedPort + } + } + + if len(portLessHost) > 0 { + host = portLessHost + } + if len(port) > 0 { + // remove enclosing brackets of ipv6 address otherwise they will be duplicated + if len(host) > 1 && host[0] == '[' && host[len(host)-1] == ']' { + host = host[1 : len(host)-1] + } + // JoinHostPort properly encloses ipv6 addresses in square brackets + host = net.JoinHostPort(host, port) + } else if isIPv6Address(host) && host[0] != '[' { + // ipv6 needs to be enclosed in square brackets in urls + host = "[" + host + "]" } basePath := routeDescriptorsMap[RouteNameBase].Path @@ -249,3 +287,28 @@ func appendValues(u string, values ...url.Values) string { return appendValuesURL(up, values...).String() } + +// isIPv6Address returns true if given string is a valid IPv6 address. No port is allowed. The address may be +// enclosed in square brackets. +func isIPv6Address(host string) bool { + if len(host) > 1 && host[0] == '[' && host[len(host)-1] == ']' { + host = host[1 : len(host)-1] + } + // The IPv6 scoped addressing zone identifier starts after the last percent sign. + if i := strings.LastIndexByte(host, '%'); i > 0 { + host = host[:i] + } + ip := net.ParseIP(host) + if ip == nil { + return false + } + if ip.To16() == nil { + return false + } + if ip.To4() == nil { + return true + } + // dot can be present in ipv4-mapped address, it needs to come after a colon though + i := strings.IndexAny(host, ":.") + return i >= 0 && host[i] == ':' +} diff --git a/registry/api/v2/urls_test.go b/registry/api/v2/urls_test.go index 10aadd52..16f16269 100644 --- a/registry/api/v2/urls_test.go +++ b/registry/api/v2/urls_test.go @@ -165,50 +165,222 @@ func TestBuilderFromRequest(t *testing.T) { t.Fatal(err) } - forwardedProtoHeader := make(http.Header, 1) - forwardedProtoHeader.Set("X-Forwarded-Proto", "https") - - forwardedHostHeader1 := make(http.Header, 1) - forwardedHostHeader1.Set("X-Forwarded-Host", "first.example.com") - - forwardedHostHeader2 := make(http.Header, 1) - forwardedHostHeader2.Set("X-Forwarded-Host", "first.example.com, proxy1.example.com") - testRequests := []struct { + name string request *http.Request base string configHost url.URL }{ { + name: "no forwarded header", request: &http.Request{URL: u, Host: u.Host}, base: "http://example.com", }, - { - request: &http.Request{URL: u, Host: u.Host, Header: forwardedProtoHeader}, - base: "http://example.com", + name: "https protocol forwarded with a non-standard header", + request: &http.Request{URL: u, Host: u.Host, Header: http.Header{ + "X-Forwarded-Proto": []string{"https"}, + }}, + base: "http://example.com", }, { - request: &http.Request{URL: u, Host: u.Host, Header: forwardedProtoHeader}, - base: "https://example.com", + name: "forwarded protocol is the same", + request: &http.Request{URL: u, Host: u.Host, Header: http.Header{ + "X-Forwarded-Proto": []string{"https"}, + }}, + base: "https://example.com", }, { - request: &http.Request{URL: u, Host: u.Host, Header: forwardedHostHeader1}, - base: "http://first.example.com", + name: "forwarded host with a non-standard header", + request: &http.Request{URL: u, Host: u.Host, Header: http.Header{ + "X-Forwarded-Host": []string{"first.example.com"}, + }}, + base: "http://first.example.com", }, { - request: &http.Request{URL: u, Host: u.Host, Header: forwardedHostHeader2}, - base: "http://first.example.com", + name: "forwarded multiple hosts a with non-standard header", + request: &http.Request{URL: u, Host: u.Host, Header: http.Header{ + "X-Forwarded-Host": []string{"first.example.com, proxy1.example.com"}, + }}, + base: "http://first.example.com", }, { - request: &http.Request{URL: u, Host: u.Host, Header: forwardedHostHeader2}, - base: "https://third.example.com:5000", + name: "host configured in config file takes priority", + request: &http.Request{URL: u, Host: u.Host, Header: http.Header{ + "X-Forwarded-Host": []string{"first.example.com, proxy1.example.com"}, + }}, + base: "https://third.example.com:5000", configHost: url.URL{ Scheme: "https", Host: "third.example.com:5000", }, }, + { + name: "forwarded host and port with just one non-standard header", + request: &http.Request{URL: u, Host: u.Host, Header: http.Header{ + "X-Forwarded-Host": []string{"first.example.com:443"}, + }}, + base: "http://first.example.com:443", + }, + { + name: "forwarded port with a non-standard header", + request: &http.Request{URL: u, Host: u.Host, Header: http.Header{ + "X-Forwarded-Port": []string{"5000"}, + }}, + base: "http://example.com:5000", + }, + { + name: "forwarded multiple ports with a non-standard header", + request: &http.Request{URL: u, Host: u.Host, Header: http.Header{ + "X-Forwarded-Port": []string{"443 , 5001"}, + }}, + base: "http://example.com:443", + }, + { + name: "several non-standard headers", + request: &http.Request{URL: u, Host: u.Host, Header: http.Header{ + "X-Forwarded-Proto": []string{"https"}, + "X-Forwarded-Host": []string{" first.example.com "}, + "X-Forwarded-Port": []string{" 12345 \t"}, + }}, + base: "http://first.example.com:12345", + }, + { + name: "forwarded host with port supplied takes priority", + request: &http.Request{URL: u, Host: u.Host, Header: http.Header{ + "X-Forwarded-Host": []string{"first.example.com:5000"}, + "X-Forwarded-Port": []string{"80"}, + }}, + base: "http://first.example.com:5000", + }, + { + name: "malformed forwarded port", + request: &http.Request{URL: u, Host: u.Host, Header: http.Header{ + "X-Forwarded-Host": []string{"first.example.com"}, + "X-Forwarded-Port": []string{"abcd"}, + }}, + base: "http://first.example.com", + }, + { + name: "forwarded protocol and addr using standard header", + request: &http.Request{URL: u, Host: u.Host, Header: http.Header{ + "Forwarded": []string{`proto=https;for="192.168.22.30:80"`}, + }}, + base: "https://192.168.22.30:80", + }, + { + name: "forwarded addr takes priority over host", + request: &http.Request{URL: u, Host: u.Host, Header: http.Header{ + "Forwarded": []string{`host=reg.example.com;for="192.168.22.30:5000"`}, + }}, + base: "http://192.168.22.30:5000", + }, + { + name: "forwarded host and protocol using standard header", + request: &http.Request{URL: u, Host: u.Host, Header: http.Header{ + "Forwarded": []string{`host=reg.example.com;proto=https`}, + }}, + base: "https://reg.example.com", + }, + { + name: "process just the first standard forwarded header", + request: &http.Request{URL: u, Host: u.Host, Header: http.Header{ + "Forwarded": []string{`host="reg.example.com:88";proto=http`, `host=reg.example.com;proto=https`}, + }}, + base: "http://reg.example.com:88", + }, + { + name: "process just the first list element of standard header", + request: &http.Request{URL: u, Host: u.Host, Header: http.Header{ + "Forwarded": []string{`for="reg.example.com:443";proto=https, for="reg.example.com:80";proto=http`}, + }}, + base: "https://reg.example.com:443", + }, + { + name: "IPv6 address override port", + request: &http.Request{URL: u, Host: u.Host, Header: http.Header{ + "Forwarded": []string{`for="2607:f0d0:1002:51::4"`}, + "X-Forwarded-Port": []string{"5001"}, + }}, + base: "http://[2607:f0d0:1002:51::4]:5001", + }, + { + name: "IPv6 address with port", + request: &http.Request{URL: u, Host: u.Host, Header: http.Header{ + "Forwarded": []string{`for="[2607:f0d0:1002:51::4]:4000"`}, + "X-Forwarded-Port": []string{"5001"}, + }}, + base: "http://[2607:f0d0:1002:51::4]:4000", + }, + { + name: "IPv6 long address override port", + request: &http.Request{URL: u, Host: u.Host, Header: http.Header{ + "Forwarded": []string{`for="2607:f0d0:1002:0051:0000:0000:0000:0004"`}, + "X-Forwarded-Port": []string{"5001"}, + }}, + base: "http://[2607:f0d0:1002:0051:0000:0000:0000:0004]:5001", + }, + { + name: "IPv6 long address enclosed in brackets - be benevolent", + request: &http.Request{URL: u, Host: u.Host, Header: http.Header{ + "Forwarded": []string{`for="[2607:f0d0:1002:0051:0000:0000:0000:0004]"`}, + "X-Forwarded-Port": []string{"5001"}, + }}, + base: "http://[2607:f0d0:1002:0051:0000:0000:0000:0004]:5001", + }, + { + name: "IPv6 long address with port", + request: &http.Request{URL: u, Host: u.Host, Header: http.Header{ + "Forwarded": []string{`for="[2607:f0d0:1002:0051:0000:0000:0000:0004]:4321"`}, + "X-Forwarded-Port": []string{"5001"}, + }}, + base: "http://[2607:f0d0:1002:0051:0000:0000:0000:0004]:4321", + }, + { + name: "IPv6 address with zone ID", + request: &http.Request{URL: u, Host: u.Host, Header: http.Header{ + "Forwarded": []string{`for="fe80::bd0f:a8bc:6480:238b%11"`}, + "X-Forwarded-Port": []string{"5001"}, + }}, + base: "http://[fe80::bd0f:a8bc:6480:238b%2511]:5001", + }, + { + name: "IPv6 address with zone ID and port", + request: &http.Request{URL: u, Host: u.Host, Header: http.Header{ + "Forwarded": []string{`for="[fe80::bd0f:a8bc:6480:238b%eth0]:12345"`}, + "X-Forwarded-Port": []string{"5001"}, + }}, + base: "http://[fe80::bd0f:a8bc:6480:238b%25eth0]:12345", + }, + { + name: "IPv6 address without port", + request: &http.Request{URL: u, Host: u.Host, Header: http.Header{ + "Forwarded": []string{`for="::FFFF:129.144.52.38"`}, + }}, + base: "http://[::FFFF:129.144.52.38]", + }, + { + name: "non-standard and standard forward headers", + request: &http.Request{URL: u, Host: u.Host, Header: http.Header{ + "X-Forwarded-Proto": []string{`https`}, + "X-Forwarded-Host": []string{`first.example.com`}, + "X-Forwarded-Port": []string{``}, + "Forwarded": []string{`host=first.example.com; proto=https`}, + }}, + base: "https://first.example.com", + }, + { + name: "non-standard headers take precedence over standard one", + request: &http.Request{URL: u, Host: u.Host, Header: http.Header{ + "X-Forwarded-Proto": []string{`http`}, + "Forwarded": []string{`host=second.example.com; proto=https`}, + "X-Forwarded-Host": []string{`first.example.com`}, + "X-Forwarded-Port": []string{`4000`}, + }}, + base: "http://first.example.com:4000", + }, } + doTest := func(relative bool) { for _, tr := range testRequests { var builder *URLBuilder @@ -221,7 +393,7 @@ func TestBuilderFromRequest(t *testing.T) { for _, testCase := range makeURLBuilderTestCases(builder) { buildURL, err := testCase.build() if err != nil { - t.Fatalf("%s: error building url: %v", testCase.description, err) + t.Fatalf("[relative=%t, request=%q, case=%q]: error building url: %v", relative, tr.name, testCase.description, err) } var expectedURL string @@ -244,11 +416,12 @@ func TestBuilderFromRequest(t *testing.T) { } if buildURL != expectedURL { - t.Fatalf("%s: %q != %q", testCase.description, buildURL, expectedURL) + t.Errorf("[relative=%t, request=%q, case=%q]: %q != %q", relative, tr.name, testCase.description, buildURL, expectedURL) } } } } + doTest(true) doTest(false) } @@ -332,3 +505,119 @@ func TestBuilderFromRequestWithPrefix(t *testing.T) { } } } + +func TestIsIPv6Address(t *testing.T) { + for _, tc := range []struct { + name string + address string + isIPv6 bool + }{ + { + name: "IPv6 short address", + address: `2607:f0d0:1002:51::4`, + isIPv6: true, + }, + { + name: "IPv6 short address enclosed in brackets", + address: "[2607:f0d0:1002:51::4]", + isIPv6: true, + }, + { + name: "IPv6 address", + address: `2607:f0d0:1002:0051:0000:0000:0000:0004`, + isIPv6: true, + }, + { + name: "IPv6 address with numeric zone ID", + address: `fe80::bd0f:a8bc:6480:238b%11`, + isIPv6: true, + }, + { + name: "IPv6 address with device name as zone ID", + address: `fe80::bd0f:a8bc:6480:238b%eth0`, + isIPv6: true, + }, + { + name: "IPv6 address with device name as zone ID enclosed in brackets", + address: `[fe80::bd0f:a8bc:6480:238b%eth0]`, + isIPv6: true, + }, + { + name: "IPv4-mapped address", + address: "::FFFF:129.144.52.38", + isIPv6: true, + }, + { + name: "localhost", + address: "::1", + isIPv6: true, + }, + { + name: "localhost", + address: "::1", + isIPv6: true, + }, + { + name: "long localhost address", + address: "0:0:0:0:0:0:0:1", + isIPv6: true, + }, + { + name: "IPv6 long address with port", + address: "[2607:f0d0:1002:0051:0000:0000:0000:0004]:4321", + isIPv6: false, + }, + { + name: "too many groups", + address: "2607:f0d0:1002:0051:0000:0000:0000:0004:4321", + isIPv6: false, + }, + { + name: "square brackets don't make an IPv6 address", + address: "[2607:f0d0]", + isIPv6: false, + }, + { + name: "require two consecutive colons in localhost", + address: ":1", + isIPv6: false, + }, + { + name: "more then 4 hexadecimal digits", + address: "2607:f0d0b:1002:0051:0000:0000:0000:0004", + isIPv6: false, + }, + { + name: "too short address", + address: `2607:f0d0:1002:0000:0000:0000:0004`, + isIPv6: false, + }, + { + name: "IPv4 address", + address: `192.168.100.1`, + isIPv6: false, + }, + { + name: "unclosed bracket", + address: `[2607:f0d0:1002:0051:0000:0000:0000:0004`, + isIPv6: false, + }, + { + name: "trailing bracket", + address: `2607:f0d0:1002:0051:0000:0000:0000:0004]`, + isIPv6: false, + }, + { + name: "domain name", + address: `localhost`, + isIPv6: false, + }, + } { + isIPv6 := isIPv6Address(tc.address) + if isIPv6 && !tc.isIPv6 { + t.Errorf("[%s] address %q falsely detected as IPv6 address", tc.name, tc.address) + } else if !isIPv6 && tc.isIPv6 { + t.Errorf("[%s] address %q not recognized as IPv6", tc.name, tc.address) + } + } +}