Validate IPv6 addresses in urls (#2079)

This commit is contained in:
Erik Dubbelboer
2025-10-05 08:10:52 +08:00
committed by GitHub
parent 75dcdb8bba
commit af41f54adb
4 changed files with 310 additions and 0 deletions
+195
View File
@@ -0,0 +1,195 @@
package fasthttp
import (
"bytes"
"errors"
)
var (
errInvalidIPv6Host = errors.New("invalid IPv6 host")
errInvalidIPv6Zone = errors.New("invalid IPv6 zone")
errInvalidIPv6Address = errors.New("invalid IPv6 address")
)
func validateIPv6Literal(host []byte) error {
if len(host) == 0 || host[0] != '[' {
return nil
}
end := bytes.IndexByte(host, ']')
if end < 0 || end == 1 {
return errInvalidIPv6Host
}
addr := host[1:end]
// Optional zone.
if zi := bytes.IndexByte(addr, '%'); zi >= 0 {
if zi == len(addr)-1 {
return errInvalidIPv6Zone
}
addr = addr[:zi]
}
// Must have a colon to be IPv6.
if bytes.IndexByte(addr, ':') < 0 {
return errInvalidIPv6Address
}
// IPv4-embedded?
if bytes.IndexByte(addr, '.') >= 0 {
lastColon := bytes.LastIndexByte(addr, ':')
if lastColon < 0 || lastColon == len(addr)-1 {
return errInvalidIPv6Address
}
ipv4 := addr[lastColon+1:]
if !validIPv4(ipv4) {
return errInvalidIPv6Address
}
head := addr[:lastColon]
seenDoubleAtSplit := lastColon > 0 && addr[lastColon-1] == ':'
if seenDoubleAtSplit {
head = addr[:lastColon-1]
}
hextets, seenDoubleHead, ok := parseIPv6Hextets(head, false)
if !ok {
return errInvalidIPv6Address
}
if seenDoubleHead && seenDoubleAtSplit {
return errInvalidIPv6Address
}
hextets += 2 // IPv4 tail = 2 hextets
seenDouble := seenDoubleHead || seenDoubleAtSplit
// '::' must compress at least one hextet.
if (!seenDouble && hextets != 8) || (seenDouble && hextets >= 8) {
return errInvalidIPv6Address
}
return nil
}
// Pure IPv6
hextets, seenDouble, ok := parseIPv6Hextets(addr, false)
if !ok {
return errInvalidIPv6Address
}
if (!seenDouble && hextets != 8) || (seenDouble && hextets >= 8) {
return errInvalidIPv6Address
}
return nil
}
func parseIPv6Hextets(s []byte, allowTrailingColon bool) (groups int, seenDouble, ok bool) {
n := len(s)
if n == 0 {
return 0, false, true
}
i := 0
justSawDouble := false
for i < n {
if s[i] == ':' {
if i+1 < n && s[i+1] == ':' {
if seenDouble || justSawDouble {
return 0, false, false
}
seenDouble = true
justSawDouble = true
i += 2
if i == n {
break
}
continue
}
if i == 0 {
return 0, false, false
}
if justSawDouble {
return 0, false, false
}
if i == n-1 {
if allowTrailingColon {
break
}
return 0, false, false
}
if !ishex(s[i+1]) {
return 0, false, false
}
i++
continue
}
justSawDouble = false
cnt := 0
for cnt < 4 && i < n && ishex(s[i]) {
i++
cnt++
}
if cnt == 0 {
return 0, false, false
}
groups++
if i < n && s[i] != ':' {
return 0, false, false
}
}
return groups, seenDouble, true
}
// validIPv4 validates a dotted-quad (exactly 4 parts, 0..255) with no leading zeros
// unless the octet is exactly "0".
func validIPv4(s []byte) bool {
parts := 0
i := 0
n := len(s)
for parts < 4 {
if i >= n {
return false
}
start := i
val := 0
digits := 0
for i < n {
c := s[i]
if c < '0' || c > '9' {
break
}
val = val*10 + int(c-'0')
if val > 255 {
return false
}
i++
digits++
if digits > 3 {
return false
}
}
if digits == 0 {
return false
}
// Disallow leading zeros like "00", "01", "001".
// Allowed: exactly "0" or any number that doesn't start with '0'.
if digits > 1 && s[start] == '0' {
return false
}
parts++
if parts == 4 {
return i == n // must consume all input
}
if i >= n || s[i] != '.' {
return false
}
i++ // skip dot
}
return false
}
+85
View File
@@ -0,0 +1,85 @@
package fasthttp
import (
"bytes"
"net"
"testing"
)
// oracleValid replicates the original function's semantics using net.ParseIP:
// - Input must start with '['
// - There must be a closing ']' and a non-empty address between
// - Optional %zone allowed but must not be empty
// - Zone is stripped before checking with net.ParseIP
// - Must contain a ':' to be IPv6 (prevents raw IPv4-in-brackets).
func oracleValid(host []byte) bool {
if len(host) == 0 || host[0] != '[' {
// Original function: non-bracketed hosts return nil (treated as valid/no-op).
return true
}
end := bytes.IndexByte(host, ']')
if end < 0 {
return false
}
addr := host[1:end]
if len(addr) == 0 {
return false
}
// Split off %zone (if present).
if zi := bytes.IndexByte(addr, '%'); zi >= 0 {
// Zone must not be empty.
if zi == len(addr)-1 {
return false
}
addr = addr[:zi]
}
// Must contain ':' to be IPv6.
if bytes.IndexByte(addr, ':') < 0 {
return false
}
// Use net.ParseIP on the de-zoned address (this was the original check).
if ip := net.ParseIP(string(addr)); ip == nil {
return false
}
return true
}
func FuzzValidateIPv6Literal(f *testing.F) {
seeds := [][]byte{
[]byte(""), // non-bracketed => valid (no-op)
[]byte("example.com"), // non-bracketed => valid (no-op)
[]byte("["), // unterminated
[]byte("[]"), // empty
[]byte("[::]"),
[]byte("[::1]"),
[]byte("[2001:db8::1]"),
[]byte("[2001:db8::]"),
[]byte("[::ffff:192.168.0.1]"),
[]byte("[fe80::1%eth0]"),
[]byte("[fe80::1%]"), // empty zone
[]byte("[1234]"), // no colon
[]byte("[2001:db8:zzzz::1]"), // invalid hex
[]byte("[::ffff:256.0.0.1]"), // invalid v4 tail
[]byte("[2001:db8:::1]"), // triple colon
[]byte("[::1]:443"), // trailing port outside ']' is ignored by validator
[]byte("[2001:db8:0:0:0:0:2:1]"),
[]byte("[2001:db8:0:0:0:0:2:1%en0]"),
}
for _, s := range seeds {
f.Add(s)
}
f.Fuzz(func(t *testing.T, host []byte) {
gotErr := validateIPv6Literal(host)
wantValid := oracleValid(host)
if (gotErr == nil) != wantValid {
t.Fatalf("mismatch for %q: validateIPv6Literal err=%v, oracleValid=%v",
b2s(host), gotErr, wantValid)
}
})
}
+3
View File
@@ -431,6 +431,9 @@ func parseHost(host []byte) ([]byte, error) {
if host, err = unescape(host, encodeHost); err != nil {
return nil, err
}
if err = validateIPv6Literal(host); err != nil {
return nil, err
}
return host, nil
}
+27
View File
@@ -104,6 +104,33 @@ func testURIPathEscape(t *testing.T, path, expectedRequestURI string) {
}
}
func TestURIRejectInvalidIPv6(t *testing.T) {
t.Parallel()
for _, raw := range []string{
"http://[0:0::vulndetector.com]:80",
"http://[2001:db8::vulndetector.com]/",
"http://[vulndetector.com]/",
"http://[::ffff:192.0.2.300]/",
} {
var u URI
if err := u.Parse(nil, []byte(raw)); err == nil {
t.Errorf("expected Parse to fail for %q", raw)
}
}
for _, raw := range []string{
"http://[2001:db8::1]/",
"http://[fe80::1%25en0]/",
"http://[::ffff:192.0.2.1]/",
} {
var u URI
if err := u.Parse(nil, []byte(raw)); err != nil {
t.Errorf("unexpected error for %q: %v", raw, err)
}
}
}
func TestURIUpdate(t *testing.T) {
t.Parallel()