Skip to content

Commit 9fd3ac8

Browse files
ethanalee-workgopherbot
authored andcommitted
[release-branch.go1.25] 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 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>
1 parent 5d7a787 commit 9fd3ac8

File tree

3 files changed

+77
-14
lines changed

3 files changed

+77
-14
lines changed

src/go/build/deps_test.go

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -235,7 +235,6 @@ var depsRules = `
235235
internal/types/errors,
236236
mime/quotedprintable,
237237
net/internal/socktest,
238-
net/url,
239238
runtime/trace,
240239
text/scanner,
241240
text/tabwriter;
@@ -298,6 +297,12 @@ var depsRules = `
298297
FMT
299298
< text/template/parse;
300299
300+
internal/bytealg, internal/itoa, math/bits, slices, strconv, unique
301+
< net/netip;
302+
303+
FMT, net/netip
304+
< net/url;
305+
301306
net/url, text/template/parse
302307
< text/template
303308
< internal/lazytemplate;
@@ -412,9 +417,6 @@ var depsRules = `
412417
< golang.org/x/net/dns/dnsmessage,
413418
golang.org/x/net/lif;
414419
415-
internal/bytealg, internal/itoa, math/bits, slices, strconv, unique
416-
< net/netip;
417-
418420
os, net/netip
419421
< internal/routebsd;
420422

src/net/url/url.go

Lines changed: 32 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import (
1616
"errors"
1717
"fmt"
1818
"maps"
19+
"net/netip"
1920
"path"
2021
"slices"
2122
"strconv"
@@ -626,40 +627,61 @@ func parseAuthority(authority string) (user *Userinfo, host string, err error) {
626627
// parseHost parses host as an authority without user
627628
// information. That is, as host[:port].
628629
func parseHost(host string) (string, error) {
629-
if strings.HasPrefix(host, "[") {
630+
if openBracketIdx := strings.LastIndex(host, "["); openBracketIdx != -1 {
630631
// Parse an IP-Literal in RFC 3986 and RFC 6874.
631632
// E.g., "[fe80::1]", "[fe80::1%25en0]", "[fe80::1]:80".
632-
i := strings.LastIndex(host, "]")
633-
if i < 0 {
633+
closeBracketIdx := strings.LastIndex(host, "]")
634+
if closeBracketIdx < 0 {
634635
return "", errors.New("missing ']' in host")
635636
}
636-
colonPort := host[i+1:]
637+
638+
colonPort := host[closeBracketIdx+1:]
637639
if !validOptionalPort(colonPort) {
638640
return "", fmt.Errorf("invalid port %q after host", colonPort)
639641
}
642+
unescapedColonPort, err := unescape(colonPort, encodeHost)
643+
if err != nil {
644+
return "", err
645+
}
640646

647+
hostname := host[openBracketIdx+1 : closeBracketIdx]
648+
var unescapedHostname string
641649
// RFC 6874 defines that %25 (%-encoded percent) introduces
642650
// the zone identifier, and the zone identifier can use basically
643651
// any %-encoding it likes. That's different from the host, which
644652
// can only %-encode non-ASCII bytes.
645653
// We do impose some restrictions on the zone, to avoid stupidity
646654
// like newlines.
647-
zone := strings.Index(host[:i], "%25")
648-
if zone >= 0 {
649-
host1, err := unescape(host[:zone], encodeHost)
655+
zoneIdx := strings.Index(hostname, "%25")
656+
if zoneIdx >= 0 {
657+
hostPart, err := unescape(hostname[:zoneIdx], encodeHost)
650658
if err != nil {
651659
return "", err
652660
}
653-
host2, err := unescape(host[zone:i], encodeZone)
661+
zonePart, err := unescape(hostname[zoneIdx:], encodeZone)
654662
if err != nil {
655663
return "", err
656664
}
657-
host3, err := unescape(host[i:], encodeHost)
665+
unescapedHostname = hostPart + zonePart
666+
} else {
667+
var err error
668+
unescapedHostname, err = unescape(hostname, encodeHost)
658669
if err != nil {
659670
return "", err
660671
}
661-
return host1 + host2 + host3, nil
662672
}
673+
674+
// Per RFC 3986, only a host identified by a valid
675+
// IPv6 address can be enclosed by square brackets.
676+
// This excludes any IPv4 or IPv4-mapped addresses.
677+
addr, err := netip.ParseAddr(unescapedHostname)
678+
if err != nil {
679+
return "", fmt.Errorf("invalid host: %w", err)
680+
}
681+
if addr.Is4() || addr.Is4In6() {
682+
return "", errors.New("invalid IPv6 host")
683+
}
684+
return "[" + unescapedHostname + "]" + unescapedColonPort, nil
663685
} else if i := strings.LastIndex(host, ":"); i != -1 {
664686
colonPort := host[i:]
665687
if !validOptionalPort(colonPort) {

src/net/url/url_test.go

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -383,6 +383,16 @@ var urltests = []URLTest{
383383
},
384384
"",
385385
},
386+
// valid IPv6 host with port and path
387+
{
388+
"https://[2001:db8::1]:8443/test/path",
389+
&URL{
390+
Scheme: "https",
391+
Host: "[2001:db8::1]:8443",
392+
Path: "/test/path",
393+
},
394+
"",
395+
},
386396
// host subcomponent; IPv6 address with zone identifier in RFC 6874
387397
{
388398
"http://[fe80::1%25en0]/", // alphanum zone identifier
@@ -707,6 +717,24 @@ var parseRequestURLTests = []struct {
707717
// RFC 6874.
708718
{"http://[fe80::1%en0]/", false},
709719
{"http://[fe80::1%en0]:8080/", false},
720+
721+
// Tests exercising RFC 3986 compliance
722+
{"https://[1:2:3:4:5:6:7:8]", true}, // full IPv6 address
723+
{"https://[2001:db8::a:b:c:d]", true}, // compressed IPv6 address
724+
{"https://[fe80::1%25eth0]", true}, // link-local address with zone ID (interface name)
725+
{"https://[fe80::abc:def%254]", true}, // link-local address with zone ID (interface index)
726+
{"https://[2001:db8::1]/path", true}, // compressed IPv6 address with path
727+
{"https://[fe80::1%25eth0]/path?query=1", true}, // link-local with zone, path, and query
728+
729+
{"https://[::ffff:192.0.2.1]", false},
730+
{"https://[:1] ", false},
731+
{"https://[1:2:3:4:5:6:7:8:9]", false},
732+
{"https://[1::1::1]", false},
733+
{"https://[1:2:3:]", false},
734+
{"https://[ffff::127.0.0.4000]", false},
735+
{"https://[0:0::test.com]:80", false},
736+
{"https://[2001:db8::test.com]", false},
737+
{"https://[test.com]", false},
710738
}
711739

712740
func TestParseRequestURI(t *testing.T) {
@@ -1643,6 +1671,17 @@ func TestParseErrors(t *testing.T) {
16431671
{"cache_object:foo", true},
16441672
{"cache_object:foo/bar", true},
16451673
{"cache_object/:foo/bar", false},
1674+
1675+
{"http://[192.168.0.1]/", true}, // IPv4 in brackets
1676+
{"http://[192.168.0.1]:8080/", true}, // IPv4 in brackets with port
1677+
{"http://[::ffff:192.168.0.1]/", true}, // IPv4-mapped IPv6 in brackets
1678+
{"http://[::ffff:192.168.0.1]:8080/", true}, // IPv4-mapped IPv6 in brackets with port
1679+
{"http://[::ffff:c0a8:1]/", true}, // IPv4-mapped IPv6 in brackets (hex)
1680+
{"http://[not-an-ip]/", true}, // invalid IP string in brackets
1681+
{"http://[fe80::1%foo]/", true}, // invalid zone format in brackets
1682+
{"http://[fe80::1", true}, // missing closing bracket
1683+
{"http://fe80::1]/", true}, // missing opening bracket
1684+
{"http://[test.com]/", true}, // domain name in brackets
16461685
}
16471686
for _, tt := range tests {
16481687
u, err := Parse(tt.in)

0 commit comments

Comments
 (0)