From 8340073ecb17c42c75a15ff6bb3cae6d3c7510e2 Mon Sep 17 00:00:00 2001 From: Simon Chrzanowski Date: Mon, 9 Nov 2026 17:50:47 +0100 Subject: [PATCH] Check the IPv4 address that NAT64 and 6to4 addresses carry The dial-time gate checked IPv6 addresses only as IPv6, so on a network with NAT64, 64:ff9b::a9fe:a9fe passed and reached 169.254.169.254. Addresses under the NAT64 well-known prefix, 6to4 and IPv4-compatible addresses are now also checked as the IPv4 address they carry, and the local-use NAT64 prefix 64:ff9b:1::/48 counts as private. --- safehttp/safehttp.go | 51 +++++++++++++++++++++++++++++++++++++-- safehttp/safehttp_test.go | 35 +++++++++++++++++++++++++++ 2 files changed, 84 insertions(+), 2 deletions(-) diff --git a/safehttp/safehttp.go b/safehttp/safehttp.go index f0a3212..d4c18cb 100644 --- a/safehttp/safehttp.go +++ b/safehttp/safehttp.go @@ -3,7 +3,9 @@ // // 1. Dial-time IP gate. DNS is resolved once per dial; each resolved // address is checked against the block list (loopback, RFC1918, -// CGNAT 100.64.0.0/10, link-local, multicast, unspecified) before +// CGNAT 100.64.0.0/10, link-local, multicast, unspecified, and IPv6 +// addresses that carry such an IPv4 address: NAT64, 6to4 and +// IPv4-compatible) before // any TCP connect. The connection then dials the resolved IP // directly, so a rebind between check and connect cannot escape // the gate. @@ -176,7 +178,44 @@ func (g *ipGate) dial(ctx context.Context, network, addr string, dial func(conte return nil, fmt.Errorf("safehttp: no addresses resolved for %s", host) } -var cgnat = mustCIDR("100.64.0.0/10") +var ( + cgnat = mustCIDR("100.64.0.0/10") + // nat64 is the NAT64 well-known prefix (RFC 6052): a gateway forwards + // 64:ff9b::a.b.c.d to the IPv4 address a.b.c.d. + nat64 = mustCIDR("64:ff9b::/96") + // nat64Local is the local-use NAT64 prefix (RFC 8215). Where the IPv4 + // address sits in it depends on the network, so it is treated as + // private. + nat64Local = mustCIDR("64:ff9b:1::/48") + // sixToFour is the 6to4 prefix (RFC 3056): 2002:AABB:CCDD::/48 is + // relayed to the IPv4 address AA.BB.CC.DD. + sixToFour = mustCIDR("2002::/16") + // ipv4Compatible holds the deprecated IPv4-compatible addresses + // ::a.b.c.d (RFC 4291). + ipv4Compatible = mustCIDR("::/96") +) + +// embeddedIPv4 returns the IPv4 address that ip reaches through a NAT64 +// gateway or a 6to4 relay, or as an IPv4-compatible address, and nil for +// any other address. IPv4-mapped addresses are already IPv4 to net.IP. +func embeddedIPv4(ip net.IP) net.IP { + if ip.To4() != nil { + return nil + } + ip16 := ip.To16() + switch { + case ip16 == nil: + return nil + case nat64.Contains(ip16): + return net.IPv4(ip16[12], ip16[13], ip16[14], ip16[15]) + case sixToFour.Contains(ip16): + return net.IPv4(ip16[2], ip16[3], ip16[4], ip16[5]) + case ipv4Compatible.Contains(ip16) && ip16[12] != 0: + // ::/104, which holds :: and ::1, embeds no routable address. + return net.IPv4(ip16[12], ip16[13], ip16[14], ip16[15]) + } + return nil +} func mustCIDR(s string) *net.IPNet { _, n, err := net.ParseCIDR(s) @@ -213,6 +252,14 @@ func (g *ipGate) checkHost(host string, ip net.IP) error { if cgnat.Contains(ip) { return blockedErr(ip, "CGNAT") } + if nat64Local.Contains(ip) { + return blockedErr(ip, "private") + } + } + if embedded := embeddedIPv4(ip); embedded != nil { + if err := g.checkHost(host, embedded); err != nil { + return fmt.Errorf("%w, embedded in %s", err, ip) + } } return nil } diff --git a/safehttp/safehttp_test.go b/safehttp/safehttp_test.go index 6377d39..8deac4b 100644 --- a/safehttp/safehttp_test.go +++ b/safehttp/safehttp_test.go @@ -45,6 +45,41 @@ func TestCheckIP_Blocks(t *testing.T) { } } +// IPv6 addresses that carry an IPv4 address reach that address through a +// NAT64 gateway or a 6to4 relay, so they are checked as the IPv4 address. +func TestCheckIP_BlocksEmbeddedIPv4(t *testing.T) { + g := newGate(Options{}) + cases := map[string]string{ + "64:ff9b::a9fe:a9fe": "link-local", // NAT64 well-known prefix, 169.254.169.254 + "64:ff9b::a00:1": "private", // NAT64, 10.0.0.1 + "64:ff9b::7f00:1": "loopback", // NAT64, 127.0.0.1 + "64:ff9b::6440:1": "CGNAT", // NAT64, 100.64.0.1 + "64:ff9b:1::1": "private", // local-use NAT64 prefix (RFC 8215) + "2002:a00:1::1": "private", // 6to4, 10.0.0.1 + "2002:a9fe:a9fe::": "link-local", // 6to4, 169.254.169.254 + "::a00:1": "private", // IPv4-compatible, 10.0.0.1 + "::7f00:1": "loopback", // IPv4-compatible, 127.0.0.1 + } + for in, wantKind := range cases { + err := g.check(net.ParseIP(in)) + if err == nil { + t.Errorf("checkIP(%s) = nil; want %s", in, wantKind) + continue + } + if !strings.Contains(err.Error(), wantKind) { + t.Errorf("checkIP(%s) = %v; want kind %q", in, err, wantKind) + } + } + for _, in := range []string{"64:ff9b::808:808", "2002:808:808::1"} { + if err := g.check(net.ParseIP(in)); err != nil { + t.Errorf("checkIP(%s) = %v; want nil for a public embedded address", in, err) + } + } + if err := newGate(Options{AllowPrivate: true}).check(net.ParseIP("64:ff9b::a00:1")); err != nil { + t.Errorf("checkIP(64:ff9b::a00:1) with AllowPrivate = %v; want nil", err) + } +} + func TestCheckIP_Allows(t *testing.T) { g := newGate(Options{}) for _, in := range []string{"8.8.8.8", "1.1.1.1", "2606:4700:4700::1111"} {