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 TestFindCountsNothing(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}) got, banned := ledger.Find(netblock.Addr(), ban.Expires.Add(-time.Nanosecond)) if !banned || got != ban { t.Errorf("find during the ban gives %+v and %t, want %+v", got, banned, ban) } _, banned = ledger.Find(netblock.Addr(), ban.Expires) if banned { t.Error("the ban did not end") } if notes := ledger.Bans(netblock)[0].Notes; notes != ban.Notes { t.Errorf("the notes are %+v, want them unchanged, %+v", notes, ban.Notes) } } 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) } } }