package bans_test import ( "net/netip" "testing" "time" "sneak.berlin/go/smallwebwaf/internal/bans" ) func TestBanWithoutACauseIsAnAdmins(t *testing.T) { t.Parallel() netblock := netip.MustParsePrefix("203.0.113.0/24") ledger := bans.New(defaultRules()) ledger.Load([]bans.Ban{{Netblock: netblock, Start: midnight()}}) if got := ledger.Bans(netblock)[0].Cause; got != bans.CauseAdmin { t.Errorf("the ban's cause is %q, want admin", got) } } func TestAdminsBansAreNeverDroppedAndDoNotCountTowardMaxBans(t *testing.T) { t.Parallel() rules := defaultRules() rules.MaxBans = 1 ledger := bans.New(rules) adminsOnly := netip.MustParsePrefix("198.51.100.0/24") both := netip.MustParsePrefix("203.0.113.1/32") second := netip.MustParsePrefix("203.0.113.2/32") third := netip.MustParsePrefix("203.0.113.3/32") // Seen longest ago, a netblock with two of an admin's bans alone, and // then one with an admin's ban before a ban smallwebwaf made: the one // ban counted toward MaxBans. ledger.Load([]bans.Ban{ {Netblock: adminsOnly, Start: midnight().Add(-3 * time.Hour), Cause: bans.CauseAdmin}, {Netblock: adminsOnly, Start: midnight().Add(-2 * time.Hour), Cause: bans.CauseAdmin}, {Netblock: both, Start: midnight().Add(-time.Hour), Cause: bans.CauseAdmin}, { Netblock: both, Start: midnight(), Expires: midnight().Add(time.Hour), Cause: bans.CauseLimit, }, }) wantBans(t, ledger, map[netip.Prefix]int{adminsOnly: 2, both: 2}) // A new ban drops the ban smallwebwaf made, and only that one. ledger.BanForLimit(second, midnight(), bans.Notes{}) wantBans(t, ledger, map[netip.Prefix]int{adminsOnly: 2, both: 1, second: 1}) if ledger.Bans(both)[0].Cause != bans.CauseAdmin { t.Errorf("%s kept %+v, want the admin's ban", both, ledger.Bans(both)) } // And the next drops that one. ledger.BanForLimit(third, midnight(), bans.Notes{}) wantBans(t, ledger, map[netip.Prefix]int{adminsOnly: 2, both: 1, second: 0, third: 1}) } func TestReasonOfTheBansSmallwebwafMakes(t *testing.T) { t.Parallel() ledger := bans.New(defaultRules()) limit, _ := ledger.BanForLimit(netip.MustParsePrefix("203.0.113.1/32"), midnight(), bans.Notes{Limit: 1000, Window: "minute"}) attack, _ := ledger.BanForAttack(netip.MustParsePrefix("203.0.113.2/32"), midnight(), bans.Notes{RuleID: "git-dir", Target: "path"}) for _, tc := range []struct{ got, want string }{ {limit.Reason, "requests per minute over the limit of 1000"}, {attack.Reason, "matched the rule git-dir"}, } { if tc.got != tc.want { t.Errorf("the reason is %q, want %q", tc.got, tc.want) } } } func TestLiftedBanForALimitRefusesNothingAndMakesNoBanLonger(t *testing.T) { t.Parallel() // An hour's ban lifted ten minutes after it started. netblock := netip.MustParsePrefix("203.0.113.9/32") lifted := bans.Ban{ Netblock: netblock, Start: midnight(), Expires: midnight().Add(time.Hour), Cause: bans.CauseLimit, Lifted: midnight().Add(10 * time.Minute), } ledger := bans.New(defaultRules()) ledger.Load([]bans.Ban{lifted}) // While it would still last, it refuses nothing, and a limit broken // bans for an hour, as a first broken limit does; the lifted ban is // kept, and counted among the earlier bans. now := midnight().Add(30 * time.Minute) _, banned, _ := ledger.Check(netblock.Addr(), now) if banned { t.Error("the lifted ban refuses") } ban, _ := ledger.BanForLimit(netblock, now, bans.Notes{}) if ban.Expires.Sub(ban.Start) != time.Hour || ban.Notes.EarlierBans != (bans.EarlierBans{Limit: 1}) { t.Errorf("the next ban lasts %s with earlier bans %+v, want 1h and 1 for a limit", ban.Expires.Sub(ban.Start), ban.Notes.EarlierBans) } held := ledger.Bans(netblock) if len(held) != 2 || held[0] != lifted { t.Errorf("the ledger holds %+v, want the lifted ban and the new one", held) } } func TestLiftedBanForAnAttackRefusesNothingAndMakesNoBanLonger(t *testing.T) { t.Parallel() // A permanent ban for a clear sign of attack, lifted. netblock := netip.MustParsePrefix("203.0.113.9/32") ledger := bans.New(defaultRules()) ledger.Load([]bans.Ban{{ Netblock: netblock, Start: midnight(), Cause: bans.CauseAttack, Lifted: midnight().Add(time.Hour), }}) now := midnight().Add(2 * time.Hour) _, banned, _ := ledger.Find(netblock.Addr(), now) if banned { t.Error("the lifted ban refuses") } active, permanent := ledger.Count(now) if active != 0 || permanent != 0 { t.Errorf("%d bans are active and %d permanent, want none", active, permanent) } // The next clear sign of attack bans for seven days, as a first does. ban, _ := ledger.BanForAttack(netblock, now, bans.Notes{}) if ban.Expires.Sub(ban.Start) != 7*day { t.Errorf("the next ban for an attack ends at %s, want seven days on", ban.Expires) } } func TestLoadEditCountsTheBansAnAdminMade(t *testing.T) { t.Parallel() ledger := bans.New(defaultRules()) made, _ := ledger.BanForLimit(netip.MustParsePrefix("203.0.113.1/32"), midnight(), bans.Notes{}) atStart := bans.Ban{ Netblock: netip.MustParsePrefix("203.0.113.2/32"), Start: midnight(), } // The bans read at the start were made before it. ledger.Load([]bans.Ban{made, atStart}) if got := ledger.Made(bans.CauseAdmin); got != 0 { t.Fatalf("%d bans made by an admin after the start's, want none", got) } // The admin keeps the ban smallwebwaf made, keeps the one read at the // start, and adds one without a cause: that one alone is made. kept := made kept.Cause = bans.CauseAdmin added := bans.Ban{ Netblock: netip.MustParsePrefix("203.0.113.3/32"), Start: midnight(), } ledger.LoadEdit([]bans.Ban{kept, atStart, added}) if ledger.Made(bans.CauseAdmin) != 1 || ledger.Made(bans.CauseLimit) != 1 { t.Errorf("%d bans made by an admin and %d for a limit, want 1 of each", ledger.Made(bans.CauseAdmin), ledger.Made(bans.CauseLimit)) } } func TestAdminsBanIsMadeWhileAnotherLasts(t *testing.T) { t.Parallel() netblock := netip.MustParsePrefix("203.0.113.0/24") ledger := bans.New(defaultRules()) // An hour's ban for a broken limit. ledger.BanForLimit(netblock, midnight(), bans.Notes{}) wantChanged(t, ledger, true) // A minute later an admin bans the netblock for good, named by an // address in it: that ban is made, and counts the other among the // earlier bans. now := midnight().Add(time.Minute) want := bans.Ban{ Netblock: netblock, Start: now, Cause: bans.CauseAdmin, Reason: "probes for logins", Notes: bans.Notes{EarlierBans: bans.EarlierBans{Limit: 1}}, } got := ledger.BanForAdmin(netip.MustParsePrefix("203.0.113.9/24"), now, time.Time{}, "probes for logins") if got != want { t.Errorf("the admin's ban is\n%+v\nwant\n%+v", got, want) } wantChanged(t, ledger, true) if made := ledger.Made(bans.CauseAdmin); made != 1 { t.Errorf("%d bans made by an admin, want 1", made) } // It refuses once the ban for the limit has ended. ban, banned, _ := ledger.Find(netblock.Addr(), midnight().Add(2*time.Hour)) if !banned || ban != want { t.Errorf("after the limit's ban the netblock is under %+v (%t), want %+v", ban, banned, want) } } func TestLiftLiftsEveryActiveBanCoveringTheClient(t *testing.T) { t.Parallel() client := netip.MustParseAddr("203.0.113.9") own := netip.MustParsePrefix("203.0.113.9/32") wide := netip.MustParsePrefix("203.0.113.0/24") other := netip.MustParsePrefix("203.0.113.10/32") ledger := bans.New(defaultRules()) ledger.Load([]bans.Ban{ // Ended an hour ago. { Netblock: own, Start: midnight().Add(-2 * time.Hour), Expires: midnight().Add(-time.Hour), Cause: bans.CauseLimit, }, // Active, on the client's address and on its /24. { Netblock: own, Start: midnight(), Expires: midnight().Add(time.Hour), Cause: bans.CauseLimit, }, {Netblock: wide, Start: midnight(), Cause: bans.CauseAdmin}, // Another client's. {Netblock: other, Start: midnight(), Cause: bans.CauseAdmin}, }) now := midnight().Add(time.Minute) lifted := ledger.Lift(client, now) if len(lifted) != 2 || lifted[0].Lifted != now || lifted[1].Lifted != now { t.Errorf("lifted %+v, want the two active bans covering the client", lifted) } wantChanged(t, ledger, true) if _, banned, _ := ledger.Check(client, now); banned { t.Error("the client is still banned") } if _, banned, _ := ledger.Check(other.Addr(), now); !banned { t.Error("the other client's ban was lifted") } // The lifted bans are kept, and the one that had ended is not lifted. covering := ledger.Covering(client) if len(covering) != 3 || covering[0].Netblock != wide || !covering[1].Lifted.IsZero() || covering[2].Lifted != now { t.Errorf("the bans covering the client are %+v, want the /24's and both "+ "of its own, the earlier not lifted", covering) } // With none active, nothing is lifted or changed. if lifted = ledger.Lift(client, now); len(lifted) != 0 { t.Errorf("lifted %+v again", lifted) } wantChanged(t, ledger, false) }