proxy: apply ip4/ip6 filtering for unauthenticated requests

This commit is contained in:
Slyghtning
2026-01-20 17:47:02 -05:00
parent 011dc72e4b
commit c289dd6f90
5 changed files with 163 additions and 15 deletions

View File

@@ -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,

29
netutil/ip.go Normal file
View File

@@ -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)
}

117
netutil/ip_test.go Normal file
View File

@@ -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")
}
}

View File

@@ -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()
}

View File

@@ -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.