🐞 IPv4 regex correctly identifies IPv4 addresses.

My regex was broken, and it didn't correctly identify the following
hostnames lookups (real, actual hostname lookups):

- funprdmongo30-03.10.1.4.133.nip.io.
- olvm-engine-01.132.145.157.105.nip.io.
- wt32-ETh01-03.172.26.131.29.NIp.IO.

The problem? The leading zero. `funprdmongo30-03.10.1.4.133.nip.io.`
should've resolved to `10.1.4.133`, but the regex incorrectly matched it
as `03.10.1.4`, which isn't a valid IPv4 address according to Golang's
net library, so the hostname DNS resolution was incorrectly treated as
not having an A record.

With this commit, the regex is now fixed, though I must admit I have a
certain trepidation tinkering with the regex at the very core of
sslip.io.

- refactored a portion of the `NameToA()` function to another function,
  `String2IPv4()`. `NameToA()` is now more readable.
- added simple fuzz test, but it wouldn't have discovered the problem.
- added a helper method for the fuzz test, `RandomIPv4String()`, and
  bumped `math/rand` to `math/rand/v2` along the way.
This commit is contained in:
Brian Cunnie
2025-08-30 18:25:09 -07:00
parent f694867b85
commit 432e836c15
3 changed files with 67 additions and 34 deletions

View File

@@ -2,8 +2,10 @@ package testhelper
import (
"encoding/binary"
"math/rand"
"math/rand/v2"
"net"
"strconv"
"strings"
)
// RandomIPv6Address is used for fuzz testing
@@ -32,11 +34,25 @@ func RandomIPv6Address() net.IP {
func Random8ByteString() string {
var randomString []byte
// 71 == ascii 'G', +32 (103) == ascii 'g'
randomString = append(randomString, byte(71+32*rand.Intn(2)+rand.Intn(20)))
randomString = append(randomString, byte(71+32*rand.IntN(2)+rand.IntN(20)))
for range 6 {
// 65 == ascii 'A', +32 (96) == ascii 'a', there are 26 letters in the alphabet. Mix upper case, too.
randomString = append(randomString, byte(65+32*rand.Intn(2)+rand.Intn(26)))
randomString = append(randomString, byte(65+32*rand.IntN(2)+rand.IntN(26)))
}
randomString = append(randomString, byte(71+32*rand.Intn(2)+rand.Intn(20)))
randomString = append(randomString, byte(71+32*rand.IntN(2)+rand.IntN(20)))
return string(randomString)
}
// returns a random IPv4 address, sometimes with dots, sometimes with dashes
func RandomIPv4String() string {
separator := "."
if rand.IntN(2) == 1 {
separator = "-"
}
ipString := ""
for i := 0; i < 4; i++ {
ipString += strconv.Itoa(int(rand.IntN(256))) + separator
}
ipString = strings.TrimSuffix(ipString, separator)
return ipString
}

View File

@@ -84,8 +84,8 @@ type DomainCustomizations map[string]DomainCustomization
// Some of these are global because they are, in essence, constants which
// I don't want to waste time recreating with every function call.
var (
ipv4REDots = regexp.MustCompile(`(^|[.-])(((25[0-5]|(2[0-4]|1?\d)?\d)\.){3}(25[0-5]|(2[0-4]|1?\d)?\d))($|[.-])`)
ipv4REDashes = regexp.MustCompile(`(^|[.-])(((25[0-5]|(2[0-4]|1?\d)?\d)-){3}(25[0-5]|(2[0-4]|1?\d)?\d))($|[.-])`)
ipv4REDots = regexp.MustCompile(`(^|[.-])(((25[0-5]|(2[0-4]|1\d|[1-9])?\d)\.){3}(25[0-5]|(2[0-4]|1\d|[1-9])?\d))($|[.-])`)
ipv4REDashes = regexp.MustCompile(`(^|[.-])(((25[0-5]|(2[0-4]|1\d|[1-9])?\d)\-){3}(25[0-5]|(2[0-4]|1\d|[1-9])?\d))($|[.-])`)
ipv4REHex = regexp.MustCompile(`(^|\.)([[:xdigit:]]{8})($|\.)`) // no dash separators, only dots
// https://stackoverflow.com/questions/53497/regular-expression-that-matches-valid-ipv6-addresses
ipv6RE = regexp.MustCompile(`(^|[.-])(([[:xdigit:]]{1,4}-){7}[[:xdigit:]]{1,4}|([[:xdigit:]]{1,4}-){1,7}-|([[:xdigit:]]{1,4}-){1,6}-[[:xdigit:]]{1,4}|([[:xdigit:]]{1,4}-){1,5}(-[[:xdigit:]]{1,4}){1,2}|([[:xdigit:]]{1,4}-){1,4}(-[[:xdigit:]]{1,4}){1,3}|([[:xdigit:]]{1,4}-){1,3}(-[[:xdigit:]]{1,4}){1,4}|([[:xdigit:]]{1,4}-){1,2}(-[[:xdigit:]]{1,4}){1,5}|[[:xdigit:]]{1,4}-((-[[:xdigit:]]{1,4}){1,6})|-((-[[:xdigit:]]{1,4}){1,7}|-)|fe80-(-[[:xdigit:]]{0,4}){0,4}%[\da-zA-Z]+|--(ffff(-0{1,4})?-)?((25[0-5]|(2[0-4]|1?\d)?\d)\.){3}(25[0-5]|(2[0-4]|1?\d)?\d)|([[:xdigit:]]{1,4}-){1,4}-((25[0-5]|(2[0-4]|1?\d)?\d)\.){3}(25[0-5]|(2[0-4]|1?\d)?\d))($|[.-])`)
@@ -781,56 +781,53 @@ func buildNSRecords(b *dnsmessage.Builder, name dnsmessage.Name, nameServers []d
// possibly more if it's a customized record (e.g. the addresses of "ns.sslip.io.")
// if "allowPublicIPs" is false, and the IP address is public, it'll return an empty array
func NameToA(fqdnString string, allowPublicIPs bool) []dnsmessage.AResource {
fqdn := []byte(fqdnString)
// is it a customized A record? If so, return early
if domain, ok := Customizations[strings.ToLower(fqdnString)]; ok && len(domain.A) > 0 {
return domain.A
}
ipv4 := String2IPv4(fqdnString)
if ipv4 == nil {
return []dnsmessage.AResource{}
}
if (!allowPublicIPs) && IsPublic(ipv4) {
return []dnsmessage.AResource{}
}
return []dnsmessage.AResource{
{A: [4]byte{ipv4[12], ipv4[13], ipv4[14], ipv4[15]}},
}
}
func String2IPv4(fqdn string) net.IP {
for _, ipv4RE := range []*regexp.Regexp{ipv4REDashes, ipv4REDots} {
if ipv4RE.Match(fqdn) {
match := string(ipv4RE.FindSubmatch(fqdn)[2])
if ipv4RE.MatchString(fqdn) {
match := string(ipv4RE.FindStringSubmatch(fqdn)[2])
match = strings.Replace(match, "-", ".", -1)
ipv4address := net.ParseIP(match).To4()
ipv4address := net.ParseIP(match)
// We shouldn't reach here because `match` should always be valid, but we're not optimists
if ipv4address == nil {
// e.g. "ubuntu20.04.235.249.181-notify.sslip.io." <- the leading zero is the problem
// funprdmongo30-03.10.1.4.133.nip.io.
// olvm-engine-01.132.145.157.105.nip.io.
// wt32-ETh01-03.172.26.131.29.NIp.IO.
log.Printf("----> Should be valid A but isn't: %s\n", fqdn) // TODO: delete this
return []dnsmessage.AResource{}
}
if (!allowPublicIPs) && IsPublic(ipv4address) {
return []dnsmessage.AResource{}
}
return []dnsmessage.AResource{
{A: [4]byte{ipv4address[0], ipv4address[1], ipv4address[2], ipv4address[3]}},
fmt.Printf("----> match: %s, Should be valid A but isn't: %s\n", match, fqdn) // TODO: delete this
} else {
return ipv4address
}
}
}
if match := ipv4REHex.FindSubmatch(fqdn); match != nil {
if match := ipv4REHex.FindStringSubmatch(fqdn); match != nil {
hexes := match[2] // strip out leading & trailing "." by using only the 2nd capture group, e.g. "7f000001"
ipBytes := make([]byte, 4)
_, err := hex.Decode(ipBytes, []byte(hexes))
if err != nil || len(ipBytes) != 4 {
return []dnsmessage.AResource{}
return nil
}
ipv4address := net.IPv4(ipBytes[0], ipBytes[1], ipBytes[2], ipBytes[3])
if ipv4address == nil {
return []dnsmessage.AResource{}
return nil
}
ipv4address = ipv4address.To4()
if ipv4address == nil {
return []dnsmessage.AResource{}
}
if (!allowPublicIPs) && IsPublic(ipv4address) {
return []dnsmessage.AResource{}
}
return []dnsmessage.AResource{
{A: [4]byte{ipv4address[0], ipv4address[1], ipv4address[2], ipv4address[3]}},
return nil
}
return ipv4address
}
return []dnsmessage.AResource{}
return nil
}
// NameToAAAA returns an []AAAAResource that matched the hostname; it returns an array of zero-or-one records

View File

@@ -609,4 +609,24 @@ var _ = Describe("Xip", func() {
Entry("Private internets", net.ParseIP("fc00::"), false),
)
})
Describe("String2IPv4()", func() {
DescribeTable("when determining if a name is IPv4",
func(fqdn string, expectedPublic net.IP) {
Expect(xip.String2IPv4(fqdn)).To(Equal(expectedPublic))
},
Entry("ubuntu20.04.52.0.56.137-notify.sslip.io.", "ubuntu20.04.52.0.56.137.181-notify.sslip.io.", net.ParseIP("52.0.56.137")),
Entry("funprdmongo30-03.10.1.4.133.nip.io.", "funprdmongo30-03.10.1.4.133.nip.io.", net.ParseIP("10.1.4.133")),
Entry("olvm-engine-01.132.145.157.105.nip.io.", "olvm-engine-01.132.145.157.105.nip.io.", net.ParseIP("132.145.157.105")),
Entry("wt32-ETh01-03.172.26.131.29.NIp.IO.", "wt32-ETh01-03.172.26.131.29.NIp.IO.", net.ParseIP("172.26.131.29")),
Entry("prepend-zeros.01.23.45.67.89.", "prepend-zeros.01.23.45.67.89.", net.ParseIP("23.45.67.89")),
Entry("moar-zeros.23.45.67.09.", "prepend-zeros.23.45.67.09.", nil),
)
When("we're fuzz testing", func() {
for i := 0; i < 1000; i++ {
It("correctly identifies the IPv4 address", func() {
Expect(xip.String2IPv4(testhelper.RandomIPv4String())).ToNot(BeNil())
})
}
})
})
})