mirror of
https://github.com/netbirdio/netbird.git
synced 2026-08-04 19:45:14 -04:00
815 lines
31 KiB
Go
815 lines
31 KiB
Go
package restrict
|
|
|
|
import (
|
|
"net/netip"
|
|
"testing"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
|
|
"github.com/netbirdio/netbird/proxy/internal/geolocation"
|
|
)
|
|
|
|
type mockGeo struct {
|
|
countries map[string]string
|
|
}
|
|
|
|
func (m *mockGeo) LookupAddr(addr netip.Addr) geolocation.Result {
|
|
return geolocation.Result{CountryCode: m.countries[addr.String()]}
|
|
}
|
|
|
|
func (m *mockGeo) Available() bool { return true }
|
|
|
|
func newMockGeo(entries map[string]string) *mockGeo {
|
|
return &mockGeo{countries: entries}
|
|
}
|
|
|
|
func TestFilter_Check_NilFilter(t *testing.T) {
|
|
var f *Filter
|
|
assert.Equal(t, Allow, f.Check(netip.MustParseAddr("1.2.3.4"), nil))
|
|
}
|
|
|
|
func TestFilter_Check_AllowedCIDR(t *testing.T) {
|
|
f := ParseFilter(FilterConfig{AllowedCIDRs: []string{"10.0.0.0/8"}})
|
|
|
|
assert.Equal(t, Allow, f.Check(netip.MustParseAddr("10.1.2.3"), nil))
|
|
assert.Equal(t, DenyCIDR, f.Check(netip.MustParseAddr("192.168.1.1"), nil))
|
|
}
|
|
|
|
func TestFilter_Check_BlockedCIDR(t *testing.T) {
|
|
f := ParseFilter(FilterConfig{BlockedCIDRs: []string{"10.0.0.0/8"}})
|
|
|
|
assert.Equal(t, DenyCIDR, f.Check(netip.MustParseAddr("10.1.2.3"), nil))
|
|
assert.Equal(t, Allow, f.Check(netip.MustParseAddr("192.168.1.1"), nil))
|
|
}
|
|
|
|
func TestFilter_Check_AllowedAndBlockedCIDR(t *testing.T) {
|
|
f := ParseFilter(FilterConfig{AllowedCIDRs: []string{"10.0.0.0/8"}, BlockedCIDRs: []string{"10.1.0.0/16"}})
|
|
|
|
assert.Equal(t, Allow, f.Check(netip.MustParseAddr("10.2.3.4"), nil), "allowed by allowlist, not in blocklist")
|
|
assert.Equal(t, DenyCIDR, f.Check(netip.MustParseAddr("10.1.2.3"), nil), "allowed by allowlist but in blocklist")
|
|
assert.Equal(t, DenyCIDR, f.Check(netip.MustParseAddr("192.168.1.1"), nil), "not in allowlist")
|
|
}
|
|
|
|
func TestFilter_Check_AllowedCountry(t *testing.T) {
|
|
geo := newMockGeo(map[string]string{
|
|
"1.1.1.1": "US",
|
|
"2.2.2.2": "DE",
|
|
"3.3.3.3": "CN",
|
|
})
|
|
f := ParseFilter(FilterConfig{AllowedCountries: []string{"US", "DE"}})
|
|
|
|
assert.Equal(t, Allow, f.Check(netip.MustParseAddr("1.1.1.1"), geo), "US in allowlist")
|
|
assert.Equal(t, Allow, f.Check(netip.MustParseAddr("2.2.2.2"), geo), "DE in allowlist")
|
|
assert.Equal(t, DenyCountry, f.Check(netip.MustParseAddr("3.3.3.3"), geo), "CN not in allowlist")
|
|
}
|
|
|
|
func TestFilter_Check_BlockedCountry(t *testing.T) {
|
|
geo := newMockGeo(map[string]string{
|
|
"1.1.1.1": "CN",
|
|
"2.2.2.2": "RU",
|
|
"3.3.3.3": "US",
|
|
})
|
|
f := ParseFilter(FilterConfig{BlockedCountries: []string{"CN", "RU"}})
|
|
|
|
assert.Equal(t, DenyCountry, f.Check(netip.MustParseAddr("1.1.1.1"), geo), "CN in blocklist")
|
|
assert.Equal(t, DenyCountry, f.Check(netip.MustParseAddr("2.2.2.2"), geo), "RU in blocklist")
|
|
assert.Equal(t, Allow, f.Check(netip.MustParseAddr("3.3.3.3"), geo), "US not in blocklist")
|
|
}
|
|
|
|
func TestFilter_Check_AllowedAndBlockedCountry(t *testing.T) {
|
|
geo := newMockGeo(map[string]string{
|
|
"1.1.1.1": "US",
|
|
"2.2.2.2": "DE",
|
|
"3.3.3.3": "CN",
|
|
})
|
|
// Allow US and DE, but block DE explicitly.
|
|
f := ParseFilter(FilterConfig{AllowedCountries: []string{"US", "DE"}, BlockedCountries: []string{"DE"}})
|
|
|
|
assert.Equal(t, Allow, f.Check(netip.MustParseAddr("1.1.1.1"), geo), "US allowed and not blocked")
|
|
assert.Equal(t, DenyCountry, f.Check(netip.MustParseAddr("2.2.2.2"), geo), "DE allowed but also blocked, block wins")
|
|
assert.Equal(t, DenyCountry, f.Check(netip.MustParseAddr("3.3.3.3"), geo), "CN not in allowlist")
|
|
}
|
|
|
|
func TestFilter_Check_UnknownCountryWithAllowlist(t *testing.T) {
|
|
geo := newMockGeo(map[string]string{
|
|
"1.1.1.1": "US",
|
|
})
|
|
f := ParseFilter(FilterConfig{AllowedCountries: []string{"US"}})
|
|
|
|
assert.Equal(t, Allow, f.Check(netip.MustParseAddr("1.1.1.1"), geo), "known US in allowlist")
|
|
assert.Equal(t, DenyCountry, f.Check(netip.MustParseAddr("9.9.9.9"), geo), "unknown country denied when allowlist is active")
|
|
}
|
|
|
|
func TestFilter_Check_UnknownCountryWithBlocklistOnly(t *testing.T) {
|
|
geo := newMockGeo(map[string]string{
|
|
"1.1.1.1": "CN",
|
|
})
|
|
f := ParseFilter(FilterConfig{BlockedCountries: []string{"CN"}})
|
|
|
|
assert.Equal(t, DenyCountry, f.Check(netip.MustParseAddr("1.1.1.1"), geo), "known CN in blocklist")
|
|
assert.Equal(t, Allow, f.Check(netip.MustParseAddr("9.9.9.9"), geo), "unknown country allowed when only blocklist is active")
|
|
}
|
|
|
|
func TestFilter_Check_CountryWithoutGeo(t *testing.T) {
|
|
f := ParseFilter(FilterConfig{AllowedCountries: []string{"US"}})
|
|
assert.Equal(t, DenyGeoUnavailable, f.Check(netip.MustParseAddr("1.2.3.4"), nil), "nil geo with country allowlist")
|
|
}
|
|
|
|
func TestFilter_Check_CountryBlocklistWithoutGeo(t *testing.T) {
|
|
f := ParseFilter(FilterConfig{BlockedCountries: []string{"CN"}})
|
|
assert.Equal(t, DenyGeoUnavailable, f.Check(netip.MustParseAddr("1.2.3.4"), nil), "nil geo with country blocklist")
|
|
}
|
|
|
|
func TestFilter_Check_GeoUnavailable(t *testing.T) {
|
|
geo := &unavailableGeo{}
|
|
|
|
f := ParseFilter(FilterConfig{AllowedCountries: []string{"US"}})
|
|
assert.Equal(t, DenyGeoUnavailable, f.Check(netip.MustParseAddr("1.2.3.4"), geo), "unavailable geo with country allowlist")
|
|
|
|
f2 := ParseFilter(FilterConfig{BlockedCountries: []string{"CN"}})
|
|
assert.Equal(t, DenyGeoUnavailable, f2.Check(netip.MustParseAddr("1.2.3.4"), geo), "unavailable geo with country blocklist")
|
|
}
|
|
|
|
func TestFilter_Check_CIDROnlySkipsGeo(t *testing.T) {
|
|
f := ParseFilter(FilterConfig{AllowedCIDRs: []string{"10.0.0.0/8"}})
|
|
|
|
// CIDR-only filter should never touch geo, so nil geo is fine.
|
|
assert.Equal(t, Allow, f.Check(netip.MustParseAddr("10.1.2.3"), nil))
|
|
assert.Equal(t, DenyCIDR, f.Check(netip.MustParseAddr("192.168.1.1"), nil))
|
|
}
|
|
|
|
func TestFilter_Check_CIDRAllowThenCountryBlock(t *testing.T) {
|
|
geo := newMockGeo(map[string]string{
|
|
"10.1.2.3": "CN",
|
|
"10.2.3.4": "US",
|
|
})
|
|
f := ParseFilter(FilterConfig{AllowedCIDRs: []string{"10.0.0.0/8"}, BlockedCountries: []string{"CN"}})
|
|
|
|
assert.Equal(t, DenyCountry, f.Check(netip.MustParseAddr("10.1.2.3"), geo), "CIDR allowed but country blocked")
|
|
assert.Equal(t, Allow, f.Check(netip.MustParseAddr("10.2.3.4"), geo), "CIDR allowed and country not blocked")
|
|
assert.Equal(t, DenyCIDR, f.Check(netip.MustParseAddr("192.168.1.1"), geo), "CIDR denied before country check")
|
|
}
|
|
|
|
// TestFilter_Check_CrossCategoryAllowlistsAreAND documents the current
|
|
// behavior: when both a CIDR allowlist and a country allowlist are set, a
|
|
// request must satisfy BOTH to be allowed (AND across categories). There is no
|
|
// way today to express "allow if in allowed country OR in allowed CIDR", e.g.
|
|
// "allow all US traffic plus our office IP abroad". This is the gap an
|
|
// any/all allow-combine mode would close; the cases marked "GAP" are the ones
|
|
// that would flip to Allow under an "any" mode.
|
|
func TestFilter_Check_CrossCategoryAllowlistsAreAND(t *testing.T) {
|
|
officeAbroad := "203.0.113.7" // in allowed CIDR, but country not in allowlist
|
|
usOutsideOffice := "1.1.1.1" // allowed country, but not in allowed CIDR
|
|
usOffice := "203.0.113.8" // both
|
|
neither := "198.51.100.1" // neither
|
|
|
|
geo := newMockGeo(map[string]string{
|
|
officeAbroad: "DE",
|
|
usOutsideOffice: "US",
|
|
usOffice: "US",
|
|
neither: "CN",
|
|
})
|
|
f := ParseFilter(FilterConfig{
|
|
AllowedCIDRs: []string{"203.0.113.0/24"},
|
|
AllowedCountries: []string{"US"},
|
|
})
|
|
|
|
assert.Equal(t, Allow, f.Check(netip.MustParseAddr(usOffice), geo), "in allowed CIDR and allowed country")
|
|
assert.Equal(t, DenyCountry, f.Check(netip.MustParseAddr(officeAbroad), geo), "GAP: in allowed CIDR but country not allowed; any-mode should Allow")
|
|
assert.Equal(t, DenyCIDR, f.Check(netip.MustParseAddr(usOutsideOffice), geo), "GAP: allowed country but not in allowed CIDR; any-mode should Allow")
|
|
assert.Equal(t, DenyCIDR, f.Check(netip.MustParseAddr(neither), geo), "neither: denied under both modes")
|
|
}
|
|
|
|
// TestFilter_Check_CrossCategoryBlockAndAllow locks the current (all/AND)
|
|
// cross-category semantics that the evaluator must preserve: a blocklist match
|
|
// in any category denies regardless of allowlists, and blocklists across
|
|
// categories are effectively OR (a match in either denies).
|
|
func TestFilter_Check_CrossCategoryBlockAndAllow(t *testing.T) {
|
|
geo := newMockGeo(map[string]string{
|
|
"1.1.1.1": "US",
|
|
"10.1.2.3": "US",
|
|
"2.2.2.2": "CN",
|
|
"3.3.3.3": "US",
|
|
})
|
|
|
|
t.Run("country allowlist with CIDR blocklist", func(t *testing.T) {
|
|
f := ParseFilter(FilterConfig{
|
|
AllowedCountries: []string{"US"},
|
|
BlockedCIDRs: []string{"10.1.0.0/16"},
|
|
})
|
|
assert.Equal(t, Allow, f.Check(netip.MustParseAddr("1.1.1.1"), geo), "US and not in blocked CIDR")
|
|
assert.Equal(t, DenyCIDR, f.Check(netip.MustParseAddr("10.1.2.3"), geo), "US but in blocked CIDR, block wins")
|
|
assert.Equal(t, DenyCountry, f.Check(netip.MustParseAddr("2.2.2.2"), geo), "not in allowed country")
|
|
})
|
|
|
|
t.Run("blocklists across categories are OR", func(t *testing.T) {
|
|
f := ParseFilter(FilterConfig{
|
|
BlockedCIDRs: []string{"10.1.0.0/16"},
|
|
BlockedCountries: []string{"CN"},
|
|
})
|
|
assert.Equal(t, DenyCIDR, f.Check(netip.MustParseAddr("10.1.2.3"), geo), "in blocked CIDR")
|
|
assert.Equal(t, DenyCountry, f.Check(netip.MustParseAddr("2.2.2.2"), geo), "in blocked country")
|
|
assert.Equal(t, Allow, f.Check(netip.MustParseAddr("3.3.3.3"), geo), "in neither blocklist")
|
|
})
|
|
}
|
|
|
|
// TestFilter_Check_AllowCIDRPlusAllowCountryDeniesGeolessLAN documents a trap
|
|
// with all/AND mode: pairing an allowed CIDR (a private LAN) with an allowed
|
|
// country denies the LAN source, because a private IP has no country in the
|
|
// geo DB and an active country allowlist denies unknown countries. Under an
|
|
// "any" mode the CIDR match alone would admit it. This is the strongest reason
|
|
// allow-CIDR + allow-country usually wants OR, not AND.
|
|
func TestFilter_Check_AllowCIDRPlusAllowCountryDeniesGeolessLAN(t *testing.T) {
|
|
geo := newMockGeo(map[string]string{}) // no entries: every lookup is unknown country
|
|
f := ParseFilter(FilterConfig{
|
|
AllowedCIDRs: []string{"192.168.50.0/24"},
|
|
AllowedCountries: []string{"US"},
|
|
})
|
|
|
|
got := f.Check(netip.MustParseAddr("192.168.50.5"), geo)
|
|
assert.Equal(t, DenyCountry, got, "GAP: LAN source in allowed CIDR is denied by the country allowlist; any-mode should Allow")
|
|
}
|
|
|
|
func TestFilter_Check_AllowMatchAny(t *testing.T) {
|
|
bannedIP := "203.0.113.9"
|
|
geo := newMockGeo(map[string]string{
|
|
"1.1.1.1": "US", // allowed country, outside allowed CIDR
|
|
"203.0.113.7": "DE", // allowed CIDR, non-allowed country
|
|
"203.0.113.8": "US", // both
|
|
bannedIP: "US", // allowed CIDR, but CrowdSec-banned
|
|
"198.51.100.1": "CN", // neither
|
|
"2.2.2.2": "CN", // blocked country, but in allowed CIDR
|
|
})
|
|
|
|
tests := []struct {
|
|
name string
|
|
config FilterConfig
|
|
addr string
|
|
geo GeoResolver
|
|
want Verdict
|
|
}{
|
|
{
|
|
name: "in allowed CIDR only",
|
|
config: FilterConfig{AllowMatch: AllowMatchAny, AllowedCIDRs: []string{"203.0.113.0/24"}, AllowedCountries: []string{"US"}},
|
|
addr: "203.0.113.7", geo: geo, want: Allow,
|
|
},
|
|
{
|
|
name: "in allowed country only",
|
|
config: FilterConfig{AllowMatch: AllowMatchAny, AllowedCIDRs: []string{"203.0.113.0/24"}, AllowedCountries: []string{"US"}},
|
|
addr: "1.1.1.1", geo: geo, want: Allow,
|
|
},
|
|
{
|
|
name: "in both",
|
|
config: FilterConfig{AllowMatch: AllowMatchAny, AllowedCIDRs: []string{"203.0.113.0/24"}, AllowedCountries: []string{"US"}},
|
|
addr: "203.0.113.8", geo: geo, want: Allow,
|
|
},
|
|
{
|
|
name: "in neither",
|
|
config: FilterConfig{AllowMatch: AllowMatchAny, AllowedCIDRs: []string{"203.0.113.0/24"}, AllowedCountries: []string{"US"}},
|
|
addr: "198.51.100.1", geo: geo, want: DenyCIDR,
|
|
},
|
|
{
|
|
name: "geoless LAN admitted via CIDR (the #597 trap, fixed)",
|
|
config: FilterConfig{AllowMatch: AllowMatchAny, AllowedCIDRs: []string{"192.168.50.0/24"}, AllowedCountries: []string{"US"}},
|
|
addr: "192.168.50.5", geo: newMockGeo(map[string]string{}), want: Allow,
|
|
},
|
|
{
|
|
name: "CIDR match short-circuits geo when geo unavailable",
|
|
config: FilterConfig{AllowMatch: AllowMatchAny, AllowedCIDRs: []string{"203.0.113.0/24"}, AllowedCountries: []string{"US"}},
|
|
addr: "203.0.113.7", geo: &unavailableGeo{}, want: Allow,
|
|
},
|
|
{
|
|
name: "geo unavailable fails closed when CIDR does not match",
|
|
config: FilterConfig{AllowMatch: AllowMatchAny, AllowedCIDRs: []string{"203.0.113.0/24"}, AllowedCountries: []string{"US"}},
|
|
addr: "1.1.1.1", geo: &unavailableGeo{}, want: DenyGeoUnavailable,
|
|
},
|
|
{
|
|
name: "block gate wins over allowed CIDR (blocked country)",
|
|
config: FilterConfig{AllowMatch: AllowMatchAny, AllowedCIDRs: []string{"0.0.0.0/0"}, BlockedCountries: []string{"CN"}},
|
|
addr: "2.2.2.2", geo: geo, want: DenyCountry,
|
|
},
|
|
{
|
|
name: "block gate wins over allowed country (blocked CIDR)",
|
|
config: FilterConfig{AllowMatch: AllowMatchAny, AllowedCountries: []string{"US"}, BlockedCIDRs: []string{"203.0.113.0/24"}},
|
|
addr: "203.0.113.8", geo: geo, want: DenyCIDR,
|
|
},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
f := ParseFilter(tc.config)
|
|
assert.Equal(t, tc.want, f.Check(netip.MustParseAddr(tc.addr), tc.geo))
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestFilter_Check_AllowMatchAny_CrowdSecStillRuns(t *testing.T) {
|
|
bannedIP := "203.0.113.9"
|
|
cs := &mockCrowdSec{decisions: map[string]*CrowdSecDecision{bannedIP: {Type: DecisionBan}}, ready: true}
|
|
geo := newMockGeo(map[string]string{bannedIP: "US", "203.0.113.7": "US"})
|
|
|
|
f := ParseFilter(FilterConfig{
|
|
AllowMatch: AllowMatchAny,
|
|
AllowedCIDRs: []string{"203.0.113.0/24"},
|
|
CrowdSec: cs,
|
|
CrowdSecMode: CrowdSecEnforce,
|
|
})
|
|
assert.Equal(t, DenyCrowdSecBan, f.Check(netip.MustParseAddr(bannedIP), geo), "CrowdSec ban denies even when allowlist admits")
|
|
assert.Equal(t, Allow, f.Check(netip.MustParseAddr("203.0.113.7"), geo), "clean IP in allowed CIDR is allowed")
|
|
}
|
|
|
|
func TestFilter_Check_UnknownAllowMatchDefaultsToAll(t *testing.T) {
|
|
// An unrecognized allow-combine mode must fall back to the restrictive
|
|
// AND default, never loosen access.
|
|
geo := newMockGeo(map[string]string{"203.0.113.7": "DE"})
|
|
f := ParseFilter(FilterConfig{
|
|
AllowMatch: AllowMatch("bogus"),
|
|
AllowedCIDRs: []string{"203.0.113.0/24"},
|
|
AllowedCountries: []string{"US"},
|
|
})
|
|
assert.Equal(t, AllowMatchAll, f.AllowMatch, "unknown mode normalizes to all")
|
|
assert.Equal(t, DenyCountry, f.Check(netip.MustParseAddr("203.0.113.7"), geo), "AND semantics: in CIDR but wrong country denied")
|
|
}
|
|
|
|
func TestParseFilter_Empty(t *testing.T) {
|
|
f := ParseFilter(FilterConfig{})
|
|
assert.Nil(t, f)
|
|
}
|
|
|
|
func TestParseFilter_InvalidCIDR(t *testing.T) {
|
|
f := ParseFilter(FilterConfig{AllowedCIDRs: []string{"invalid", "10.0.0.0/8"}})
|
|
|
|
assert.NotNil(t, f)
|
|
assert.Len(t, f.AllowedCIDRs, 1, "invalid CIDR should be skipped")
|
|
assert.Equal(t, netip.MustParsePrefix("10.0.0.0/8"), f.AllowedCIDRs[0])
|
|
}
|
|
|
|
func TestFilter_HasRestrictions(t *testing.T) {
|
|
assert.False(t, (*Filter)(nil).HasRestrictions())
|
|
assert.False(t, (&Filter{}).HasRestrictions())
|
|
assert.True(t, ParseFilter(FilterConfig{AllowedCIDRs: []string{"10.0.0.0/8"}}).HasRestrictions())
|
|
assert.True(t, ParseFilter(FilterConfig{AllowedCountries: []string{"US"}}).HasRestrictions())
|
|
}
|
|
|
|
func TestFilter_Check_IPv6CIDR(t *testing.T) {
|
|
f := ParseFilter(FilterConfig{AllowedCIDRs: []string{"2001:db8::/32"}})
|
|
|
|
assert.Equal(t, Allow, f.Check(netip.MustParseAddr("2001:db8::1"), nil), "v6 addr in v6 allowlist")
|
|
assert.Equal(t, DenyCIDR, f.Check(netip.MustParseAddr("2001:db9::1"), nil), "v6 addr not in v6 allowlist")
|
|
assert.Equal(t, DenyCIDR, f.Check(netip.MustParseAddr("10.1.2.3"), nil), "v4 addr not in v6 allowlist")
|
|
}
|
|
|
|
func TestFilter_Check_IPv4MappedIPv6(t *testing.T) {
|
|
f := ParseFilter(FilterConfig{AllowedCIDRs: []string{"10.0.0.0/8"}})
|
|
|
|
// A v4-mapped-v6 address like ::ffff:10.1.2.3 must match a v4 CIDR.
|
|
v4mapped := netip.MustParseAddr("::ffff:10.1.2.3")
|
|
assert.True(t, v4mapped.Is4In6(), "precondition: address is v4-in-v6")
|
|
assert.Equal(t, Allow, f.Check(v4mapped, nil), "v4-mapped-v6 must match v4 CIDR after Unmap")
|
|
|
|
v4mappedOutside := netip.MustParseAddr("::ffff:192.168.1.1")
|
|
assert.Equal(t, DenyCIDR, f.Check(v4mappedOutside, nil), "v4-mapped-v6 outside v4 CIDR")
|
|
}
|
|
|
|
func TestFilter_Check_MixedV4V6CIDRs(t *testing.T) {
|
|
f := ParseFilter(FilterConfig{AllowedCIDRs: []string{"10.0.0.0/8", "2001:db8::/32"}})
|
|
|
|
assert.Equal(t, Allow, f.Check(netip.MustParseAddr("10.1.2.3"), nil), "v4 in v4 CIDR")
|
|
assert.Equal(t, Allow, f.Check(netip.MustParseAddr("2001:db8::1"), nil), "v6 in v6 CIDR")
|
|
assert.Equal(t, Allow, f.Check(netip.MustParseAddr("::ffff:10.1.2.3"), nil), "v4-mapped matches v4 CIDR")
|
|
assert.Equal(t, DenyCIDR, f.Check(netip.MustParseAddr("192.168.1.1"), nil), "v4 not in either CIDR")
|
|
assert.Equal(t, DenyCIDR, f.Check(netip.MustParseAddr("fe80::1"), nil), "v6 not in either CIDR")
|
|
}
|
|
|
|
func TestParseFilter_CanonicalizesNonMaskedCIDR(t *testing.T) {
|
|
// 1.1.1.1/24 has host bits set; ParseFilter should canonicalize to 1.1.1.0/24.
|
|
f := ParseFilter(FilterConfig{AllowedCIDRs: []string{"1.1.1.1/24"}})
|
|
assert.Equal(t, netip.MustParsePrefix("1.1.1.0/24"), f.AllowedCIDRs[0])
|
|
|
|
// Verify it still matches correctly.
|
|
assert.Equal(t, Allow, f.Check(netip.MustParseAddr("1.1.1.100"), nil))
|
|
assert.Equal(t, DenyCIDR, f.Check(netip.MustParseAddr("1.1.2.1"), nil))
|
|
}
|
|
|
|
func TestFilter_Check_CountryCodeCaseInsensitive(t *testing.T) {
|
|
geo := newMockGeo(map[string]string{
|
|
"1.1.1.1": "US",
|
|
"2.2.2.2": "DE",
|
|
"3.3.3.3": "CN",
|
|
})
|
|
|
|
tests := []struct {
|
|
name string
|
|
allowedCountries []string
|
|
blockedCountries []string
|
|
addr string
|
|
want Verdict
|
|
}{
|
|
{
|
|
name: "lowercase allowlist matches uppercase MaxMind code",
|
|
allowedCountries: []string{"us", "de"},
|
|
addr: "1.1.1.1",
|
|
want: Allow,
|
|
},
|
|
{
|
|
name: "mixed-case allowlist matches",
|
|
allowedCountries: []string{"Us", "dE"},
|
|
addr: "2.2.2.2",
|
|
want: Allow,
|
|
},
|
|
{
|
|
name: "lowercase allowlist rejects non-matching country",
|
|
allowedCountries: []string{"us", "de"},
|
|
addr: "3.3.3.3",
|
|
want: DenyCountry,
|
|
},
|
|
{
|
|
name: "lowercase blocklist blocks matching country",
|
|
blockedCountries: []string{"cn"},
|
|
addr: "3.3.3.3",
|
|
want: DenyCountry,
|
|
},
|
|
{
|
|
name: "mixed-case blocklist blocks matching country",
|
|
blockedCountries: []string{"Cn"},
|
|
addr: "3.3.3.3",
|
|
want: DenyCountry,
|
|
},
|
|
{
|
|
name: "lowercase blocklist does not block non-matching country",
|
|
blockedCountries: []string{"cn"},
|
|
addr: "1.1.1.1",
|
|
want: Allow,
|
|
},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
f := ParseFilter(FilterConfig{AllowedCountries: tc.allowedCountries, BlockedCountries: tc.blockedCountries})
|
|
got := f.Check(netip.MustParseAddr(tc.addr), geo)
|
|
assert.Equal(t, tc.want, got)
|
|
})
|
|
}
|
|
}
|
|
|
|
// unavailableGeo simulates a GeoResolver whose database is not loaded.
|
|
type unavailableGeo struct{}
|
|
|
|
func (u *unavailableGeo) LookupAddr(_ netip.Addr) geolocation.Result { return geolocation.Result{} }
|
|
func (u *unavailableGeo) Available() bool { return false }
|
|
|
|
// mockCrowdSec is a test implementation of CrowdSecChecker.
|
|
type mockCrowdSec struct {
|
|
decisions map[string]*CrowdSecDecision
|
|
ready bool
|
|
}
|
|
|
|
func (m *mockCrowdSec) CheckIP(addr netip.Addr) *CrowdSecDecision {
|
|
return m.decisions[addr.Unmap().String()]
|
|
}
|
|
|
|
func (m *mockCrowdSec) Ready() bool { return m.ready }
|
|
|
|
func TestFilter_CrowdSec_Enforce_Ban(t *testing.T) {
|
|
cs := &mockCrowdSec{
|
|
decisions: map[string]*CrowdSecDecision{"1.2.3.4": {Type: DecisionBan}},
|
|
ready: true,
|
|
}
|
|
f := ParseFilter(FilterConfig{CrowdSec: cs, CrowdSecMode: CrowdSecEnforce})
|
|
|
|
assert.Equal(t, DenyCrowdSecBan, f.Check(netip.MustParseAddr("1.2.3.4"), nil))
|
|
assert.Equal(t, Allow, f.Check(netip.MustParseAddr("5.6.7.8"), nil))
|
|
}
|
|
|
|
func TestFilter_CrowdSec_Enforce_Captcha(t *testing.T) {
|
|
cs := &mockCrowdSec{
|
|
decisions: map[string]*CrowdSecDecision{"1.2.3.4": {Type: DecisionCaptcha}},
|
|
ready: true,
|
|
}
|
|
f := ParseFilter(FilterConfig{CrowdSec: cs, CrowdSecMode: CrowdSecEnforce})
|
|
|
|
assert.Equal(t, DenyCrowdSecCaptcha, f.Check(netip.MustParseAddr("1.2.3.4"), nil))
|
|
}
|
|
|
|
func TestFilter_CrowdSec_Enforce_Throttle(t *testing.T) {
|
|
cs := &mockCrowdSec{
|
|
decisions: map[string]*CrowdSecDecision{"1.2.3.4": {Type: DecisionThrottle}},
|
|
ready: true,
|
|
}
|
|
f := ParseFilter(FilterConfig{CrowdSec: cs, CrowdSecMode: CrowdSecEnforce})
|
|
|
|
assert.Equal(t, DenyCrowdSecThrottle, f.Check(netip.MustParseAddr("1.2.3.4"), nil))
|
|
}
|
|
|
|
func TestFilter_CrowdSec_Observe_DoesNotBlock(t *testing.T) {
|
|
cs := &mockCrowdSec{
|
|
decisions: map[string]*CrowdSecDecision{"1.2.3.4": {Type: DecisionBan}},
|
|
ready: true,
|
|
}
|
|
f := ParseFilter(FilterConfig{CrowdSec: cs, CrowdSecMode: CrowdSecObserve})
|
|
|
|
verdict := f.Check(netip.MustParseAddr("1.2.3.4"), nil)
|
|
assert.Equal(t, DenyCrowdSecBan, verdict, "verdict should be ban")
|
|
assert.True(t, f.IsObserveOnly(verdict), "should be observe-only")
|
|
}
|
|
|
|
func TestFilter_CrowdSec_Enforce_NotReady(t *testing.T) {
|
|
cs := &mockCrowdSec{ready: false}
|
|
f := ParseFilter(FilterConfig{CrowdSec: cs, CrowdSecMode: CrowdSecEnforce})
|
|
|
|
assert.Equal(t, DenyCrowdSecUnavailable, f.Check(netip.MustParseAddr("1.2.3.4"), nil))
|
|
}
|
|
|
|
func TestFilter_CrowdSec_Observe_NotReady_Allows(t *testing.T) {
|
|
cs := &mockCrowdSec{ready: false}
|
|
f := ParseFilter(FilterConfig{CrowdSec: cs, CrowdSecMode: CrowdSecObserve})
|
|
|
|
assert.Equal(t, Allow, f.Check(netip.MustParseAddr("1.2.3.4"), nil))
|
|
}
|
|
|
|
func TestFilter_CrowdSec_Off(t *testing.T) {
|
|
cs := &mockCrowdSec{
|
|
decisions: map[string]*CrowdSecDecision{"1.2.3.4": {Type: DecisionBan}},
|
|
ready: true,
|
|
}
|
|
f := ParseFilter(FilterConfig{CrowdSec: cs, CrowdSecMode: CrowdSecOff})
|
|
|
|
// CrowdSecOff means the filter is nil (no restrictions).
|
|
assert.Nil(t, f)
|
|
}
|
|
|
|
func TestFilter_IsObserveOnly(t *testing.T) {
|
|
f := &Filter{CrowdSecMode: CrowdSecObserve}
|
|
assert.True(t, f.IsObserveOnly(DenyCrowdSecBan))
|
|
assert.True(t, f.IsObserveOnly(DenyCrowdSecCaptcha))
|
|
assert.True(t, f.IsObserveOnly(DenyCrowdSecThrottle))
|
|
assert.True(t, f.IsObserveOnly(DenyCrowdSecUnavailable))
|
|
assert.False(t, f.IsObserveOnly(DenyCIDR))
|
|
assert.False(t, f.IsObserveOnly(Allow))
|
|
|
|
f2 := &Filter{CrowdSecMode: CrowdSecEnforce}
|
|
assert.False(t, f2.IsObserveOnly(DenyCrowdSecBan))
|
|
}
|
|
|
|
// TestFilter_LayerInteraction exercises the evaluation order across all three
|
|
// restriction layers: CIDR -> Country -> CrowdSec. Each layer can only further
|
|
// restrict; no layer can relax a denial from an earlier layer.
|
|
//
|
|
// Layer order | Behavior
|
|
// ---------------|-------------------------------------------------------
|
|
// 1. CIDR | Allowlist narrows to specific ranges, blocklist removes
|
|
// | specific ranges. Deny here → stop, CrowdSec never runs.
|
|
// 2. Country | Allowlist/blocklist by geo. Deny here → stop.
|
|
// 3. CrowdSec | IP reputation. Can block IPs that passed layers 1-2.
|
|
// | Observe mode: verdict returned but caller doesn't block.
|
|
func TestFilter_LayerInteraction(t *testing.T) {
|
|
bannedIP := "10.1.2.3"
|
|
cleanIP := "10.2.3.4"
|
|
outsideIP := "192.168.1.1"
|
|
|
|
cs := &mockCrowdSec{
|
|
decisions: map[string]*CrowdSecDecision{bannedIP: {Type: DecisionBan}},
|
|
ready: true,
|
|
}
|
|
geo := newMockGeo(map[string]string{
|
|
bannedIP: "US",
|
|
cleanIP: "US",
|
|
outsideIP: "CN",
|
|
})
|
|
|
|
tests := []struct {
|
|
name string
|
|
config FilterConfig
|
|
addr string
|
|
want Verdict
|
|
}{
|
|
// CIDR allowlist + CrowdSec enforce: CrowdSec blocks inside allowed range
|
|
{
|
|
name: "allowed CIDR + CrowdSec banned",
|
|
config: FilterConfig{AllowedCIDRs: []string{"10.0.0.0/8"}, CrowdSec: cs, CrowdSecMode: CrowdSecEnforce},
|
|
addr: bannedIP,
|
|
want: DenyCrowdSecBan,
|
|
},
|
|
{
|
|
name: "allowed CIDR + CrowdSec clean",
|
|
config: FilterConfig{AllowedCIDRs: []string{"10.0.0.0/8"}, CrowdSec: cs, CrowdSecMode: CrowdSecEnforce},
|
|
addr: cleanIP,
|
|
want: Allow,
|
|
},
|
|
{
|
|
name: "CIDR deny stops before CrowdSec",
|
|
config: FilterConfig{AllowedCIDRs: []string{"10.0.0.0/8"}, CrowdSec: cs, CrowdSecMode: CrowdSecEnforce},
|
|
addr: outsideIP,
|
|
want: DenyCIDR,
|
|
},
|
|
|
|
// CIDR blocklist + CrowdSec enforce: blocklist blocks first, CrowdSec blocks remaining
|
|
{
|
|
name: "blocked CIDR stops before CrowdSec",
|
|
config: FilterConfig{BlockedCIDRs: []string{"10.1.0.0/16"}, CrowdSec: cs, CrowdSecMode: CrowdSecEnforce},
|
|
addr: bannedIP,
|
|
want: DenyCIDR,
|
|
},
|
|
{
|
|
name: "not in blocklist + CrowdSec clean",
|
|
config: FilterConfig{BlockedCIDRs: []string{"10.1.0.0/16"}, CrowdSec: cs, CrowdSecMode: CrowdSecEnforce},
|
|
addr: cleanIP,
|
|
want: Allow,
|
|
},
|
|
|
|
// Country allowlist + CrowdSec enforce
|
|
{
|
|
name: "allowed country + CrowdSec banned",
|
|
config: FilterConfig{AllowedCountries: []string{"US"}, CrowdSec: cs, CrowdSecMode: CrowdSecEnforce},
|
|
addr: bannedIP,
|
|
want: DenyCrowdSecBan,
|
|
},
|
|
{
|
|
name: "country deny stops before CrowdSec",
|
|
config: FilterConfig{AllowedCountries: []string{"US"}, CrowdSec: cs, CrowdSecMode: CrowdSecEnforce},
|
|
addr: outsideIP,
|
|
want: DenyCountry,
|
|
},
|
|
|
|
// All three layers: CIDR allowlist + country blocklist + CrowdSec
|
|
{
|
|
name: "all layers: CIDR allow + country allow + CrowdSec ban",
|
|
config: FilterConfig{
|
|
AllowedCIDRs: []string{"10.0.0.0/8"},
|
|
BlockedCountries: []string{"CN"},
|
|
CrowdSec: cs,
|
|
CrowdSecMode: CrowdSecEnforce,
|
|
},
|
|
addr: bannedIP, // 10.x (CIDR ok), US (country ok), banned (CrowdSec deny)
|
|
want: DenyCrowdSecBan,
|
|
},
|
|
{
|
|
name: "all layers: CIDR deny short-circuits everything",
|
|
config: FilterConfig{
|
|
AllowedCIDRs: []string{"10.0.0.0/8"},
|
|
BlockedCountries: []string{"CN"},
|
|
CrowdSec: cs,
|
|
CrowdSecMode: CrowdSecEnforce,
|
|
},
|
|
addr: outsideIP, // 192.x (CIDR deny)
|
|
want: DenyCIDR,
|
|
},
|
|
|
|
// Observe mode: verdict returned but IsObserveOnly is true
|
|
{
|
|
name: "observe mode: CrowdSec banned inside allowed CIDR",
|
|
config: FilterConfig{AllowedCIDRs: []string{"10.0.0.0/8"}, CrowdSec: cs, CrowdSecMode: CrowdSecObserve},
|
|
addr: bannedIP,
|
|
want: DenyCrowdSecBan, // verdict is ban, caller checks IsObserveOnly
|
|
},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
f := ParseFilter(tc.config)
|
|
got := f.Check(netip.MustParseAddr(tc.addr), geo)
|
|
assert.Equal(t, tc.want, got)
|
|
|
|
// Verify observe mode flag when applicable.
|
|
if tc.config.CrowdSecMode == CrowdSecObserve && got.IsCrowdSec() {
|
|
assert.True(t, f.IsObserveOnly(got), "observe mode verdict should be observe-only")
|
|
}
|
|
if tc.config.CrowdSecMode == CrowdSecEnforce && got.IsCrowdSec() {
|
|
assert.False(t, f.IsObserveOnly(got), "enforce mode verdict should not be observe-only")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestFilter_CrowdSec_Enforce_NilChecker(t *testing.T) {
|
|
// LAPI not configured: checker is nil but mode is enforce. Must fail closed.
|
|
f := ParseFilter(FilterConfig{CrowdSec: nil, CrowdSecMode: CrowdSecEnforce})
|
|
|
|
assert.Equal(t, DenyCrowdSecUnavailable, f.Check(netip.MustParseAddr("1.2.3.4"), nil))
|
|
}
|
|
|
|
func TestFilter_CrowdSec_Observe_NilChecker(t *testing.T) {
|
|
// LAPI not configured: checker is nil but mode is observe. Must allow.
|
|
f := ParseFilter(FilterConfig{CrowdSec: nil, CrowdSecMode: CrowdSecObserve})
|
|
|
|
assert.Equal(t, Allow, f.Check(netip.MustParseAddr("1.2.3.4"), nil))
|
|
}
|
|
|
|
func TestFilter_CheckCIDR_AllowsWithoutCountryOrCrowdSec(t *testing.T) {
|
|
cs := &mockCrowdSec{ready: true, decisions: map[string]*CrowdSecDecision{
|
|
"100.64.5.6": {Type: DecisionBan},
|
|
}}
|
|
f := ParseFilter(FilterConfig{
|
|
AllowedCIDRs: []string{"100.64.0.0/10"},
|
|
AllowedCountries: []string{"US"},
|
|
CrowdSec: cs,
|
|
CrowdSecMode: CrowdSecEnforce,
|
|
})
|
|
|
|
// CheckCIDR skips country + CrowdSec evaluation: an address inside
|
|
// the allowed CIDR passes even when it would be denied by CrowdSec
|
|
// or by the country allowlist (CGNAT addresses have no geo data).
|
|
assert.Equal(t, Allow, f.CheckCIDR(netip.MustParseAddr("100.64.5.6")),
|
|
"CheckCIDR must not run CrowdSec lookups on overlay traffic")
|
|
|
|
// CIDR denials still fire.
|
|
assert.Equal(t, DenyCIDR, f.CheckCIDR(netip.MustParseAddr("198.51.100.1")),
|
|
"CheckCIDR must still reject addresses outside the allow list")
|
|
}
|
|
|
|
func TestFilter_CheckCIDR_NilFilter(t *testing.T) {
|
|
var f *Filter
|
|
assert.Equal(t, Allow, f.CheckCIDR(netip.MustParseAddr("100.64.5.6")),
|
|
"CheckCIDR on a nil filter must allow")
|
|
}
|
|
|
|
func TestFilter_HasRestrictions_CrowdSec(t *testing.T) {
|
|
cs := &mockCrowdSec{ready: true}
|
|
f := ParseFilter(FilterConfig{CrowdSec: cs, CrowdSecMode: CrowdSecEnforce})
|
|
assert.True(t, f.HasRestrictions())
|
|
|
|
// Enforce mode without checker (LAPI not configured): still has restrictions
|
|
// because Check() will fail-closed with DenyCrowdSecUnavailable.
|
|
f2 := ParseFilter(FilterConfig{CrowdSec: nil, CrowdSecMode: CrowdSecEnforce})
|
|
assert.True(t, f2.HasRestrictions())
|
|
}
|
|
|
|
// countingGeo records how many times an address was resolved.
|
|
type countingGeo struct {
|
|
countries map[string]string
|
|
lookups int
|
|
}
|
|
|
|
func (c *countingGeo) LookupAddr(addr netip.Addr) geolocation.Result {
|
|
c.lookups++
|
|
return geolocation.Result{CountryCode: c.countries[addr.String()]}
|
|
}
|
|
|
|
func (c *countingGeo) Available() bool { return true }
|
|
|
|
// The geo lookup is the expensive part of the check and runs per connection, so
|
|
// "any" mode must resolve the country once and share it between the blocklist
|
|
// and the allowlist, the way "all" mode does.
|
|
func TestCheck_AnyResolvesCountryOnce(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
ip string
|
|
want Verdict
|
|
wantLookups int
|
|
}{
|
|
{"blocked and allowed lists both active", "203.0.113.1", Allow, 1},
|
|
{"blocked country denies", "198.51.100.1", DenyCountry, 1},
|
|
{"neither allowlist matches", "192.0.2.1", DenyCIDR, 1},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
geo := &countingGeo{countries: map[string]string{
|
|
"203.0.113.1": "US",
|
|
"198.51.100.1": "CN",
|
|
"192.0.2.1": "FR",
|
|
}}
|
|
f := ParseFilter(FilterConfig{
|
|
AllowedCIDRs: []string{"10.0.0.0/8"},
|
|
AllowedCountries: []string{"US"},
|
|
BlockedCountries: []string{"CN"},
|
|
AllowMatch: AllowMatchAny,
|
|
})
|
|
require.NotNil(t, f)
|
|
|
|
assert.Equal(t, tt.want, f.Check(netip.MustParseAddr(tt.ip), geo))
|
|
assert.Equal(t, tt.wantLookups, geo.lookups, "the country must be resolved at most once per check")
|
|
})
|
|
}
|
|
}
|
|
|
|
// A matching allowed CIDR short-circuits the allowlist, so with no country
|
|
// blocklist configured there is nothing left to resolve.
|
|
func TestCheck_AnySkipsLookupWhenCIDRAdmits(t *testing.T) {
|
|
geo := &countingGeo{countries: map[string]string{"10.1.2.3": "US"}}
|
|
f := ParseFilter(FilterConfig{
|
|
AllowedCIDRs: []string{"10.0.0.0/8"},
|
|
AllowedCountries: []string{"DE"},
|
|
AllowMatch: AllowMatchAny,
|
|
})
|
|
require.NotNil(t, f)
|
|
|
|
assert.Equal(t, Allow, f.Check(netip.MustParseAddr("10.1.2.3"), geo))
|
|
assert.Zero(t, geo.lookups, "an admitted CIDR needs no geo lookup")
|
|
}
|
|
|
|
// The blocklist is a hard gate, so it is consulted even when a CIDR allowlist
|
|
// already admitted the address.
|
|
func TestCheck_AnyBlocklistOutranksAllowedCIDR(t *testing.T) {
|
|
geo := &countingGeo{countries: map[string]string{"10.1.2.3": "CN"}}
|
|
f := ParseFilter(FilterConfig{
|
|
AllowedCIDRs: []string{"10.0.0.0/8"},
|
|
BlockedCountries: []string{"CN"},
|
|
AllowMatch: AllowMatchAny,
|
|
})
|
|
require.NotNil(t, f)
|
|
|
|
assert.Equal(t, DenyCountry, f.Check(netip.MustParseAddr("10.1.2.3"), geo))
|
|
assert.Equal(t, 1, geo.lookups)
|
|
}
|