mirror of
https://github.com/lightninglabs/aperture.git
synced 2026-01-31 15:14:26 +01:00
proxy: apply ip4/ip6 filtering for unauthenticated requests
This commit is contained in:
@@ -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
29
netutil/ip.go
Normal 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
117
netutil/ip_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user