Repository: favonia/cloudflare-ddns (go)

Issue:
Ban IPv4-mapped IPv6 addresses in AAAA records
Using IPv4-mapped IPv6 addresses in AAAA records has caused lots of issues and should be seriously warned. The code should be reviewed along with implementing #889. Given that the goal is to detect _public_ IPs, maybe the updater should err out!

Pull request description:
fix(ipnet): reject IPv4-mapped IPv6 addresses used for IPv6
Close #934.

Patch:
diff --git a/internal/ipnet/ipnet.go b/internal/ipnet/ipnet.go
index a657b68..80a1c6f 100644
--- a/internal/ipnet/ipnet.go
+++ b/internal/ipnet/ipnet.go
@@ -20,13 +20,23 @@ const (
 	IP6 Type = 6
 )
 
+// Int returns the version of the IP networks. It is either 4 or 6.
+func (t Type) Int() int {
+	switch t {
+	case IP4, IP6:
+		return int(t)
+	default:
+		return 0
+	}
+}
+
 // Describe returns a human-readable description of the IP network.
 func (t Type) Describe() string {
 	switch t {
 	case IP4, IP6:
 		return fmt.Sprintf("IPv%d", t)
 	default:
-		return fmt.Sprintf("<unrecognized IP version %d>", t)
+		return ""
 	}
 }
 
@@ -42,13 +52,27 @@ func (t Type) RecordType() string {
 	}
 }
 
-// Int returns the version of the IP networks. It is either 4 or 6.
-func (t Type) Int() int {
+// UDPNetwork gives the network name for net.Dial.
+func (t Type) UDPNetwork() string {
 	switch t {
-	case IP4, IP6:
-		return int(t)
+	case IP4:
+		return "udp4"
+	case IP6:
+		return "udp6"
 	default:
-		return 0
+		return ""
+	}
+}
+
+// Matches checks whether an IP belongs to it.
+func (t Type) Matches(ip netip.Addr) bool {
+	switch t {
+	case IP4:
+		return ip.Is4()
+	case IP6:
+		return ip.Is6()
+	default:
+		return false
 	}
 }
 
@@ -62,31 +86,42 @@ func (t Type) NormalizeDetectedIP(ppfmt pp.PP, ip netip.Addr) (netip.Addr, bool)
 		return netip.Addr{}, false
 	}
 
-	if ip.IsUnspecified() {
-		ppfmt.Noticef(
-			pp.EmojiImpossible,
-			`Detected IP address %s is an unspecified %s address`,
-			ip.String(),
-			t.Describe(),
-		)
-		return netip.Addr{}, false
-	}
-
 	switch t {
 	case IP4:
+		if !ip.Is4() && !ip.Is4In6() {
+			ppfmt.Noticef(pp.EmojiError, "Detected IP address %s is not a valid IPv4 address", ip.String())
+			return netip.Addr{}, false
+		}
 		// Turns an IPv4-mapped IPv6 address back to an IPv4 address
 		ip = ip.Unmap()
 
-		if !ip.Is4() {
-			ppfmt.Noticef(pp.EmojiError, "Detected IP address %s is not a valid IPv4 address", ip.String())
+	case IP6:
+		// Turns an IPv4-mapped IPv6 address back to an IPv4 address
+		if !ip.Is6() {
+			ppfmt.Noticef(pp.EmojiError, "Detected IP address %s is not a valid IPv6 address", ip.String())
 			return netip.Addr{}, false
 		}
-	case IP6:
-		// If the address is an IPv4 address, map it back to an IPv6 address.
-		ip = netip.AddrFrom16(ip.As16())
+		if ip.Is4In6() {
+			ppfmt.Noticef(pp.EmojiError, "Detected IP address %s is an IPv4-mapped IPv6 address", ip.String())
+			ppfmt.Hintf(pp.HintIP4MappedIP6Address,
+				"An IPv4-mapped IPv6 address is an IPv4 address in disguise. "+
+					"It cannot be used for routing IPv6 traffic. "+
+					"If you need to use it for DNS, please open an issue at %s",
+				pp.IssueReportingURL)
+			return netip.Addr{}, false
+		}
+
 	default:
-		ppfmt.Noticef(pp.EmojiImpossible,
-			"Unrecognized IP version %d was used; please report this at %s", int(t), pp.IssueReportingURL)
+		return netip.Addr{}, false
+	}
+
+	if ip.IsUnspecified() {
+		ppfmt.Noticef(
+			pp.EmojiImpossible,
+			`Detected IP address %s is an unspecified %s address`,
+			ip.String(),
+			t.Describe(),
+		)
 		return netip.Addr{}, false
 	}
 
@@ -101,30 +136,6 @@ func (t Type) NormalizeDetectedIP(ppfmt pp.PP, ip netip.Addr) (netip.Addr, bool)
 	return ip, true
 }
 
-// UDPNetwork gives the network name for net.Dial.
-func (t Type) UDPNetwork() string {
-	switch t {
-	case IP4:
-		return "udp4"
-	case IP6:
-		return "udp6"
-	default:
-		return ""
-	}
-}
-
-// Matches checks whether an IP belongs to it.
-func (t Type) Matches(ip netip.Addr) bool {
-	switch t {
-	case IP4:
-		return ip.Is4()
-	case IP6:
-		return ip.Is6()
-	default:
-		return false
-	}
-}
-
 // All enumerates [IP4] and then [IP6].
 func All(yield func(Type) bool) {
 	_ = yield(IP4) && yield(IP6)
diff --git a/internal/pp/hint.go b/internal/pp/hint.go
index dd56dd1..fb7d957 100644
--- a/internal/pp/hint.go
+++ b/internal/pp/hint.go
@@ -8,6 +8,7 @@ const (
 	HintUpdateDockerTemplate Hint = iota
 	HintIP4DetectionFails
 	HintIP6DetectionFails
+	HintIP4MappedIP6Address
 	HintDetectionTimeouts
 	HintUpdateTimeouts
 	HintRecordPermission


Test patch:
diff --git a/internal/ipnet/ipnet_test.go b/internal/ipnet/ipnet_test.go
index 9d3998e..0536f75 100644
--- a/internal/ipnet/ipnet_test.go
+++ b/internal/ipnet/ipnet_test.go
@@ -16,6 +16,23 @@ func mustIP(ip string) netip.Addr {
 	return netip.MustParseAddr(ip)
 }
 
+func TestInt(t *testing.T) {
+	t.Parallel()
+	for name, tc := range map[string]struct {
+		input    ipnet.Type
+		expected int
+	}{
+		"4":   {ipnet.IP4, 4},
+		"6":   {ipnet.IP6, 6},
+		"100": {ipnet.Type(100), 0},
+	} {
+		t.Run(name, func(t *testing.T) {
+			t.Parallel()
+			require.Equal(t, tc.expected, tc.input.Int())
+		})
+	}
+}
+
 func TestDescribe(t *testing.T) {
 	t.Parallel()
 	for name, tc := range map[string]struct {
@@ -24,7 +41,7 @@ func TestDescribe(t *testing.T) {
 	}{
 		"4":   {ipnet.IP4, "IPv4"},
 		"6":   {ipnet.IP6, "IPv6"},
-		"100": {ipnet.Type(100), "<unrecognized IP version 100>"},
+		"100": {ipnet.Type(100), ""},
 	} {
 		t.Run(name, func(t *testing.T) {
 			t.Parallel()
@@ -50,19 +67,19 @@ func TestRecordType(t *testing.T) {
 	}
 }
 
-func TestInt(t *testing.T) {
+func TestUDPNetwork(t *testing.T) {
 	t.Parallel()
 	for name, tc := range map[string]struct {
 		input    ipnet.Type
-		expected int
+		expected string
 	}{
-		"4":   {ipnet.IP4, 4},
-		"6":   {ipnet.IP6, 6},
-		"100": {ipnet.Type(100), 0},
+		"4":   {ipnet.IP4, "udp4"},
+		"6":   {ipnet.IP6, "udp6"},
+		"100": {ipnet.Type(100), ""},
 	} {
 		t.Run(name, func(t *testing.T) {
 			t.Parallel()
-			require.Equal(t, tc.expected, tc.input.Int())
+			require.Equal(t, tc.expected, tc.input.UDPNetwork())
 		})
 	}
 }
@@ -86,7 +103,31 @@ func TestNormalizeDetectedIP(t *testing.T) {
 		},
 		"4-::ffff:0a0a:0a0a": {ipnet.IP4, mustIP("::ffff:0a0a:0a0a"), mustIP("10.10.10.10"), true, nil},
 		"6-1::2":             {ipnet.IP6, mustIP("1::2"), mustIP("1::2"), true, nil},
-		"6-10.10.10.10":      {ipnet.IP6, mustIP("10.10.10.10"), mustIP("::ffff:10.10.10.10"), true, nil},
+		"6-10.10.10.10": {
+			ipnet.IP6, mustIP("10.10.10.10"),
+			netip.Addr{},
+			false,
+			func(m *mocks.MockPP) {
+				m.EXPECT().Noticef(pp.EmojiError, "Detected IP address %s is not a valid IPv6 address", "10.10.10.10")
+			},
+		},
+		"6-::ffff:10.10.10.10": {
+			ipnet.IP6, mustIP("::ffff:10.10.10.10"),
+			netip.Addr{},
+			false,
+			func(m *mocks.MockPP) {
+				gomock.InOrder(
+					m.EXPECT().Noticef(pp.EmojiError,
+						"Detected IP address %s is an IPv4-mapped IPv6 address",
+						"::ffff:10.10.10.10"),
+					m.EXPECT().Hintf(pp.HintIP4MappedIP6Address,
+						"An IPv4-mapped IPv6 address is an IPv4 address in disguise. "+
+							"It cannot be used for routing IPv6 traffic. "+
+							"If you need to use it for DNS, please open an issue at %s",
+						pp.IssueReportingURL),
+				)
+			},
+		},
 		"6-invalid": {
 			ipnet.IP6,
 			netip.Addr{},
@@ -100,10 +141,7 @@ func TestNormalizeDetectedIP(t *testing.T) {
 			100, mustIP("10.10.10.10"),
 			netip.Addr{},
 			false,
-			func(m *mocks.MockPP) {
-				m.EXPECT().Noticef(pp.EmojiImpossible,
-					"Unrecognized IP version %d was used; please report this at %s", 100, pp.IssueReportingURL)
-			},
+			nil,
 		},
 		"4-0.0.0.0": {
 			ipnet.IP4, mustIP("0.0.0.0"),
@@ -129,23 +167,6 @@ func TestNormalizeDetectedIP(t *testing.T) {
 	}
 }
 
-func TestUDPNetwork(t *testing.T) {
-	t.Parallel()
-	for name, tc := range map[string]struct {
-		input    ipnet.Type
-		expected string
-	}{
-		"4":   {ipnet.IP4, "udp4"},
-		"6":   {ipnet.IP6, "udp6"},
-		"100": {ipnet.Type(100), ""},
-	} {
-		t.Run(name, func(t *testing.T) {
-			t.Parallel()
-			require.Equal(t, tc.expected, tc.input.UDPNetwork())
-		})
-	}
-}
-
 func TestMatches(t *testing.T) {
 	t.Parallel()
 	for name, tc := range map[string]struct {
diff --git a/internal/provider/protocol/http_test.go b/internal/provider/protocol/http_test.go
index 4449afc..00e1da0 100644
--- a/internal/provider/protocol/http_test.go
+++ b/internal/provider/protocol/http_test.go
@@ -30,7 +30,6 @@ func TestHTTPName(t *testing.T) {
 
 func TestHTTPGetIP(t *testing.T) {
 	ip4 := netip.MustParseAddr("1.2.3.4")
-	ip4As6 := netip.MustParseAddr("::ffff:1.2.3.4")
 	ip6 := netip.MustParseAddr("::1:2:3:4:5:6")
 	invalidIP := netip.Addr{}
 
@@ -56,9 +55,8 @@ func TestHTTPGetIP(t *testing.T) {
 			expected      netip.Addr
 			prepareMockPP func(*mocks.MockPP)
 		}{
-			"4":    {false, ipnet.IP4, ip4Server.URL, ipnet.IP4, ip4, nil},
-			"6":    {false, ipnet.IP6, ip6Server.URL, ipnet.IP6, ip6, nil},
-			"4to6": {false, ipnet.IP6, ip4Server.URL, ipnet.IP6, ip4As6, nil},
+			"4": {false, ipnet.IP4, ip4Server.URL, ipnet.IP4, ip4, nil},
+			"6": {false, ipnet.IP6, ip6Server.URL, ipnet.IP6, ip6, nil},
 			"nilctx": {
 				true, ipnet.IP4, ip4Server.URL, ipnet.IP4, invalidIP,
 				func(m *mocks.MockPP) {
diff --git a/internal/provider/protocol/regexp_test.go b/internal/provider/protocol/regexp_test.go
index 7e0bf22..1e02b64 100644
--- a/internal/provider/protocol/regexp_test.go
+++ b/internal/provider/protocol/regexp_test.go
@@ -31,7 +31,6 @@ func TestRegexpName(t *testing.T) {
 
 func TestRegexpGetIP(t *testing.T) {
 	ip4 := netip.MustParseAddr("1.2.3.4")
-	ip4As6 := netip.MustParseAddr("::ffff:1.2.3.4")
 	ip6 := netip.MustParseAddr("::1:2:3:4:5:6")
 	invalidIP := netip.Addr{}
 
@@ -57,9 +56,8 @@ func TestRegexpGetIP(t *testing.T) {
 			expected      netip.Addr
 			prepareMockPP func(*mocks.MockPP)
 		}{
-			"4":    {ipnet.IP4, ip4Server.URL, regexp.MustCompile(`<<(.*)>>`), ipnet.IP4, ip4, nil},
-			"6":    {ipnet.IP6, ip6Server.URL, regexp.MustCompile(`<<(.*)>>`), ipnet.IP6, ip6, nil},
-			"4to6": {ipnet.IP6, ip4Server.URL, regexp.MustCompile(`<<(.*)>>`), ipnet.IP6, ip4As6, nil},
+			"4": {ipnet.IP4, ip4Server.URL, regexp.MustCompile(`<<(.*)>>`), ipnet.IP4, ip4, nil},
+			"6": {ipnet.IP6, ip6Server.URL, regexp.MustCompile(`<<(.*)>>`), ipnet.IP6, ip6, nil},
 			"6to4": {
 				ipnet.IP4, ip6Server.URL, regexp.MustCompile(`<<(.*)>>`), ipnet.IP4, invalidIP,
 				func(m *mocks.MockPP) {