]> Cypherpunks repositories - gostls13.git/commitdiff
[release-branch.go1.25] net/url: enforce stricter parsing of bracketed IPv6 hostnames
authorEthan Lee <ethanalee@google.com>
Fri, 29 Aug 2025 17:35:55 +0000 (17:35 +0000)
committerGopher Robot <gobot@golang.org>
Tue, 7 Oct 2025 18:02:12 +0000 (11:02 -0700)
- Previously, url.Parse did not enforce validation of hostnames within
  square brackets.
- RFC 3986 stipulates that only IPv6 hostnames can be embedded within
  square brackets in a URL.
- Now, the parsing logic should strictly enforce that only IPv6
  hostnames can be resolved when in square brackets. IPv4, IPv4-mapped
  addresses and other input will be rejected.
- Update url_test to add test cases that cover the above scenarios.

Thanks to Enze Wang, Jingcheng Yang and Zehui Miao of Tsinghua
University for reporting this issue.

Fixes CVE-2025-47912
For #75678
Fixes #75713

Change-Id: Iaa41432bf0ee86de95a39a03adae5729e4deb46c
Reviewed-on: https://go-internal-review.googlesource.com/c/go/+/2680
Reviewed-by: Damien Neil <dneil@google.com>
Reviewed-by: Roland Shoemaker <bracewell@google.com>
Reviewed-on: https://go-internal-review.googlesource.com/c/go/+/2988
Commit-Queue: Roland Shoemaker <bracewell@google.com>
Reviewed-on: https://go-review.googlesource.com/c/go/+/709847
TryBot-Bypass: Michael Pratt <mpratt@google.com>
Auto-Submit: Michael Pratt <mpratt@google.com>
Reviewed-by: Carlos Amedee <carlos@golang.org>
src/go/build/deps_test.go
src/net/url/url.go
src/net/url/url_test.go

index 6d92542e31b6526a5c9797f2d51bfb9f6a7c9ae2..b3ca2a017edd26eb00a0a897256bd3f54dc6943f 100644 (file)
@@ -235,7 +235,6 @@ var depsRules = `
          internal/types/errors,
          mime/quotedprintable,
          net/internal/socktest,
-         net/url,
          runtime/trace,
          text/scanner,
          text/tabwriter;
@@ -298,6 +297,12 @@ var depsRules = `
        FMT
        < text/template/parse;
 
+       internal/bytealg, internal/itoa, math/bits, slices, strconv, unique
+       < net/netip;
+
+       FMT, net/netip
+       < net/url;
+
        net/url, text/template/parse
        < text/template
        < internal/lazytemplate;
@@ -412,9 +417,6 @@ var depsRules = `
        < golang.org/x/net/dns/dnsmessage,
          golang.org/x/net/lif;
 
-       internal/bytealg, internal/itoa, math/bits, slices, strconv, unique
-       < net/netip;
-
        os, net/netip
        < internal/routebsd;
 
index 2a57659460373d0f6b7820fe9be121e00bf2e02e..40faa7cb9e1c6f59cae9d14be07beb1bf2cb4905 100644 (file)
@@ -16,6 +16,7 @@ import (
        "errors"
        "fmt"
        "maps"
+       "net/netip"
        "path"
        "slices"
        "strconv"
@@ -626,40 +627,61 @@ func parseAuthority(authority string) (user *Userinfo, host string, err error) {
 // parseHost parses host as an authority without user
 // information. That is, as host[:port].
 func parseHost(host string) (string, error) {
-       if strings.HasPrefix(host, "[") {
+       if openBracketIdx := strings.LastIndex(host, "["); openBracketIdx != -1 {
                // Parse an IP-Literal in RFC 3986 and RFC 6874.
                // E.g., "[fe80::1]", "[fe80::1%25en0]", "[fe80::1]:80".
-               i := strings.LastIndex(host, "]")
-               if i < 0 {
+               closeBracketIdx := strings.LastIndex(host, "]")
+               if closeBracketIdx < 0 {
                        return "", errors.New("missing ']' in host")
                }
-               colonPort := host[i+1:]
+
+               colonPort := host[closeBracketIdx+1:]
                if !validOptionalPort(colonPort) {
                        return "", fmt.Errorf("invalid port %q after host", colonPort)
                }
+               unescapedColonPort, err := unescape(colonPort, encodeHost)
+               if err != nil {
+                       return "", err
+               }
 
+               hostname := host[openBracketIdx+1 : closeBracketIdx]
+               var unescapedHostname string
                // RFC 6874 defines that %25 (%-encoded percent) introduces
                // the zone identifier, and the zone identifier can use basically
                // any %-encoding it likes. That's different from the host, which
                // can only %-encode non-ASCII bytes.
                // We do impose some restrictions on the zone, to avoid stupidity
                // like newlines.
-               zone := strings.Index(host[:i], "%25")
-               if zone >= 0 {
-                       host1, err := unescape(host[:zone], encodeHost)
+               zoneIdx := strings.Index(hostname, "%25")
+               if zoneIdx >= 0 {
+                       hostPart, err := unescape(hostname[:zoneIdx], encodeHost)
                        if err != nil {
                                return "", err
                        }
-                       host2, err := unescape(host[zone:i], encodeZone)
+                       zonePart, err := unescape(hostname[zoneIdx:], encodeZone)
                        if err != nil {
                                return "", err
                        }
-                       host3, err := unescape(host[i:], encodeHost)
+                       unescapedHostname = hostPart + zonePart
+               } else {
+                       var err error
+                       unescapedHostname, err = unescape(hostname, encodeHost)
                        if err != nil {
                                return "", err
                        }
-                       return host1 + host2 + host3, nil
                }
+
+               // Per RFC 3986, only a host identified by a valid
+               // IPv6 address can be enclosed by square brackets.
+               // This excludes any IPv4 or IPv4-mapped addresses.
+               addr, err := netip.ParseAddr(unescapedHostname)
+               if err != nil {
+                       return "", fmt.Errorf("invalid host: %w", err)
+               }
+               if addr.Is4() || addr.Is4In6() {
+                       return "", errors.New("invalid IPv6 host")
+               }
+               return "[" + unescapedHostname + "]" + unescapedColonPort, nil
        } else if i := strings.LastIndex(host, ":"); i != -1 {
                colonPort := host[i:]
                if !validOptionalPort(colonPort) {
index 16e08b63c6d09868a7e0f6ce66b49174f982a350..32065583f27dd7fa4a1a1acedd781f1e6f05dcdf 100644 (file)
@@ -383,6 +383,16 @@ var urltests = []URLTest{
                },
                "",
        },
+       // valid IPv6 host with port and path
+       {
+               "https://[2001:db8::1]:8443/test/path",
+               &URL{
+                       Scheme: "https",
+                       Host:   "[2001:db8::1]:8443",
+                       Path:   "/test/path",
+               },
+               "",
+       },
        // host subcomponent; IPv6 address with zone identifier in RFC 6874
        {
                "http://[fe80::1%25en0]/", // alphanum zone identifier
@@ -707,6 +717,24 @@ var parseRequestURLTests = []struct {
        // RFC 6874.
        {"http://[fe80::1%en0]/", false},
        {"http://[fe80::1%en0]:8080/", false},
+
+       // Tests exercising RFC 3986 compliance
+       {"https://[1:2:3:4:5:6:7:8]", true},             // full IPv6 address
+       {"https://[2001:db8::a:b:c:d]", true},           // compressed IPv6 address
+       {"https://[fe80::1%25eth0]", true},              // link-local address with zone ID (interface name)
+       {"https://[fe80::abc:def%254]", true},           // link-local address with zone ID (interface index)
+       {"https://[2001:db8::1]/path", true},            // compressed IPv6 address with path
+       {"https://[fe80::1%25eth0]/path?query=1", true}, // link-local with zone, path, and query
+
+       {"https://[::ffff:192.0.2.1]", false},
+       {"https://[:1] ", false},
+       {"https://[1:2:3:4:5:6:7:8:9]", false},
+       {"https://[1::1::1]", false},
+       {"https://[1:2:3:]", false},
+       {"https://[ffff::127.0.0.4000]", false},
+       {"https://[0:0::test.com]:80", false},
+       {"https://[2001:db8::test.com]", false},
+       {"https://[test.com]", false},
 }
 
 func TestParseRequestURI(t *testing.T) {
@@ -1643,6 +1671,17 @@ func TestParseErrors(t *testing.T) {
                {"cache_object:foo", true},
                {"cache_object:foo/bar", true},
                {"cache_object/:foo/bar", false},
+
+               {"http://[192.168.0.1]/", true},             // IPv4 in brackets
+               {"http://[192.168.0.1]:8080/", true},        // IPv4 in brackets with port
+               {"http://[::ffff:192.168.0.1]/", true},      // IPv4-mapped IPv6 in brackets
+               {"http://[::ffff:192.168.0.1]:8080/", true}, // IPv4-mapped IPv6 in brackets with port
+               {"http://[::ffff:c0a8:1]/", true},           // IPv4-mapped IPv6 in brackets (hex)
+               {"http://[not-an-ip]/", true},               // invalid IP string in brackets
+               {"http://[fe80::1%foo]/", true},             // invalid zone format in brackets
+               {"http://[fe80::1", true},                   // missing closing bracket
+               {"http://fe80::1]/", true},                  // missing opening bracket
+               {"http://[test.com]/", true},                // domain name in brackets
        }
        for _, tt := range tests {
                u, err := Parse(tt.in)