mirror of
https://github.com/netbirdio/netbird.git
synced 2026-04-16 07:16:38 +00:00
[management, proxy] Add CrowdSec IP reputation integration for reverse proxy (#5722)
This commit is contained in:
@@ -12,12 +12,44 @@ import (
|
||||
"github.com/netbirdio/netbird/proxy/internal/geolocation"
|
||||
)
|
||||
|
||||
// defaultLogger is used when no logger is provided to ParseFilter.
|
||||
var defaultLogger = log.NewEntry(log.StandardLogger())
|
||||
|
||||
// GeoResolver resolves an IP address to geographic information.
|
||||
type GeoResolver interface {
|
||||
LookupAddr(addr netip.Addr) geolocation.Result
|
||||
Available() bool
|
||||
}
|
||||
|
||||
// DecisionType is the type of CrowdSec remediation action.
|
||||
type DecisionType string
|
||||
|
||||
const (
|
||||
DecisionBan DecisionType = "ban"
|
||||
DecisionCaptcha DecisionType = "captcha"
|
||||
DecisionThrottle DecisionType = "throttle"
|
||||
)
|
||||
|
||||
// CrowdSecDecision holds the type of a CrowdSec decision.
|
||||
type CrowdSecDecision struct {
|
||||
Type DecisionType
|
||||
}
|
||||
|
||||
// CrowdSecChecker queries CrowdSec decisions for an IP address.
|
||||
type CrowdSecChecker interface {
|
||||
CheckIP(addr netip.Addr) *CrowdSecDecision
|
||||
Ready() bool
|
||||
}
|
||||
|
||||
// CrowdSecMode is the per-service enforcement mode.
|
||||
type CrowdSecMode string
|
||||
|
||||
const (
|
||||
CrowdSecOff CrowdSecMode = ""
|
||||
CrowdSecEnforce CrowdSecMode = "enforce"
|
||||
CrowdSecObserve CrowdSecMode = "observe"
|
||||
)
|
||||
|
||||
// Filter evaluates IP restrictions. CIDR checks are performed first
|
||||
// (cheap), followed by country lookups (more expensive) only when needed.
|
||||
type Filter struct {
|
||||
@@ -25,32 +57,55 @@ type Filter struct {
|
||||
BlockedCIDRs []netip.Prefix
|
||||
AllowedCountries []string
|
||||
BlockedCountries []string
|
||||
CrowdSec CrowdSecChecker
|
||||
CrowdSecMode CrowdSecMode
|
||||
}
|
||||
|
||||
// ParseFilter builds a Filter from the raw string slices. Returns nil
|
||||
// if all slices are empty.
|
||||
func ParseFilter(allowedCIDRs, blockedCIDRs, allowedCountries, blockedCountries []string) *Filter {
|
||||
if len(allowedCIDRs) == 0 && len(blockedCIDRs) == 0 &&
|
||||
len(allowedCountries) == 0 && len(blockedCountries) == 0 {
|
||||
// FilterConfig holds the raw configuration for building a Filter.
|
||||
type FilterConfig struct {
|
||||
AllowedCIDRs []string
|
||||
BlockedCIDRs []string
|
||||
AllowedCountries []string
|
||||
BlockedCountries []string
|
||||
CrowdSec CrowdSecChecker
|
||||
CrowdSecMode CrowdSecMode
|
||||
Logger *log.Entry
|
||||
}
|
||||
|
||||
// ParseFilter builds a Filter from the config. Returns nil if no restrictions
|
||||
// are configured.
|
||||
func ParseFilter(cfg FilterConfig) *Filter {
|
||||
hasCS := cfg.CrowdSecMode == CrowdSecEnforce || cfg.CrowdSecMode == CrowdSecObserve
|
||||
if len(cfg.AllowedCIDRs) == 0 && len(cfg.BlockedCIDRs) == 0 &&
|
||||
len(cfg.AllowedCountries) == 0 && len(cfg.BlockedCountries) == 0 && !hasCS {
|
||||
return nil
|
||||
}
|
||||
|
||||
f := &Filter{
|
||||
AllowedCountries: normalizeCountryCodes(allowedCountries),
|
||||
BlockedCountries: normalizeCountryCodes(blockedCountries),
|
||||
logger := cfg.Logger
|
||||
if logger == nil {
|
||||
logger = defaultLogger
|
||||
}
|
||||
for _, cidr := range allowedCIDRs {
|
||||
|
||||
f := &Filter{
|
||||
AllowedCountries: normalizeCountryCodes(cfg.AllowedCountries),
|
||||
BlockedCountries: normalizeCountryCodes(cfg.BlockedCountries),
|
||||
}
|
||||
if hasCS {
|
||||
f.CrowdSec = cfg.CrowdSec
|
||||
f.CrowdSecMode = cfg.CrowdSecMode
|
||||
}
|
||||
for _, cidr := range cfg.AllowedCIDRs {
|
||||
prefix, err := netip.ParsePrefix(cidr)
|
||||
if err != nil {
|
||||
log.Warnf("skip invalid allowed CIDR %q: %v", cidr, err)
|
||||
logger.Warnf("skip invalid allowed CIDR %q: %v", cidr, err)
|
||||
continue
|
||||
}
|
||||
f.AllowedCIDRs = append(f.AllowedCIDRs, prefix.Masked())
|
||||
}
|
||||
for _, cidr := range blockedCIDRs {
|
||||
for _, cidr := range cfg.BlockedCIDRs {
|
||||
prefix, err := netip.ParsePrefix(cidr)
|
||||
if err != nil {
|
||||
log.Warnf("skip invalid blocked CIDR %q: %v", cidr, err)
|
||||
logger.Warnf("skip invalid blocked CIDR %q: %v", cidr, err)
|
||||
continue
|
||||
}
|
||||
f.BlockedCIDRs = append(f.BlockedCIDRs, prefix.Masked())
|
||||
@@ -82,6 +137,15 @@ const (
|
||||
// DenyGeoUnavailable indicates that country restrictions are configured
|
||||
// but the geo lookup is unavailable.
|
||||
DenyGeoUnavailable
|
||||
// DenyCrowdSecBan indicates a CrowdSec "ban" decision.
|
||||
DenyCrowdSecBan
|
||||
// DenyCrowdSecCaptcha indicates a CrowdSec "captcha" decision.
|
||||
DenyCrowdSecCaptcha
|
||||
// DenyCrowdSecThrottle indicates a CrowdSec "throttle" decision.
|
||||
DenyCrowdSecThrottle
|
||||
// DenyCrowdSecUnavailable indicates enforce mode but the bouncer has not
|
||||
// completed its initial sync.
|
||||
DenyCrowdSecUnavailable
|
||||
)
|
||||
|
||||
// String returns the deny reason string matching the HTTP auth mechanism names.
|
||||
@@ -95,14 +159,42 @@ func (v Verdict) String() string {
|
||||
return "country_restricted"
|
||||
case DenyGeoUnavailable:
|
||||
return "geo_unavailable"
|
||||
case DenyCrowdSecBan:
|
||||
return "crowdsec_ban"
|
||||
case DenyCrowdSecCaptcha:
|
||||
return "crowdsec_captcha"
|
||||
case DenyCrowdSecThrottle:
|
||||
return "crowdsec_throttle"
|
||||
case DenyCrowdSecUnavailable:
|
||||
return "crowdsec_unavailable"
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
// IsCrowdSec returns true when the verdict originates from a CrowdSec check.
|
||||
func (v Verdict) IsCrowdSec() bool {
|
||||
switch v {
|
||||
case DenyCrowdSecBan, DenyCrowdSecCaptcha, DenyCrowdSecThrottle, DenyCrowdSecUnavailable:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// IsObserveOnly returns true when v is a CrowdSec verdict and the filter is in
|
||||
// observe mode. Callers should log the verdict but not block the request.
|
||||
func (f *Filter) IsObserveOnly(v Verdict) bool {
|
||||
if f == nil {
|
||||
return false
|
||||
}
|
||||
return v.IsCrowdSec() && f.CrowdSecMode == CrowdSecObserve
|
||||
}
|
||||
|
||||
// Check evaluates whether addr is permitted. CIDR rules are evaluated
|
||||
// first because they are O(n) prefix comparisons. Country rules run
|
||||
// only when CIDR checks pass and require a geo lookup.
|
||||
// only when CIDR checks pass and require a geo lookup. CrowdSec checks
|
||||
// run last.
|
||||
func (f *Filter) Check(addr netip.Addr, geo GeoResolver) Verdict {
|
||||
if f == nil {
|
||||
return Allow
|
||||
@@ -115,7 +207,10 @@ func (f *Filter) Check(addr netip.Addr, geo GeoResolver) Verdict {
|
||||
if v := f.checkCIDR(addr); v != Allow {
|
||||
return v
|
||||
}
|
||||
return f.checkCountry(addr, geo)
|
||||
if v := f.checkCountry(addr, geo); v != Allow {
|
||||
return v
|
||||
}
|
||||
return f.checkCrowdSec(addr)
|
||||
}
|
||||
|
||||
func (f *Filter) checkCIDR(addr netip.Addr) Verdict {
|
||||
@@ -173,11 +268,48 @@ func (f *Filter) checkCountry(addr netip.Addr, geo GeoResolver) Verdict {
|
||||
return Allow
|
||||
}
|
||||
|
||||
func (f *Filter) checkCrowdSec(addr netip.Addr) Verdict {
|
||||
if f.CrowdSecMode == CrowdSecOff {
|
||||
return Allow
|
||||
}
|
||||
|
||||
// Checker nil with enforce means CrowdSec was requested but the proxy
|
||||
// has no LAPI configured. Fail-closed.
|
||||
if f.CrowdSec == nil {
|
||||
if f.CrowdSecMode == CrowdSecEnforce {
|
||||
return DenyCrowdSecUnavailable
|
||||
}
|
||||
return Allow
|
||||
}
|
||||
|
||||
if !f.CrowdSec.Ready() {
|
||||
if f.CrowdSecMode == CrowdSecEnforce {
|
||||
return DenyCrowdSecUnavailable
|
||||
}
|
||||
return Allow
|
||||
}
|
||||
|
||||
d := f.CrowdSec.CheckIP(addr)
|
||||
if d == nil {
|
||||
return Allow
|
||||
}
|
||||
|
||||
switch d.Type {
|
||||
case DecisionCaptcha:
|
||||
return DenyCrowdSecCaptcha
|
||||
case DecisionThrottle:
|
||||
return DenyCrowdSecThrottle
|
||||
default:
|
||||
return DenyCrowdSecBan
|
||||
}
|
||||
}
|
||||
|
||||
// HasRestrictions returns true if any restriction rules are configured.
|
||||
func (f *Filter) HasRestrictions() bool {
|
||||
if f == nil {
|
||||
return false
|
||||
}
|
||||
return len(f.AllowedCIDRs) > 0 || len(f.BlockedCIDRs) > 0 ||
|
||||
len(f.AllowedCountries) > 0 || len(f.BlockedCountries) > 0
|
||||
len(f.AllowedCountries) > 0 || len(f.BlockedCountries) > 0 ||
|
||||
f.CrowdSecMode == CrowdSecEnforce || f.CrowdSecMode == CrowdSecObserve
|
||||
}
|
||||
|
||||
@@ -29,21 +29,21 @@ func TestFilter_Check_NilFilter(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestFilter_Check_AllowedCIDR(t *testing.T) {
|
||||
f := ParseFilter([]string{"10.0.0.0/8"}, nil, nil, nil)
|
||||
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(nil, []string{"10.0.0.0/8"}, nil, nil)
|
||||
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([]string{"10.0.0.0/8"}, []string{"10.1.0.0/16"}, nil, nil)
|
||||
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")
|
||||
@@ -56,7 +56,7 @@ func TestFilter_Check_AllowedCountry(t *testing.T) {
|
||||
"2.2.2.2": "DE",
|
||||
"3.3.3.3": "CN",
|
||||
})
|
||||
f := ParseFilter(nil, nil, []string{"US", "DE"}, nil)
|
||||
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")
|
||||
@@ -69,7 +69,7 @@ func TestFilter_Check_BlockedCountry(t *testing.T) {
|
||||
"2.2.2.2": "RU",
|
||||
"3.3.3.3": "US",
|
||||
})
|
||||
f := ParseFilter(nil, nil, nil, []string{"CN", "RU"})
|
||||
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")
|
||||
@@ -83,7 +83,7 @@ func TestFilter_Check_AllowedAndBlockedCountry(t *testing.T) {
|
||||
"3.3.3.3": "CN",
|
||||
})
|
||||
// Allow US and DE, but block DE explicitly.
|
||||
f := ParseFilter(nil, nil, []string{"US", "DE"}, []string{"DE"})
|
||||
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")
|
||||
@@ -94,7 +94,7 @@ func TestFilter_Check_UnknownCountryWithAllowlist(t *testing.T) {
|
||||
geo := newMockGeo(map[string]string{
|
||||
"1.1.1.1": "US",
|
||||
})
|
||||
f := ParseFilter(nil, nil, []string{"US"}, nil)
|
||||
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")
|
||||
@@ -104,34 +104,34 @@ func TestFilter_Check_UnknownCountryWithBlocklistOnly(t *testing.T) {
|
||||
geo := newMockGeo(map[string]string{
|
||||
"1.1.1.1": "CN",
|
||||
})
|
||||
f := ParseFilter(nil, nil, nil, []string{"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(nil, nil, []string{"US"}, nil)
|
||||
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(nil, nil, nil, []string{"CN"})
|
||||
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(nil, nil, []string{"US"}, nil)
|
||||
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(nil, nil, nil, []string{"CN"})
|
||||
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([]string{"10.0.0.0/8"}, nil, nil, nil)
|
||||
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))
|
||||
@@ -143,7 +143,7 @@ func TestFilter_Check_CIDRAllowThenCountryBlock(t *testing.T) {
|
||||
"10.1.2.3": "CN",
|
||||
"10.2.3.4": "US",
|
||||
})
|
||||
f := ParseFilter([]string{"10.0.0.0/8"}, nil, nil, []string{"CN"})
|
||||
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")
|
||||
@@ -151,12 +151,12 @@ func TestFilter_Check_CIDRAllowThenCountryBlock(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestParseFilter_Empty(t *testing.T) {
|
||||
f := ParseFilter(nil, nil, nil, nil)
|
||||
f := ParseFilter(FilterConfig{})
|
||||
assert.Nil(t, f)
|
||||
}
|
||||
|
||||
func TestParseFilter_InvalidCIDR(t *testing.T) {
|
||||
f := ParseFilter([]string{"invalid", "10.0.0.0/8"}, nil, nil, nil)
|
||||
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")
|
||||
@@ -166,12 +166,12 @@ func TestParseFilter_InvalidCIDR(t *testing.T) {
|
||||
func TestFilter_HasRestrictions(t *testing.T) {
|
||||
assert.False(t, (*Filter)(nil).HasRestrictions())
|
||||
assert.False(t, (&Filter{}).HasRestrictions())
|
||||
assert.True(t, ParseFilter([]string{"10.0.0.0/8"}, nil, nil, nil).HasRestrictions())
|
||||
assert.True(t, ParseFilter(nil, nil, []string{"US"}, nil).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([]string{"2001:db8::/32"}, nil, nil, nil)
|
||||
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")
|
||||
@@ -179,7 +179,7 @@ func TestFilter_Check_IPv6CIDR(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestFilter_Check_IPv4MappedIPv6(t *testing.T) {
|
||||
f := ParseFilter([]string{"10.0.0.0/8"}, nil, nil, nil)
|
||||
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")
|
||||
@@ -191,7 +191,7 @@ func TestFilter_Check_IPv4MappedIPv6(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestFilter_Check_MixedV4V6CIDRs(t *testing.T) {
|
||||
f := ParseFilter([]string{"10.0.0.0/8", "2001:db8::/32"}, nil, nil, nil)
|
||||
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")
|
||||
@@ -202,7 +202,7 @@ func TestFilter_Check_MixedV4V6CIDRs(t *testing.T) {
|
||||
|
||||
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([]string{"1.1.1.1/24"}, nil, nil, nil)
|
||||
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.
|
||||
@@ -264,7 +264,7 @@ func TestFilter_Check_CountryCodeCaseInsensitive(t *testing.T) {
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
f := ParseFilter(nil, nil, tc.allowedCountries, tc.blockedCountries)
|
||||
f := ParseFilter(FilterConfig{AllowedCountries: tc.allowedCountries, BlockedCountries: tc.blockedCountries})
|
||||
got := f.Check(netip.MustParseAddr(tc.addr), geo)
|
||||
assert.Equal(t, tc.want, got)
|
||||
})
|
||||
@@ -275,4 +275,252 @@ func TestFilter_Check_CountryCodeCaseInsensitive(t *testing.T) {
|
||||
type unavailableGeo struct{}
|
||||
|
||||
func (u *unavailableGeo) LookupAddr(_ netip.Addr) geolocation.Result { return geolocation.Result{} }
|
||||
func (u *unavailableGeo) Available() bool { return false }
|
||||
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_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())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user