mirror of
https://github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin.git
synced 2026-09-02 20:28:50 +02:00
✅ cache: cover the CIDR operations and what a lookup costs
pkg/cache had tests for Get, Set and Delete but none for their CIDR counterparts, so the range keyspace was only exercised end to end by the e2e scenario. Test_GetCIDR covers hits, the boundaries of a range, IPv6, IPv4 mapped clients and invalid input. Test_GetCIDR_MostSpecific pins the precedence between overlapping decisions, which is deliberate behaviour that nothing was holding in place: a captcha on a /24 is not overruled by a ban on its /8. Test_DeleteCIDR checks the wider decision survives a narrower one being removed, and Test_SetCIDR_InvalidIsNotStored that a rejected value stores nothing at all. Test_GetCIDR_Reads counts cache reads through an isolated cacheInterface, so the cost of a lookup is part of the contract: probing every prefix length again turns it into 34 reads for IPv4 and 130 for IPv6 and fails.
This commit is contained in:
Vendored
+184
@@ -3,6 +3,7 @@
|
|||||||
package cache
|
package cache
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
logger "github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/logger"
|
logger "github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/logger"
|
||||||
@@ -160,3 +161,186 @@ func Test_nextReader(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// countingCache is an isolated cacheInterface recording how many reads a lookup costs,
|
||||||
|
// so a CIDR lookup can be checked for both its result and its price.
|
||||||
|
type countingCache struct {
|
||||||
|
values map[string]string
|
||||||
|
reads int
|
||||||
|
}
|
||||||
|
|
||||||
|
func newCountingCache() *countingCache {
|
||||||
|
return &countingCache{values: map[string]string{}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *countingCache) get(key string) (string, error) {
|
||||||
|
c.reads++
|
||||||
|
if value, found := c.values[key]; found && value != "" {
|
||||||
|
return value, nil
|
||||||
|
}
|
||||||
|
return "", errors.New(CacheMiss)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *countingCache) set(key, value string, _ int64) {
|
||||||
|
c.values[key] = value
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *countingCache) delete(key string) {
|
||||||
|
delete(c.values, key)
|
||||||
|
}
|
||||||
|
|
||||||
|
func newCIDRClient(decisions map[string]string) (*Client, *countingCache) {
|
||||||
|
counting := newCountingCache()
|
||||||
|
client := &Client{cache: counting, log: logger.New("INFO", "")}
|
||||||
|
for cidr, value := range decisions {
|
||||||
|
client.SetCIDR(cidr, value, 60)
|
||||||
|
}
|
||||||
|
return client, counting
|
||||||
|
}
|
||||||
|
|
||||||
|
func Test_GetCIDR(t *testing.T) {
|
||||||
|
decisions := map[string]string{
|
||||||
|
"10.0.0.0/24": BannedValue,
|
||||||
|
"192.168.1.42/24": CaptchaValue, // not a network address, host bits are dropped
|
||||||
|
"2001:db8::/32": BannedValue,
|
||||||
|
}
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
clientIP string
|
||||||
|
want string
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{name: "IP inside a banned range", clientIP: "10.0.0.7", want: BannedValue},
|
||||||
|
{name: "network address itself", clientIP: "10.0.0.0", want: BannedValue},
|
||||||
|
{name: "broadcast address of the range", clientIP: "10.0.0.255", want: BannedValue},
|
||||||
|
{name: "IP just outside the range", clientIP: "10.0.1.0", wantErr: true},
|
||||||
|
{name: "IP inside a captcha range", clientIP: "192.168.1.7", want: CaptchaValue},
|
||||||
|
{name: "IP inside an IPv6 range", clientIP: "2001:db8::dead:beef", want: BannedValue},
|
||||||
|
{name: "IP outside the IPv6 range", clientIP: "2001:db9::1", wantErr: true},
|
||||||
|
{name: "IPv4 mapped client against an IPv4 range", clientIP: "::ffff:10.0.0.7", want: BannedValue},
|
||||||
|
{name: "unknown IP", clientIP: "8.8.8.8", wantErr: true},
|
||||||
|
{name: "invalid IP", clientIP: "not-an-ip", wantErr: true},
|
||||||
|
{name: "empty IP", clientIP: "", wantErr: true},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
client, _ := newCIDRClient(decisions)
|
||||||
|
got, err := client.GetCIDR(tt.clientIP)
|
||||||
|
if (err != nil) != tt.wantErr {
|
||||||
|
t.Fatalf("GetCIDR(%q) error = %v, wantErr %v", tt.clientIP, err, tt.wantErr)
|
||||||
|
}
|
||||||
|
if got != tt.want {
|
||||||
|
t.Errorf("GetCIDR(%q) = %q, want %q", tt.clientIP, got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test_GetCIDR_MostSpecific pins precedence: the narrowest range wins, so a captcha on
|
||||||
|
// a /24 is not overruled by a ban on its /8.
|
||||||
|
func Test_GetCIDR_MostSpecific(t *testing.T) {
|
||||||
|
client, _ := newCIDRClient(map[string]string{
|
||||||
|
"10.0.0.0/8": BannedValue,
|
||||||
|
"10.1.0.0/16": CaptchaValue,
|
||||||
|
"10.1.2.0/24": BannedValue,
|
||||||
|
"2001:db8::/32": BannedValue,
|
||||||
|
"2001:db8::/48": CaptchaValue,
|
||||||
|
})
|
||||||
|
tests := []struct {
|
||||||
|
clientIP string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{clientIP: "10.1.2.3", want: BannedValue},
|
||||||
|
{clientIP: "10.1.3.3", want: CaptchaValue},
|
||||||
|
{clientIP: "10.2.3.4", want: BannedValue},
|
||||||
|
{clientIP: "2001:db8::1", want: CaptchaValue},
|
||||||
|
{clientIP: "2001:db8:1::1", want: BannedValue},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.clientIP, func(t *testing.T) {
|
||||||
|
got, err := client.GetCIDR(tt.clientIP)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetCIDR(%q) unexpected error %v", tt.clientIP, err)
|
||||||
|
}
|
||||||
|
if got != tt.want {
|
||||||
|
t.Errorf("GetCIDR(%q) = %q, want %q", tt.clientIP, got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func Test_DeleteCIDR(t *testing.T) {
|
||||||
|
client, _ := newCIDRClient(map[string]string{
|
||||||
|
"10.0.0.0/8": BannedValue,
|
||||||
|
"10.1.2.0/24": CaptchaValue,
|
||||||
|
})
|
||||||
|
client.DeleteCIDR("10.1.2.0/24")
|
||||||
|
// The wider decision is untouched and takes over.
|
||||||
|
if got, err := client.GetCIDR("10.1.2.3"); err != nil || got != BannedValue {
|
||||||
|
t.Errorf("after deleting the /24, GetCIDR = %q %v, want %q", got, err, BannedValue)
|
||||||
|
}
|
||||||
|
client.DeleteCIDR("10.0.0.0/8")
|
||||||
|
if _, err := client.GetCIDR("10.1.2.3"); err == nil {
|
||||||
|
t.Error("GetCIDR should miss once every decision is deleted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func Test_SetCIDR_InvalidIsNotStored(t *testing.T) {
|
||||||
|
for _, cidr := range []string{"", "garbage", "10.0.0.1", "10.0.0.0/33", "10.0.0.0/-1"} {
|
||||||
|
t.Run(cidr, func(t *testing.T) {
|
||||||
|
_, counting := newCIDRClient(map[string]string{cidr: BannedValue})
|
||||||
|
if len(counting.values) != 0 {
|
||||||
|
t.Errorf("SetCIDR(%q) stored %v, want nothing", cidr, counting.values)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test_GetCIDR_Reads guards the cost of the lookup: it must probe only the prefix
|
||||||
|
// lengths that have a decision, not every possible one.
|
||||||
|
func Test_GetCIDR_Reads(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
decisions map[string]string
|
||||||
|
clientIP string
|
||||||
|
wantReads int
|
||||||
|
}{
|
||||||
|
{name: "no decision at all, IPv4", decisions: nil, clientIP: "10.0.0.1", wantReads: 1},
|
||||||
|
{name: "no decision at all, IPv6", decisions: nil, clientIP: "2001:db8::1", wantReads: 1},
|
||||||
|
{
|
||||||
|
name: "one prefix length, hit",
|
||||||
|
decisions: map[string]string{"10.0.0.0/24": BannedValue},
|
||||||
|
clientIP: "10.0.0.1",
|
||||||
|
wantReads: 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "one prefix length, miss",
|
||||||
|
decisions: map[string]string{"10.0.0.0/24": BannedValue},
|
||||||
|
clientIP: "11.0.0.1",
|
||||||
|
wantReads: 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "three prefix lengths, miss probes each once",
|
||||||
|
decisions: map[string]string{"10.0.0.0/8": BannedValue, "10.1.0.0/16": BannedValue, "10.1.2.0/24": BannedValue},
|
||||||
|
clientIP: "11.0.0.1",
|
||||||
|
wantReads: 4,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "IPv6 client does not probe every length",
|
||||||
|
decisions: map[string]string{"2001:db8::/32": BannedValue},
|
||||||
|
clientIP: "2001:dead::1",
|
||||||
|
wantReads: 2,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
client, counting := newCIDRClient(tt.decisions)
|
||||||
|
counting.reads = 0
|
||||||
|
// Only the number of reads matters here, the result is covered above.
|
||||||
|
_, _ = client.GetCIDR(tt.clientIP)
|
||||||
|
if counting.reads != tt.wantReads {
|
||||||
|
t.Errorf("GetCIDR(%q) did %d cache reads, want %d", tt.clientIP, counting.reads, tt.wantReads)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user