Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 38 additions & 22 deletions httpx/ssrf.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,12 @@ import (
"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"
)
Expand Down Expand Up @@ -66,6 +68,7 @@ func init() {
d.Control = ssrf.New(
ssrf.WithAnyPort(),
ssrf.WithNetworks("tcp4", "tcp6"),
ssrf.WithAllowedV6Prefixes(ipx.PublicIPv4Nat64Prefixes()...),
).Safe
prohibitInternalAllowIPv6 = otelTransport(t)
}
Expand All @@ -75,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)
Expand All @@ -83,41 +87,53 @@ 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(slices.Concat(
ipx.PublicIPv4Nat64Prefixes(),
[]netip.Prefix{
netip.MustParsePrefix("::1/128"), // Loopback (RFC 4193)
netip.MustParsePrefix("fc00::/7"), // Unique Local (RFC 4193)
},
ipx.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(slices.Concat(
ipx.PublicIPv4Nat64Prefixes(),
[]netip.Prefix{
netip.MustParsePrefix("::1/128"), // Loopback (RFC 4193)
netip.MustParsePrefix("fc00::/7"), // Unique Local (RFC 4193)
},
ipx.MustConvertToNAT64Prefixes(allowedV4Prefixes),
)...),
).Safe
t.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
return d.DialContext(ctx, "tcp4", addr)
Expand Down
106 changes: 106 additions & 0 deletions ipx/ip_nat64.go
Original file line number Diff line number Diff line change
@@ -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
}
103 changes: 103 additions & 0 deletions ipx/ip_nat64_test.go
Original file line number Diff line number Diff line change
@@ -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))
}
Loading