From c289dd6f90d6dcb612fda3aae9df2243d49c856b Mon Sep 17 00:00:00 2001 From: Slyghtning Date: Tue, 20 Jan 2026 17:47:02 -0500 Subject: [PATCH] proxy: apply ip4/ip6 filtering for unauthenticated requests --- freebie/mem_store.go | 14 ++--- netutil/ip.go | 29 ++++++++++ netutil/ip_test.go | 117 ++++++++++++++++++++++++++++++++++++++ proxy/ratelimiter.go | 4 +- proxy/ratelimiter_test.go | 14 +++-- 5 files changed, 163 insertions(+), 15 deletions(-) create mode 100644 netutil/ip.go create mode 100644 netutil/ip_test.go diff --git a/freebie/mem_store.go b/freebie/mem_store.go index 4a97b98..848404a 100644 --- a/freebie/mem_store.go +++ b/freebie/mem_store.go @@ -3,10 +3,8 @@ package freebie import ( "net" "net/http" -) -var ( - defaultIPMask = net.IPv4Mask(0xff, 0xff, 0xff, 0x00) + "github.com/lightninglabs/aperture/netutil" ) type Count uint16 @@ -17,7 +15,7 @@ type memStore struct { } func (m *memStore) getKey(ip net.IP) string { - return ip.Mask(defaultIPMask).String() + return netutil.MaskIP(ip).String() } func (m *memStore) currentCount(ip net.IP) Count { @@ -38,10 +36,10 @@ func (m *memStore) TallyFreebie(r *http.Request, ip net.IP) (bool, error) { return true, nil } -// NewMemIPMaskStore creates a new in-memory freebie store that masks the last -// byte of an IP address to keep track of free requests. The last byte of the -// address is discarded for the mapping to reduce risk of abuse by users that -// have a whole range of IPs at their disposal. +// NewMemIPMaskStore creates a new in-memory freebie store that masks IP +// addresses to keep track of free requests. IPv4 addresses are masked to /24 +// and IPv6 addresses to /48. This reduces risk of abuse by users that have a +// whole range of IPs at their disposal. func NewMemIPMaskStore(numFreebies Count) DB { return &memStore{ numFreebies: numFreebies, diff --git a/netutil/ip.go b/netutil/ip.go new file mode 100644 index 0000000..d2ede7f --- /dev/null +++ b/netutil/ip.go @@ -0,0 +1,29 @@ +package netutil + +import "net" + +var ( + // ipv4Mask24 masks IPv4 addresses to /24 (last octet zeroed). + // This groups clients on the same subnet together. + ipv4Mask24 = net.CIDRMask(24, 32) + + // ipv6Mask48 masks IPv6 addresses to /48. + // Residential connections typically receive /48 to /64 allocations, + // so /48 provides reasonable grouping for rate limiting purposes. + ipv6Mask48 = net.CIDRMask(48, 128) +) + +// MaskIP returns a masked version of the IP address for grouping purposes. +// IPv4 addresses are masked to /24 (zeroing the last octet). +// IPv6 addresses are masked to /48. +// +// This is useful for rate limiting and freebie tracking where we want to +// group requests from the same network segment rather than individual IPs, +// reducing abuse potential from users with multiple addresses. +func MaskIP(ip net.IP) net.IP { + if ip4 := ip.To4(); ip4 != nil { + return ip4.Mask(ipv4Mask24) + } + + return ip.Mask(ipv6Mask48) +} diff --git a/netutil/ip_test.go b/netutil/ip_test.go new file mode 100644 index 0000000..6c714d0 --- /dev/null +++ b/netutil/ip_test.go @@ -0,0 +1,117 @@ +package netutil + +import ( + "net" + "testing" + + "github.com/stretchr/testify/require" +) + +// TestMaskIP verifies that MaskIP correctly applies /24 masks to IPv4 and /48 +// masks to IPv6 addresses. +func TestMaskIP(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + { + name: "IPv4 masks last octet", + input: "192.168.1.123", + expected: "192.168.1.0", + }, + { + name: "IPv4 already masked", + input: "10.0.0.0", + expected: "10.0.0.0", + }, + { + name: "IPv4 different last octet same result", + input: "192.168.1.255", + expected: "192.168.1.0", + }, + { + name: "IPv6 masks to /48", + input: "2001:db8:1234:5678:9abc:def0:1234:5678", + expected: "2001:db8:1234::", + }, + { + name: "IPv6 already masked", + input: "2001:db8:abcd::", + expected: "2001:db8:abcd::", + }, + { + name: "IPv6 loopback", + input: "::1", + expected: "::", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + ip := net.ParseIP(tc.input) + require.NotNil(t, ip, "failed to parse input IP") + + result := MaskIP(ip) + require.Equal(t, tc.expected, result.String()) + }) + } +} + +// TestMaskIP_SameSubnetGroupsTogether verifies that IPv4 addresses in the same +// /24 subnet produce identical masked results. +func TestMaskIP_SameSubnetGroupsTogether(t *testing.T) { + // Verify that IPs in the same /24 subnet produce the same masked result. + ips := []string{ + "192.168.1.1", + "192.168.1.100", + "192.168.1.255", + } + + results := make([]string, 0, len(ips)) + for _, ipStr := range ips { + ip := net.ParseIP(ipStr) + results = append(results, MaskIP(ip).String()) + } + + // All should be the same. + for i := 1; i < len(results); i++ { + require.Equal(t, results[0], results[i], + "IPs in same /24 should have same masked result") + } +} + +// TestMaskIP_DifferentSubnetsDiffer verifies that IPv4 addresses in different +// /24 subnets produce distinct masked results. +func TestMaskIP_DifferentSubnetsDiffer(t *testing.T) { + ip1 := net.ParseIP("192.168.1.100") + ip2 := net.ParseIP("192.168.2.100") + + result1 := MaskIP(ip1).String() + result2 := MaskIP(ip2).String() + + require.NotEqual(t, result1, result2, + "IPs in different /24 subnets should have different masked results") +} + +// TestMaskIP_IPv6SamePrefix48GroupsTogether verifies that IPv6 addresses +// sharing the same /48 prefix produce identical masked results. +func TestMaskIP_IPv6SamePrefix48GroupsTogether(t *testing.T) { + // IPs in the same /48 should produce the same masked result. + ips := []string{ + "2001:db8:1234:0001::", + "2001:db8:1234:ffff::", + "2001:db8:1234:abcd:1234:5678:9abc:def0", + } + + results := make([]string, 0, len(ips)) + for _, ipStr := range ips { + ip := net.ParseIP(ipStr) + results = append(results, MaskIP(ip).String()) + } + + for i := 1; i < len(results); i++ { + require.Equal(t, results[0], results[i], + "IPs in same /48 should have same masked result") + } +} diff --git a/proxy/ratelimiter.go b/proxy/ratelimiter.go index d9226be..e274bd4 100644 --- a/proxy/ratelimiter.go +++ b/proxy/ratelimiter.go @@ -8,6 +8,7 @@ import ( "time" "github.com/lightninglabs/aperture/l402" + "github.com/lightninglabs/aperture/netutil" "github.com/lightninglabs/neutrino/cache/lru" "golang.org/x/time/rate" ) @@ -246,5 +247,6 @@ func ExtractRateLimitKey(r *http.Request, remoteIP net.IP, } // Fall back to IP address for unauthenticated requests. - return "ip:" + remoteIP.String() + // Mask the IP to group clients from the same network segment. + return "ip:" + netutil.MaskIP(remoteIP).String() } diff --git a/proxy/ratelimiter_test.go b/proxy/ratelimiter_test.go index 5a30ce6..a445fe7 100644 --- a/proxy/ratelimiter_test.go +++ b/proxy/ratelimiter_test.go @@ -205,18 +205,19 @@ func TestExtractRateLimitKeyIP(t *testing.T) { req := httptest.NewRequest("GET", "/api/test", nil) ip := net.ParseIP("192.168.1.100") - // Unauthenticated request should use IP. + // Unauthenticated request should use masked IP (/24 for IPv4). key := ExtractRateLimitKey(req, ip, false) - require.Equal(t, "ip:192.168.1.100", key) + require.Equal(t, "ip:192.168.1.0", key) } // TestExtractRateLimitKeyIPv6 tests IPv6 key extraction. func TestExtractRateLimitKeyIPv6(t *testing.T) { req := httptest.NewRequest("GET", "/api/test", nil) - ip := net.ParseIP("2001:db8::1") + ip := net.ParseIP("2001:db8:1234:5678::1") + // IPv6 should be masked to /48. key := ExtractRateLimitKey(req, ip, false) - require.Equal(t, "ip:2001:db8::1", key) + require.Equal(t, "ip:2001:db8:1234::", key) } // TestExtractRateLimitKeyUnauthenticatedIgnoresL402 tests that unauthenticated @@ -228,9 +229,10 @@ func TestExtractRateLimitKeyUnauthenticatedIgnoresL402(t *testing.T) { req.Header.Set("Authorization", "L402 garbage:token") ip := net.ParseIP("192.168.1.100") - // Even with L402 header present, unauthenticated=false should use IP. + // Even with L402 header present, unauthenticated=false should use + // masked IP. key := ExtractRateLimitKey(req, ip, false) - require.Equal(t, "ip:192.168.1.100", key) + require.Equal(t, "ip:192.168.1.0", key) } // TestRateLimitConfigRate tests the Rate() calculation.