From 7a27c37fa8b7ec58bb6f8ec29af58b5b356c20dc Mon Sep 17 00:00:00 2001 From: Nicolas Schwartz <9308314+StarAurryon@users.noreply.github.com> Date: Tue, 3 Jun 2025 20:45:36 +0200 Subject: [PATCH 1/2] fix(httpx): allow local standard rfc6052 for nat64 if local --- httpx/ssrf.go | 82 +++++++++++++++++++++++++++++++++++++-------------- 1 file changed, 60 insertions(+), 22 deletions(-) diff --git a/httpx/ssrf.go b/httpx/ssrf.go index 99b16e9e..dccb0941 100644 --- a/httpx/ssrf.go +++ b/httpx/ssrf.go @@ -5,6 +5,7 @@ package httpx import ( "context" + "fmt" "net" "net/http" "net/http/httptrace" @@ -83,41 +84,49 @@ func init() { } func init() { + allowedV4Prefixes := []netip.Prefix{ + netip.MustParsePrefix("10.0.0.0/8"), // Private-Use (RFC 1918) + netip.MustParsePrefix("127.0.0.0/8"), // Loopback (RFC 1122, Section 3.2.1.3)) + netip.MustParsePrefix("169.254.0.0/16"), // Link Local (RFC 3927) + netip.MustParsePrefix("172.16.0.0/12"), // Private-Use (RFC 1918) + netip.MustParsePrefix("192.168.0.0/16"), // Private-Use (RFC 1918) + } + t, d := newDefaultTransport() d.Control = ssrf.New( ssrf.WithAnyPort(), ssrf.WithNetworks("tcp4", "tcp6"), - ssrf.WithAllowedV4Prefixes( - netip.MustParsePrefix("10.0.0.0/8"), // Private-Use (RFC 1918) - netip.MustParsePrefix("127.0.0.0/8"), // Loopback (RFC 1122, Section 3.2.1.3)) - netip.MustParsePrefix("169.254.0.0/16"), // Link Local (RFC 3927) - netip.MustParsePrefix("172.16.0.0/12"), // Private-Use (RFC 1918) - netip.MustParsePrefix("192.168.0.0/16"), // Private-Use (RFC 1918) - ), - ssrf.WithAllowedV6Prefixes( - netip.MustParsePrefix("::1/128"), // Loopback (RFC 4193) - netip.MustParsePrefix("fc00::/7"), // Unique Local (RFC 4193) - ), + ssrf.WithAllowedV4Prefixes(allowedV4Prefixes...), + ssrf.WithAllowedV6Prefixes(append( + []netip.Prefix{ + netip.MustParsePrefix("::1/128"), // Loopback (RFC 4193) + netip.MustParsePrefix("fc00::/7"), // Unique Local (RFC 4193) + }, mustConvertToNAT64Prefixes(allowedV4Prefixes)..., + )...), ).Safe allowInternalAllowIPv6 = otelTransport(t) } func init() { + allowedV4Prefixes := []netip.Prefix{ + netip.MustParsePrefix("10.0.0.0/8"), // Private-Use (RFC 1918) + netip.MustParsePrefix("127.0.0.0/8"), // Loopback (RFC 1122, Section 3.2.1.3)) + netip.MustParsePrefix("169.254.0.0/16"), // Link Local (RFC 3927) + netip.MustParsePrefix("172.16.0.0/12"), // Private-Use (RFC 1918) + netip.MustParsePrefix("192.168.0.0/16"), // Private-Use (RFC 1918) + } + t, d := newDefaultTransport() d.Control = ssrf.New( ssrf.WithAnyPort(), ssrf.WithNetworks("tcp4"), - ssrf.WithAllowedV4Prefixes( - netip.MustParsePrefix("10.0.0.0/8"), // Private-Use (RFC 1918) - netip.MustParsePrefix("127.0.0.0/8"), // Loopback (RFC 1122, Section 3.2.1.3)) - netip.MustParsePrefix("169.254.0.0/16"), // Link Local (RFC 3927) - netip.MustParsePrefix("172.16.0.0/12"), // Private-Use (RFC 1918) - netip.MustParsePrefix("192.168.0.0/16"), // Private-Use (RFC 1918) - ), - ssrf.WithAllowedV6Prefixes( - netip.MustParsePrefix("::1/128"), // Loopback (RFC 4193) - netip.MustParsePrefix("fc00::/7"), // Unique Local (RFC 4193) - ), + ssrf.WithAllowedV4Prefixes(allowedV4Prefixes...), + ssrf.WithAllowedV6Prefixes(append( + []netip.Prefix{ + netip.MustParsePrefix("::1/128"), // Loopback (RFC 4193) + netip.MustParsePrefix("fc00::/7"), // Unique Local (RFC 4193) + }, mustConvertToNAT64Prefixes(allowedV4Prefixes)..., + )...), ).Safe t.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) { return d.DialContext(ctx, "tcp4", addr) @@ -146,3 +155,32 @@ func otelTransport(t *http.Transport) http.RoundTripper { return otelhttptrace.NewClientTrace(ctx, otelhttptrace.WithoutHeaders(), otelhttptrace.WithoutSubSpans()) })) } + +func mustConvertToNAT64Prefixes(ps []netip.Prefix) []netip.Prefix { + out := make([]netip.Prefix, len(ps)) + for i, p := range ps { + out[i] = mustConvertToNAT64Prefix(p) + } + return out +} + +func mustConvertToNAT64Prefix(p netip.Prefix) netip.Prefix { + var nat64Base = netip.MustParsePrefix("64:ff9b::/96") // NAT64 Prefix (RFC 6052) + + if !p.Addr().Is4() { + panic(fmt.Errorf("prefix %v is not an IPv4 prefix", p)) + } + + ipv4Len := p.Bits() + if ipv4Len > 32 { + panic(fmt.Errorf("invalid IPv4 prefix length: %d", ipv4Len)) + } + + newLen := 96 + ipv4Len + ip4 := p.Addr().As4() + + baseBytes := nat64Base.Addr().As16() + copy(baseBytes[12:], ip4[:]) + + return netip.PrefixFrom(netip.AddrFrom16(baseBytes), newLen) +} From b836923a92874e8e0ad4aa5b9c03837470f1277a Mon Sep 17 00:00:00 2001 From: Nicolas Schwartz <9308314+StarAurryon@users.noreply.github.com> Date: Tue, 3 Jun 2025 21:19:19 +0200 Subject: [PATCH 2/2] fix(httpx): allowing public nat64 prefix by default --- httpx/ssrf.go | 46 +++++-------------- ipx/ip_nat64.go | 106 +++++++++++++++++++++++++++++++++++++++++++ ipx/ip_nat64_test.go | 103 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 221 insertions(+), 34 deletions(-) create mode 100644 ipx/ip_nat64.go create mode 100644 ipx/ip_nat64_test.go diff --git a/httpx/ssrf.go b/httpx/ssrf.go index dccb0941..51174c83 100644 --- a/httpx/ssrf.go +++ b/httpx/ssrf.go @@ -5,15 +5,16 @@ package httpx import ( "context" - "fmt" "net" "net/http" "net/http/httptrace" "net/netip" + "slices" "time" "code.dny.dev/ssrf" "github.com/gobwas/glob" + "github.com/ory/x/ipx" "go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace" "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" ) @@ -67,6 +68,7 @@ func init() { d.Control = ssrf.New( ssrf.WithAnyPort(), ssrf.WithNetworks("tcp4", "tcp6"), + ssrf.WithAllowedV6Prefixes(ipx.PublicIPv4Nat64Prefixes()...), ).Safe prohibitInternalAllowIPv6 = otelTransport(t) } @@ -76,6 +78,7 @@ func init() { d.Control = ssrf.New( ssrf.WithAnyPort(), ssrf.WithNetworks("tcp4"), + ssrf.WithAllowedV6Prefixes(ipx.PublicIPv4Nat64Prefixes()...), ).Safe t.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) { return d.DialContext(ctx, "tcp4", addr) @@ -97,11 +100,13 @@ func init() { ssrf.WithAnyPort(), ssrf.WithNetworks("tcp4", "tcp6"), ssrf.WithAllowedV4Prefixes(allowedV4Prefixes...), - ssrf.WithAllowedV6Prefixes(append( + ssrf.WithAllowedV6Prefixes(slices.Concat( + ipx.PublicIPv4Nat64Prefixes(), []netip.Prefix{ netip.MustParsePrefix("::1/128"), // Loopback (RFC 4193) netip.MustParsePrefix("fc00::/7"), // Unique Local (RFC 4193) - }, mustConvertToNAT64Prefixes(allowedV4Prefixes)..., + }, + ipx.MustConvertToNAT64Prefixes(allowedV4Prefixes), )...), ).Safe allowInternalAllowIPv6 = otelTransport(t) @@ -121,11 +126,13 @@ func init() { ssrf.WithAnyPort(), ssrf.WithNetworks("tcp4"), ssrf.WithAllowedV4Prefixes(allowedV4Prefixes...), - ssrf.WithAllowedV6Prefixes(append( + ssrf.WithAllowedV6Prefixes(slices.Concat( + ipx.PublicIPv4Nat64Prefixes(), []netip.Prefix{ netip.MustParsePrefix("::1/128"), // Loopback (RFC 4193) netip.MustParsePrefix("fc00::/7"), // Unique Local (RFC 4193) - }, mustConvertToNAT64Prefixes(allowedV4Prefixes)..., + }, + ipx.MustConvertToNAT64Prefixes(allowedV4Prefixes), )...), ).Safe t.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) { @@ -155,32 +162,3 @@ func otelTransport(t *http.Transport) http.RoundTripper { return otelhttptrace.NewClientTrace(ctx, otelhttptrace.WithoutHeaders(), otelhttptrace.WithoutSubSpans()) })) } - -func mustConvertToNAT64Prefixes(ps []netip.Prefix) []netip.Prefix { - out := make([]netip.Prefix, len(ps)) - for i, p := range ps { - out[i] = mustConvertToNAT64Prefix(p) - } - return out -} - -func mustConvertToNAT64Prefix(p netip.Prefix) netip.Prefix { - var nat64Base = netip.MustParsePrefix("64:ff9b::/96") // NAT64 Prefix (RFC 6052) - - if !p.Addr().Is4() { - panic(fmt.Errorf("prefix %v is not an IPv4 prefix", p)) - } - - ipv4Len := p.Bits() - if ipv4Len > 32 { - panic(fmt.Errorf("invalid IPv4 prefix length: %d", ipv4Len)) - } - - newLen := 96 + ipv4Len - ip4 := p.Addr().As4() - - baseBytes := nat64Base.Addr().As16() - copy(baseBytes[12:], ip4[:]) - - return netip.PrefixFrom(netip.AddrFrom16(baseBytes), newLen) -} diff --git a/ipx/ip_nat64.go b/ipx/ip_nat64.go new file mode 100644 index 00000000..3e37f1eb --- /dev/null +++ b/ipx/ip_nat64.go @@ -0,0 +1,106 @@ +// Copyright © 2023 Ory Corp +// SPDX-License-Identifier: Apache-2.0 + +package ipx + +import ( + "fmt" + "net/netip" + + "code.dny.dev/ssrf" +) + +// PublicIPv4Nat64Prefixes returns the list of public IPv4 to their NAT64 (RFC 6052) IPv6 representation +func PublicIPv4Nat64Prefixes() []netip.Prefix { + return MustConvertToNAT64Prefixes(complementIPv4(ssrf.IPv4DeniedPrefixes)) +} + +// MustConvertToNAT64Prefixes convert a list of IPv4 prefixes to a NAT64 (RFC 6052) list of IPv6 prefixes or panic +func MustConvertToNAT64Prefixes(ps []netip.Prefix) []netip.Prefix { + out := make([]netip.Prefix, len(ps)) + for i, p := range ps { + out[i] = MustConvertToNAT64Prefix(p) + } + return out +} + +// MustConvertToNAT64Prefix convert an IPv4 prefix to a NAT64 (RFC 6052) IPv6 prefix or panic +func MustConvertToNAT64Prefix(p netip.Prefix) netip.Prefix { + if !p.Addr().Is4() { + panic(fmt.Errorf("prefix %v is not an IPv4 prefix", p)) + } + + ipv4Len := p.Bits() + if ipv4Len > 32 { + panic(fmt.Errorf("invalid IPv4 prefix length: %d", ipv4Len)) + } + + newLen := 96 + ipv4Len + ip4 := p.Addr().As4() + + baseBytes := ssrf.IPv6NAT64Prefix.Addr().As16() + copy(baseBytes[12:], ip4[:]) + + return netip.PrefixFrom(netip.AddrFrom16(baseBytes), newLen) +} + +// ipToUint32 converts a netip.Addr (IPv4) to its uint32 representation. +func ipToUint32(a netip.Addr) uint32 { + b := a.As4() + return uint32(b[0])<<24 | uint32(b[1])<<16 | uint32(b[2])<<8 | uint32(b[3]) +} + +// prefixRange returns the first and last IPv4 addresses of p as uint32. +func prefixRange(p netip.Prefix) (start, end uint32) { + // Masked() ensures the address is the network address. + base := ipToUint32(p.Masked().Addr()) + ones, _ := p.Bits(), p.Addr().BitLen() + size := uint32(1) << (32 - ones) + return base, base + size - 1 +} + +// subtractPrefix(r, p) returns the set of IPv4 prefixes covering (r − p) +// without using AddrRange(). +func subtractPrefix(r, p netip.Prefix) []netip.Prefix { + rStart, rEnd := prefixRange(r) + pStart, pEnd := prefixRange(p) + + // No overlap + if rEnd < pStart || pEnd < rStart { + return []netip.Prefix{r} + } + // p covers r completely + if pStart <= rStart && rEnd <= pEnd { + return nil + } + // r strictly contains p: split r into two / (r.Bits()+1) children. + childLen := r.Bits() + 1 + c1 := netip.PrefixFrom(r.Addr(), childLen) + + // Compute base for second child. + increment := uint32(1) << (32 - childLen) + raw := ipToUint32(r.Addr()) + increment + b0 := byte((raw >> 24) & 0xFF) + b1 := byte((raw >> 16) & 0xFF) + b2 := byte((raw >> 8) & 0xFF) + b3 := byte(raw & 0xFF) + c2 := netip.PrefixFrom(netip.AddrFrom4([4]byte{b0, b1, b2, b3}), childLen) + + out := subtractPrefix(c1, p) + out = append(out, subtractPrefix(c2, p)...) + return out +} + +// complementIPv4 returns the minimal set of IPv4 prefixes covering all +// addresses in 0.0.0.0/0 that are not in any of the input prefixes. +func complementIPv4(input []netip.Prefix) []netip.Prefix { + remainder := []netip.Prefix{netip.MustParsePrefix("0.0.0.0/0")} + for _, p := range input { + next := make([]netip.Prefix, 0, len(remainder)) + for _, r := range remainder { + next = append(next, subtractPrefix(r, p)...) + } + remainder = next + } + return remainder +} diff --git a/ipx/ip_nat64_test.go b/ipx/ip_nat64_test.go new file mode 100644 index 00000000..905e71b1 --- /dev/null +++ b/ipx/ip_nat64_test.go @@ -0,0 +1,103 @@ +// Copyright © 2023 Ory Corp +// SPDX-License-Identifier: Apache-2.0 + +package ipx + +import ( + "net/netip" + "testing" + + "code.dny.dev/ssrf" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestMustConvertToNAT64Prefix_ValidInputs(t *testing.T) { + tests := []struct { + in string + want string + }{ + {"192.0.2.0/24", "64:ff9b::c000:200/120"}, + {"10.0.0.0/8", "64:ff9b::a00:0/104"}, + {"0.0.0.0/0", "64:ff9b::/96"}, + } + + for _, tc := range tests { + p := netip.MustParsePrefix(tc.in) + got := MustConvertToNAT64Prefix(p) + assert.Equal(t, tc.want, got.String(), "MustConvertToNAT64Prefix(%q)", tc.in) + } +} + +func TestMustConvertToNAT64Prefix_PanicsOnInvalid(t *testing.T) { + assert.Panics(t, func() { + MustConvertToNAT64Prefix(netip.MustParsePrefix("2001:db8::/32")) + }, "Expected panic for non-IPv4 prefix") +} + +func TestMustConvertToNAT64Prefixes_SliceConversion(t *testing.T) { + input := []netip.Prefix{ + netip.MustParsePrefix("192.0.2.0/24"), + netip.MustParsePrefix("10.1.0.0/16"), + } + want := []string{ + "64:ff9b::c000:200/120", + "64:ff9b::a01:0/112", + } + + got := MustConvertToNAT64Prefixes(input) + require.Len(t, got, len(want), "MustConvertToNAT64Prefixes returned %d entries; want %d", len(got), len(want)) + for i, p := range got { + assert.Equal(t, want[i], p.String(), "Element %d", i) + } +} + +func TestPublicIPv4Nat64Prefixes_BasicSanity(t *testing.T) { + out := PublicIPv4Nat64Prefixes() + require.NotEmpty(t, out, "Expected nonempty slice") + + for _, p := range out { + s := p.String() + assert.True(t, p.Addr().Is6(), "Returned prefix %q is not IPv6", s) + assert.GreaterOrEqual(t, p.Bits(), 96, "Unexpected prefix length for %q", s) + assert.LessOrEqual(t, p.Bits(), 128, "Unexpected prefix length for %q", s) + base := ssrf.IPv6NAT64Prefix.Addr().String()[:len("64:ff9b:")] + assert.True(t, len(s) >= len(base) && s[:len(base)] == base, + "Prefix %q does not start with NAT64 base", s, + ) + } +} + +func TestComplementIPv4_FullCoverage(t *testing.T) { + denied := ssrf.IPv4DeniedPrefixes + allowed := complementIPv4(denied) + + // 1) No denied overlaps any allowed + for _, d := range denied { + dStart, dEnd := prefixRange(d) + for _, a := range allowed { + aStart, aEnd := prefixRange(a) + overlaps := !(dEnd < aStart || aEnd < dStart) + assert.False(t, overlaps, "Denied prefix %q overlaps allowed prefix %q", d, a) + } + } + + // 2) Denied + allowed cover all /8 blocks exactly once + seen := make(map[string]struct{}) + for _, set := range [][]netip.Prefix{denied, allowed} { + for _, p := range set { + start, end := prefixRange(p) + for ip := start; ip <= end; { + octet := byte((ip >> 24) & 0xFF) + key, err := netip.AddrFrom4([4]byte{octet, 0, 0, 0}).Prefix(8) + require.NoError(t, err) + seen[key.String()] = struct{}{} + ip += 1 << 24 + if ip == 0 { + break + } + } + } + } + assert.Len(t, seen, 256, "Denied+allowed did not cover all 256 /8 blocks; covered %d", len(seen)) +}