From d6d2f7bf76718f1db05461cd912ae5e30d7b77ea Mon Sep 17 00:00:00 2001
From: Ethan Lee <ethanalee@google.com>
Date: Fri, 29 Aug 2025 17:35:55 +0000
Subject: [PATCH] [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>

CVE: CVE-2025-47912

Upstream-Status: Backport [https://github.com/golang/go/commit/d6d2f7bf76718f1db05461cd912ae5e30d7b77ea]

Signed-off-by: Archana Polampalli <archana.polampalli@windriver.com>
---
 src/go/build/deps_test.go |  9 ++++++---
 src/net/url/url.go        | 42 +++++++++++++++++++++++++++++----------
 src/net/url/url_test.go   | 39 ++++++++++++++++++++++++++++++++++++
 3 files changed, 77 insertions(+), 13 deletions(-)

diff --git a/src/go/build/deps_test.go b/src/go/build/deps_test.go
index 7ce8d34..9f2663f 100644
--- a/src/go/build/deps_test.go
+++ b/src/go/build/deps_test.go
@@ -209,7 +209,6 @@ var depsRules = `
	  internal/types/errors,
	  mime/quotedprintable,
	  net/internal/socktest,
-	  net/url,
	  runtime/trace,
	  text/scanner,
	  text/tabwriter;
@@ -252,6 +251,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;
@@ -367,8 +372,6 @@ var depsRules = `
	internal/godebug
	< internal/intern;

-	internal/bytealg, internal/intern, internal/itoa, math/bits, sort, strconv
-	< net/netip;

	# net is unavoidable when doing any networking,
	# so large dependencies must be kept out.
diff --git a/src/net/url/url.go b/src/net/url/url.go
index f362958..d2ae032 100644
--- a/src/net/url/url.go
+++ b/src/net/url/url.go
@@ -13,6 +13,7 @@ package url
 import (
	"errors"
	"fmt"
+	"net/netip"
	"path"
	"sort"
	"strconv"
@@ -621,40 +622,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 4aa20bb..fef236e 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) {
@@ -1635,6 +1663,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)
--
2.40.0
