mirror of
https://github.com/netbirdio/netbird.git
synced 2026-09-01 20:41:28 +02:00
Add allow_match any/all mode to reverse proxy access restrictions
This commit is contained in:
@@ -50,6 +50,28 @@ const (
|
||||
CrowdSecObserve CrowdSecMode = "observe"
|
||||
)
|
||||
|
||||
// AllowMatch controls how the configured allowlists (CIDR, country) combine.
|
||||
// Blocklists are always a separate hard-deny gate and are unaffected by it.
|
||||
type AllowMatch string
|
||||
|
||||
const (
|
||||
// AllowMatchAll requires the address to match every configured allowlist
|
||||
// (AND). This is the default and preserves the historical behavior.
|
||||
AllowMatchAll AllowMatch = "all"
|
||||
// AllowMatchAny requires the address to match at least one configured
|
||||
// allowlist (OR), e.g. "allowed country OR allowed CIDR".
|
||||
AllowMatchAny AllowMatch = "any"
|
||||
)
|
||||
|
||||
// normalizeAllowMatch maps unknown or empty values to the restrictive default
|
||||
// (AllowMatchAll) so an unrecognized mode never loosens access.
|
||||
func normalizeAllowMatch(m AllowMatch) AllowMatch {
|
||||
if m == AllowMatchAny {
|
||||
return AllowMatchAny
|
||||
}
|
||||
return AllowMatchAll
|
||||
}
|
||||
|
||||
// Filter evaluates IP restrictions. CIDR checks are performed first
|
||||
// (cheap), followed by country lookups (more expensive) only when needed.
|
||||
type Filter struct {
|
||||
@@ -59,6 +81,9 @@ type Filter struct {
|
||||
BlockedCountries []string
|
||||
CrowdSec CrowdSecChecker
|
||||
CrowdSecMode CrowdSecMode
|
||||
// AllowMatch controls how the allowlists combine (AND vs OR). Empty means
|
||||
// AllowMatchAll.
|
||||
AllowMatch AllowMatch
|
||||
}
|
||||
|
||||
// FilterConfig holds the raw configuration for building a Filter.
|
||||
@@ -69,6 +94,7 @@ type FilterConfig struct {
|
||||
BlockedCountries []string
|
||||
CrowdSec CrowdSecChecker
|
||||
CrowdSecMode CrowdSecMode
|
||||
AllowMatch AllowMatch
|
||||
Logger *log.Entry
|
||||
}
|
||||
|
||||
@@ -89,6 +115,7 @@ func ParseFilter(cfg FilterConfig) *Filter {
|
||||
f := &Filter{
|
||||
AllowedCountries: normalizeCountryCodes(cfg.AllowedCountries),
|
||||
BlockedCountries: normalizeCountryCodes(cfg.BlockedCountries),
|
||||
AllowMatch: normalizeAllowMatch(cfg.AllowMatch),
|
||||
}
|
||||
if hasCS {
|
||||
f.CrowdSec = cfg.CrowdSec
|
||||
@@ -216,6 +243,10 @@ func (f *Filter) Check(addr netip.Addr, geo GeoResolver) Verdict {
|
||||
// IPv4 CIDR rules match regardless of how the address was received.
|
||||
addr = addr.Unmap()
|
||||
|
||||
if f.AllowMatch == AllowMatchAny {
|
||||
return f.checkAny(addr, geo)
|
||||
}
|
||||
|
||||
if v := f.checkCIDR(addr); v != Allow {
|
||||
return v
|
||||
}
|
||||
@@ -225,6 +256,77 @@ func (f *Filter) Check(addr netip.Addr, geo GeoResolver) Verdict {
|
||||
return f.checkCrowdSec(addr)
|
||||
}
|
||||
|
||||
// checkAny evaluates the filter with OR semantics across allowlists: the
|
||||
// address is admitted if it matches any configured allowlist (CIDR or country).
|
||||
// Blocklists remain a hard-deny gate evaluated first and are independent of the
|
||||
// allow-combine mode, so a blocklist match (or unverifiable country block) still
|
||||
// denies. CrowdSec runs last, as in the default path.
|
||||
func (f *Filter) checkAny(addr netip.Addr, geo GeoResolver) Verdict {
|
||||
if v := f.checkBlocked(addr, geo); v != Allow {
|
||||
return v
|
||||
}
|
||||
if v := f.checkAllowedAny(addr, geo); v != Allow {
|
||||
return v
|
||||
}
|
||||
return f.checkCrowdSec(addr)
|
||||
}
|
||||
|
||||
// checkBlocked is the hard-deny gate: it denies on any blocklist match,
|
||||
// regardless of the allow-combine mode. A configured country blocklist with an
|
||||
// unavailable geo lookup fails closed.
|
||||
func (f *Filter) checkBlocked(addr netip.Addr, geo GeoResolver) Verdict {
|
||||
for _, prefix := range f.BlockedCIDRs {
|
||||
if prefix.Contains(addr) {
|
||||
return DenyCIDR
|
||||
}
|
||||
}
|
||||
|
||||
if len(f.BlockedCountries) == 0 {
|
||||
return Allow
|
||||
}
|
||||
if geo == nil || !geo.Available() {
|
||||
return DenyGeoUnavailable
|
||||
}
|
||||
if code := geo.LookupAddr(addr).CountryCode; code != "" && slices.Contains(f.BlockedCountries, code) {
|
||||
return DenyCountry
|
||||
}
|
||||
return Allow
|
||||
}
|
||||
|
||||
// checkAllowedAny admits the address if it matches any active allowlist. The
|
||||
// CIDR allowlist is evaluated first so a match admits without a geo lookup;
|
||||
// only when it does not match is the country allowlist consulted, where an
|
||||
// unavailable geo lookup fails closed.
|
||||
func (f *Filter) checkAllowedAny(addr netip.Addr, geo GeoResolver) Verdict {
|
||||
cidrActive := len(f.AllowedCIDRs) > 0
|
||||
countryActive := len(f.AllowedCountries) > 0
|
||||
if !cidrActive && !countryActive {
|
||||
return Allow
|
||||
}
|
||||
|
||||
if cidrActive {
|
||||
for _, prefix := range f.AllowedCIDRs {
|
||||
if prefix.Contains(addr) {
|
||||
return Allow
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if countryActive {
|
||||
if geo == nil || !geo.Available() {
|
||||
return DenyGeoUnavailable
|
||||
}
|
||||
if code := geo.LookupAddr(addr).CountryCode; code != "" && slices.Contains(f.AllowedCountries, code) {
|
||||
return Allow
|
||||
}
|
||||
}
|
||||
|
||||
if cidrActive {
|
||||
return DenyCIDR
|
||||
}
|
||||
return DenyCountry
|
||||
}
|
||||
|
||||
func (f *Filter) checkCIDR(addr netip.Addr) Verdict {
|
||||
if len(f.AllowedCIDRs) > 0 {
|
||||
allowed := false
|
||||
|
||||
@@ -150,6 +150,187 @@ func TestFilter_Check_CIDRAllowThenCountryBlock(t *testing.T) {
|
||||
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)
|
||||
|
||||
@@ -1900,6 +1900,7 @@ func (s *Server) parseRestrictions(mapping *proto.ProxyMapping) *restrict.Filter
|
||||
BlockedCountries: r.GetBlockedCountries(),
|
||||
CrowdSec: checker,
|
||||
CrowdSecMode: csMode,
|
||||
AllowMatch: restrict.AllowMatch(r.GetAllowMatch()),
|
||||
Logger: log.NewEntry(s.Logger),
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user