check / check (push) Successful in 4m22s
smallwebwaf now copies its state to bans.json, clients.json and lookups.json in SWWAF_STATE_DIR, as "Persistent state" in SPEC.md describes, and reads them back at start, so a restart lifts no ban and gives no client a fresh allowance. Each client gains a history, and a ban's notes count the netblock's requests. bans.json is written SWWAF_STATE_WRITE_DELAY after a ban, and every file every SWWAF_STATE_COUNTER_INTERVAL and at the stop. A ban read back is masked to its netblock and refuses every client in it, whatever SWWAF_BAN_SCOPE_V4_PREFIX is now. A file that does not parse, an unknown version or an unwritable directory stops the start. Deviation: no AS number or name, and no ban cause, reason or lifting yet. Model: opus-5-5
270 lines
7.9 KiB
Go
270 lines
7.9 KiB
Go
package bans_test
|
|
|
|
import (
|
|
"net/netip"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"sneak.berlin/go/smallwebwaf/internal/bans"
|
|
)
|
|
|
|
const day = 24 * time.Hour
|
|
|
|
func TestRepeatsTripleUntilPermanent(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ledger := bans.New(defaultRules())
|
|
netblock := netip.MustParsePrefix("203.0.113.9/32")
|
|
now := midnight()
|
|
|
|
// Each ban is followed by another as soon as it ends: 1, 3, 9, 27 and
|
|
// 81 hours.
|
|
for i, hours := range []int{1, 3, 9, 27, 81} {
|
|
ban := ledger.BanForLimit(netblock, now, bans.Notes{})
|
|
|
|
length := time.Duration(hours) * time.Hour
|
|
if !ban.Expires.Equal(now.Add(length)) || ban.Notes.EarlierBans != i {
|
|
t.Fatalf("ban %d lasts %s with %d earlier bans, want %d hours and %d",
|
|
i+1, ban.Expires.Sub(now), ban.Notes.EarlierBans, hours, i)
|
|
}
|
|
|
|
now = ban.Expires
|
|
}
|
|
|
|
// The sixth would last 243 hours, more than seven days: it is
|
|
// permanent, and never ends.
|
|
ban := ledger.BanForLimit(netblock, now, bans.Notes{})
|
|
if !ban.Permanent() {
|
|
t.Fatalf("sixth ban ends at %s, want a permanent one", ban.Expires)
|
|
}
|
|
|
|
_, banned := ledger.Check(netblock.Addr(), now.Add(100*365*day))
|
|
if !banned {
|
|
t.Error("a permanent ban ended")
|
|
}
|
|
}
|
|
|
|
func TestRepeatWindowRunsOut(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
for _, tc := range []struct {
|
|
name string
|
|
// gap is the time between the end of the first ban and the second.
|
|
gap time.Duration
|
|
want time.Duration
|
|
}{
|
|
{"broken again as the window ends", day, 3 * time.Hour},
|
|
{"broken again after the window", day + time.Nanosecond, time.Hour},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ledger := bans.New(defaultRules())
|
|
netblock := netip.MustParsePrefix("203.0.113.9/32")
|
|
|
|
first := ledger.BanForLimit(netblock, midnight(), bans.Notes{})
|
|
second := ledger.BanForLimit(netblock, first.Expires.Add(tc.gap), bans.Notes{})
|
|
|
|
if second.Expires.Sub(second.Start) != tc.want || second.Notes.EarlierBans != 1 {
|
|
t.Errorf("second ban lasts %s with %d earlier bans, want %s and 1",
|
|
second.Expires.Sub(second.Start), second.Notes.EarlierBans, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestFirstBanLongerThanTheMaximumIsPermanent(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
rules := defaultRules()
|
|
rules.LimitBanDuration = rules.MaxBanDuration + time.Hour
|
|
ledger := bans.New(rules)
|
|
|
|
ban := ledger.BanForLimit(netip.MustParsePrefix("203.0.113.9/32"), midnight(),
|
|
bans.Notes{})
|
|
if !ban.Permanent() {
|
|
t.Errorf("first ban ends at %s, want a permanent one", ban.Expires)
|
|
}
|
|
}
|
|
|
|
func TestLongestBanSetFarOffDoesNotOverflow(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// With bans of up to 100,000 days, the 14th ban in a row, of 3^13
|
|
// hours, is within the maximum, and three times as long would not fit
|
|
// in a time.Duration. The 15th is permanent.
|
|
rules := defaultRules()
|
|
rules.MaxBanDuration = 100000 * day
|
|
ledger := bans.New(rules)
|
|
netblock := netip.MustParsePrefix("203.0.113.9/32")
|
|
now := midnight()
|
|
|
|
for i := range 14 {
|
|
ban := ledger.BanForLimit(netblock, now, bans.Notes{})
|
|
if !ban.Expires.After(ban.Start) {
|
|
t.Fatalf("ban %d starts at %s and ends at %s", i+1, ban.Start, ban.Expires)
|
|
}
|
|
|
|
now = ban.Expires
|
|
}
|
|
|
|
ban := ledger.BanForLimit(netblock, now, bans.Notes{})
|
|
if !ban.Permanent() {
|
|
t.Errorf("15th ban ends at %s, want a permanent one", ban.Expires)
|
|
}
|
|
}
|
|
|
|
func TestBrokenLimitDuringABanMakesNoOther(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ledger := bans.New(defaultRules())
|
|
netblock := netip.MustParsePrefix("203.0.113.9/32")
|
|
|
|
first := ledger.BanForLimit(netblock, midnight(), bans.Notes{})
|
|
again := ledger.BanForLimit(netblock, midnight().Add(time.Minute), bans.Notes{})
|
|
|
|
if again != first || len(ledger.Bans(netblock)) != 1 {
|
|
t.Errorf("a limit broken during a ban gave %+v and %d bans, want %+v and 1",
|
|
again, len(ledger.Bans(netblock)), first)
|
|
}
|
|
}
|
|
|
|
func TestCheckRefusesWhileTheBanLastsAndCountsTheRefusals(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ledger := bans.New(defaultRules())
|
|
netblock := netip.MustParsePrefix("203.0.113.9/32")
|
|
ban := ledger.BanForLimit(netblock, midnight(), bans.Notes{Requests: 5})
|
|
|
|
for range 3 {
|
|
got, banned := ledger.Check(netblock.Addr(), ban.Expires.Add(-time.Nanosecond))
|
|
if !banned || got.Start != ban.Start {
|
|
t.Fatalf("check during the ban gives %+v and %t", got, banned)
|
|
}
|
|
}
|
|
|
|
_, banned := ledger.Check(netip.MustParseAddr("203.0.113.10"), midnight())
|
|
if banned {
|
|
t.Error("another netblock is banned")
|
|
}
|
|
|
|
_, banned = ledger.Check(netblock.Addr(), ban.Expires)
|
|
if banned {
|
|
t.Error("the ban did not end")
|
|
}
|
|
|
|
// The netblock's requests went from 5 to 8 with the three refused.
|
|
notes := ledger.Bans(netblock)[0].Notes
|
|
if notes.Refused != 3 || notes.Requests != 8 {
|
|
t.Errorf("the notes count %d refused requests of %d, want 3 of 8",
|
|
notes.Refused, notes.Requests)
|
|
}
|
|
}
|
|
|
|
func TestMaxBansDropsTheEarliestBanOfTheNetblockSeenLongestAgo(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
rules := defaultRules()
|
|
rules.MaxBans = 3
|
|
ledger := bans.New(rules)
|
|
a := netip.MustParsePrefix("203.0.113.1/32")
|
|
b := netip.MustParsePrefix("203.0.113.2/32")
|
|
c := netip.MustParsePrefix("203.0.113.3/32")
|
|
d := netip.MustParsePrefix("2001:db8::/64")
|
|
now := midnight()
|
|
|
|
first := ledger.BanForLimit(a, now, bans.Notes{})
|
|
ledger.BanForLimit(b, now, bans.Notes{})
|
|
ledger.BanForLimit(c, now, bans.Notes{})
|
|
|
|
// A request from a makes b the netblock seen longest ago, and its ban
|
|
// goes to make room for d's.
|
|
ledger.Check(a.Addr(), now)
|
|
ledger.BanForLimit(d, now, bans.Notes{})
|
|
wantBans(t, ledger, map[netip.Prefix]int{a: 1, b: 0, c: 1, d: 1})
|
|
|
|
// a is banned again once its ban has ended; c, seen longest ago, goes.
|
|
ledger.BanForLimit(a, first.Expires, bans.Notes{})
|
|
wantBans(t, ledger, map[netip.Prefix]int{a: 2, c: 0, d: 1})
|
|
|
|
// With d seen since, a is seen longest ago, and its earlier ban goes
|
|
// first.
|
|
ledger.Check(d.Addr(), first.Expires)
|
|
ledger.BanForLimit(b, first.Expires, bans.Notes{})
|
|
wantBans(t, ledger, map[netip.Prefix]int{a: 1, b: 1, d: 1})
|
|
|
|
if !ledger.Bans(a)[0].Start.Equal(first.Expires) {
|
|
t.Errorf("a kept its ban of %s, want the later one", ledger.Bans(a)[0].Start)
|
|
}
|
|
}
|
|
|
|
func TestFullLedgerDropsTheEarlierBanOfTheNetblockBannedAgain(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// With room for one ban, the netblock's ended ban goes to make room for
|
|
// its new one, whose notes still count it.
|
|
rules := defaultRules()
|
|
rules.MaxBans = 1
|
|
ledger := bans.New(rules)
|
|
netblock := netip.MustParsePrefix("203.0.113.9/32")
|
|
|
|
first := ledger.BanForLimit(netblock, midnight(), bans.Notes{})
|
|
second := ledger.BanForLimit(netblock, first.Expires, bans.Notes{})
|
|
|
|
held := ledger.Bans(netblock)
|
|
if len(held) != 1 || held[0] != second || held[0].Notes.EarlierBans != 1 {
|
|
t.Errorf("the ledger holds %+v, want only the second ban, with 1 earlier ban",
|
|
held)
|
|
}
|
|
}
|
|
|
|
func TestRequestTextsAreCutTo256Bytes(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ledger := bans.New(defaultRules())
|
|
netblock := netip.MustParsePrefix("203.0.113.9/32")
|
|
long := strings.Repeat("a", 300)
|
|
request := bans.Request{
|
|
Time: midnight(), Method: long, Host: long, Path: long, Status: 403, UserAgent: long,
|
|
}
|
|
|
|
ban := ledger.BanForLimit(netblock, midnight(), bans.Notes{Request: request})
|
|
|
|
cut := long[:256]
|
|
want := bans.Request{
|
|
Time: midnight(), Method: cut, Host: cut, Path: cut, Status: 403, UserAgent: cut,
|
|
}
|
|
|
|
if ban.Notes.Request != want || ledger.Bans(netblock)[0].Notes.Request != want {
|
|
t.Errorf("the notes keep %+v, want each text cut to 256 bytes", ban.Notes.Request)
|
|
}
|
|
}
|
|
|
|
// defaultRules are the rules at the settings' defaults.
|
|
func defaultRules() bans.Rules {
|
|
return bans.Rules{
|
|
LimitBanDuration: time.Hour,
|
|
LimitBanRepeatWindow: day,
|
|
MaxBanDuration: 7 * day,
|
|
MaxBans: 5000,
|
|
}
|
|
}
|
|
|
|
// midnight is when the tests' first bans are made.
|
|
func midnight() time.Time {
|
|
return time.Date(2026, 10, 6, 0, 0, 0, 0, time.UTC)
|
|
}
|
|
|
|
// wantBans checks how many bans the ledger holds on each netblock.
|
|
func wantBans(t *testing.T, ledger *bans.Ledger, want map[netip.Prefix]int) {
|
|
t.Helper()
|
|
|
|
for netblock, count := range want {
|
|
got := len(ledger.Bans(netblock))
|
|
if got != count {
|
|
t.Errorf("%s has %d bans, want %d", netblock, got, count)
|
|
}
|
|
}
|
|
}
|