mirror of
https://github.com/golang/go.git
synced 2025-12-08 06:10:04 +00:00
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 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-review.googlesource.com/c/go/+/709857 TryBot-Bypass: Michael Pratt <mpratt@google.com> Reviewed-by: Carlos Amedee <carlos@golang.org> Auto-Submit: Michael Pratt <mpratt@google.com>
This commit is contained in:
parent
7dd54e1fd7
commit
f6f4e8b3ef
3 changed files with 77 additions and 14 deletions
|
|
@ -237,7 +237,6 @@ var depsRules = `
|
||||||
internal/types/errors,
|
internal/types/errors,
|
||||||
mime/quotedprintable,
|
mime/quotedprintable,
|
||||||
net/internal/socktest,
|
net/internal/socktest,
|
||||||
net/url,
|
|
||||||
runtime/trace,
|
runtime/trace,
|
||||||
text/scanner,
|
text/scanner,
|
||||||
text/tabwriter;
|
text/tabwriter;
|
||||||
|
|
@ -300,6 +299,12 @@ var depsRules = `
|
||||||
FMT
|
FMT
|
||||||
< text/template/parse;
|
< text/template/parse;
|
||||||
|
|
||||||
|
internal/bytealg, internal/itoa, math/bits, slices, strconv, unique
|
||||||
|
< net/netip;
|
||||||
|
|
||||||
|
FMT, net/netip
|
||||||
|
< net/url;
|
||||||
|
|
||||||
net/url, text/template/parse
|
net/url, text/template/parse
|
||||||
< text/template
|
< text/template
|
||||||
< internal/lazytemplate;
|
< internal/lazytemplate;
|
||||||
|
|
@ -414,9 +419,6 @@ var depsRules = `
|
||||||
< golang.org/x/net/dns/dnsmessage,
|
< golang.org/x/net/dns/dnsmessage,
|
||||||
golang.org/x/net/lif;
|
golang.org/x/net/lif;
|
||||||
|
|
||||||
internal/bytealg, internal/itoa, math/bits, slices, strconv, unique
|
|
||||||
< net/netip;
|
|
||||||
|
|
||||||
os, net/netip
|
os, net/netip
|
||||||
< internal/routebsd;
|
< internal/routebsd;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"maps"
|
"maps"
|
||||||
|
"net/netip"
|
||||||
"path"
|
"path"
|
||||||
"slices"
|
"slices"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
@ -642,40 +643,61 @@ func parseAuthority(authority string) (user *Userinfo, host string, err error) {
|
||||||
// parseHost parses host as an authority without user
|
// parseHost parses host as an authority without user
|
||||||
// information. That is, as host[:port].
|
// information. That is, as host[:port].
|
||||||
func parseHost(host string) (string, error) {
|
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.
|
// Parse an IP-Literal in RFC 3986 and RFC 6874.
|
||||||
// E.g., "[fe80::1]", "[fe80::1%25en0]", "[fe80::1]:80".
|
// E.g., "[fe80::1]", "[fe80::1%25en0]", "[fe80::1]:80".
|
||||||
i := strings.LastIndex(host, "]")
|
closeBracketIdx := strings.LastIndex(host, "]")
|
||||||
if i < 0 {
|
if closeBracketIdx < 0 {
|
||||||
return "", errors.New("missing ']' in host")
|
return "", errors.New("missing ']' in host")
|
||||||
}
|
}
|
||||||
colonPort := host[i+1:]
|
|
||||||
|
colonPort := host[closeBracketIdx+1:]
|
||||||
if !validOptionalPort(colonPort) {
|
if !validOptionalPort(colonPort) {
|
||||||
return "", fmt.Errorf("invalid port %q after host", 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
|
// RFC 6874 defines that %25 (%-encoded percent) introduces
|
||||||
// the zone identifier, and the zone identifier can use basically
|
// the zone identifier, and the zone identifier can use basically
|
||||||
// any %-encoding it likes. That's different from the host, which
|
// any %-encoding it likes. That's different from the host, which
|
||||||
// can only %-encode non-ASCII bytes.
|
// can only %-encode non-ASCII bytes.
|
||||||
// We do impose some restrictions on the zone, to avoid stupidity
|
// We do impose some restrictions on the zone, to avoid stupidity
|
||||||
// like newlines.
|
// like newlines.
|
||||||
zone := strings.Index(host[:i], "%25")
|
zoneIdx := strings.Index(hostname, "%25")
|
||||||
if zone >= 0 {
|
if zoneIdx >= 0 {
|
||||||
host1, err := unescape(host[:zone], encodeHost)
|
hostPart, err := unescape(hostname[:zoneIdx], encodeHost)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
host2, err := unescape(host[zone:i], encodeZone)
|
zonePart, err := unescape(hostname[zoneIdx:], encodeZone)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
host3, err := unescape(host[i:], encodeHost)
|
unescapedHostname = hostPart + zonePart
|
||||||
|
} else {
|
||||||
|
var err error
|
||||||
|
unescapedHostname, err = unescape(hostname, encodeHost)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
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 {
|
} else if i := strings.LastIndex(host, ":"); i != -1 {
|
||||||
colonPort := host[i:]
|
colonPort := host[i:]
|
||||||
if !validOptionalPort(colonPort) {
|
if !validOptionalPort(colonPort) {
|
||||||
|
|
|
||||||
|
|
@ -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
|
// host subcomponent; IPv6 address with zone identifier in RFC 6874
|
||||||
{
|
{
|
||||||
"http://[fe80::1%25en0]/", // alphanum zone identifier
|
"http://[fe80::1%25en0]/", // alphanum zone identifier
|
||||||
|
|
@ -707,6 +717,24 @@ var parseRequestURLTests = []struct {
|
||||||
// RFC 6874.
|
// RFC 6874.
|
||||||
{"http://[fe80::1%en0]/", false},
|
{"http://[fe80::1%en0]/", false},
|
||||||
{"http://[fe80::1%en0]:8080/", 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) {
|
func TestParseRequestURI(t *testing.T) {
|
||||||
|
|
@ -1643,6 +1671,17 @@ func TestParseErrors(t *testing.T) {
|
||||||
{"cache_object:foo", true},
|
{"cache_object:foo", true},
|
||||||
{"cache_object:foo/bar", true},
|
{"cache_object:foo/bar", true},
|
||||||
{"cache_object/:foo/bar", false},
|
{"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 {
|
for _, tt := range tests {
|
||||||
u, err := Parse(tt.in)
|
u, err := Parse(tt.in)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue