crypto/x509: use netip.ParseAddr in VerifyHostname for IPv6 zones

net.ParseIP returns nil for zone-scoped IPv6 addresses (e.g.
fe80::1%eth0), causing VerifyHostname to fall through to DNS name
matching instead of IP matching. Switch to netip.ParseAddr which
handles zone IDs correctly; AsSlice() returns the zone-free byte
representation for comparison against certificate SANs.

Fixes #79719

Change-Id: I47fa43838273d225b8e70eb6c68d5d5466b75e5f
Reviewed-on: https://go-review.googlesource.com/c/go/+/785280
Reviewed-by: Sravani Bhamidipaty <sravanibhamidipaty@gmail.com>
Reviewed-by: Roland Shoemaker <roland@golang.org>
Auto-Submit: Daniel McCarney <daniel@binaryparadox.net>
Reviewed-by: Daniel McCarney <daniel@binaryparadox.net>
LUCI-TryBot-Result: golang-scoped@luci-project-accounts.iam.gserviceaccount.com <golang-scoped@luci-project-accounts.iam.gserviceaccount.com>
Reviewed-by: Mark Freeman <markfreeman@google.com>
diff --git a/src/crypto/x509/verify.go b/src/crypto/x509/verify.go
index f97393f..cba05a1 100644
--- a/src/crypto/x509/verify.go
+++ b/src/crypto/x509/verify.go
@@ -13,6 +13,7 @@
 	"iter"
 	"maps"
 	"net"
+	"net/netip"
 	"runtime"
 	"slices"
 	"strings"
@@ -944,15 +945,17 @@
 	if len(h) >= 3 && h[0] == '[' && h[len(h)-1] == ']' {
 		candidateIP = h[1 : len(h)-1]
 	}
-	if ip := net.ParseIP(candidateIP); ip != nil {
+	// We use netip.ParseAddr() to allow IPv6 scoped addresses.
+	if addr, err := netip.ParseAddr(candidateIP); err == nil {
 		// We only match IP addresses against IP SANs.
 		// See RFC 6125, Appendix B.2.
+		ip := net.IP(addr.AsSlice())
 		for _, candidate := range c.IPAddresses {
 			if ip.Equal(candidate) {
 				return nil
 			}
 		}
-		return HostnameError{c, candidateIP}
+		return HostnameError{c, ip.String()}
 	}
 
 	candidateName := toLowerCaseASCII(h) // Save allocations inside the loop.
diff --git a/src/crypto/x509/verify_test.go b/src/crypto/x509/verify_test.go
index 1d3e845..d32d815 100644
--- a/src/crypto/x509/verify_test.go
+++ b/src/crypto/x509/verify_test.go
@@ -3183,3 +3183,39 @@
 	}
 	return dsaDER
 }
+
+func TestVerifyHostnameIPAddresses(t *testing.T) {
+	cert := &Certificate{
+		IPAddresses: []net.IP{
+			net.ParseIP("192.0.2.1"),
+			net.ParseIP("fe80::1"),
+		},
+	}
+
+	tests := []struct {
+		name  string
+		host  string
+		match bool
+	}{
+		{name: "IPv4", host: "192.0.2.1", match: true},
+		{name: "IPv4 bracketed", host: "[192.0.2.1]", match: true},
+		{name: "IPv4 mismatch", host: "192.0.2.2"},
+		{name: "IPv6", host: "fe80::1", match: true},
+		{name: "IPv6 bracketed", host: "[fe80::1]", match: true},
+		{name: "IPv6 with zone", host: "fe80::1%eth0", match: true},
+		{name: "IPv6 with zone bracketed", host: "[fe80::1%eth0]", match: true},
+		{name: "IPv6 mismatch", host: "fe80::2"},
+		{name: "IPv6 with zone mismatch", host: "fe80::2%eth0"},
+	}
+	for _, tc := range tests {
+		t.Run(tc.name, func(t *testing.T) {
+			err := cert.VerifyHostname(tc.host)
+			if tc.match && err != nil {
+				t.Errorf("VerifyHostname(%q) = %v, want nil", tc.host, err)
+			}
+			if !tc.match && err == nil {
+				t.Errorf("VerifyHostname(%q) = nil, want error", tc.host)
+			}
+		})
+	}
+}