aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorEthan Lee <ethanalee@google.com>2025-08-29 17:35:55 +0000
committerGopher Robot <gobot@golang.org>2025-10-07 11:00:42 -0700
commitd6d2f7bf76718f1db05461cd912ae5e30d7b77ea (patch)
tree462bc7b89c448d6de07fea0677b6b7072166b7cd
parenta402f4ad285514f5f3db90516d72047d591b307a (diff)
downloadgo-d6d2f7bf76718f1db05461cd912ae5e30d7b77ea.tar.xz
[release-branch.go1.24] net/url: enforce stricter parsing of bracketed IPv6 hostnames
- 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 Fixes #75678 Fixes #75712 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/+/2968 Reviewed-by: Nicholas Husin <husin@google.com> Reviewed-on: https://go-review.googlesource.com/c/go/+/709838 TryBot-Bypass: Michael Pratt <mpratt@google.com> Reviewed-by: Carlos Amedee <carlos@golang.org> Auto-Submit: Michael Pratt <mpratt@google.com>
-rw-r--r--src/go/build/deps_test.go10
-rw-r--r--src/net/url/url.go42
-rw-r--r--src/net/url/url_test.go39
3 files changed, 77 insertions, 14 deletions
diff --git a/src/go/build/deps_test.go b/src/go/build/deps_test.go
index e3e01077c1..5694e435f6 100644
--- a/src/go/build/deps_test.go
+++ b/src/go/build/deps_test.go
@@ -233,7 +233,6 @@ var depsRules = `
internal/types/errors,
mime/quotedprintable,
net/internal/socktest,
- net/url,
runtime/trace,
text/scanner,
text/tabwriter;
@@ -276,6 +275,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;
@@ -391,9 +396,6 @@ var depsRules = `
golang.org/x/net/lif,
golang.org/x/net/route;
- internal/bytealg, internal/itoa, math/bits, slices, strconv, unique
- < net/netip;
-
# net is unavoidable when doing any networking,
# so large dependencies must be kept out.
# This is a long-looking list but most of these
diff --git a/src/net/url/url.go b/src/net/url/url.go
index 8a8de1c6a8..c686239a71 100644
--- a/src/net/url/url.go
+++ b/src/net/url/url.go
@@ -14,6 +14,7 @@ import (
"errors"
"fmt"
"maps"
+ "net/netip"
"path"
"slices"
"strconv"
@@ -623,40 +624,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) {
diff --git a/src/net/url/url_test.go b/src/net/url/url_test.go
index 16e08b63c6..32065583f2 100644
--- a/src/net/url/url_test.go
+++ b/src/net/url/url_test.go
@@ -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)