check / check (push) Canceled after 0s
earlierBans took the counts in the notes of the netblock's first ban held, so a ban dropped after an admin's ban, which came first, went uncounted. Each ban held now gives a count, its notes' counts plus one for it and each ban held after it, and each cause takes the largest. Counting from the newest ban alone would lose the bans before an admin's ban added to bans.json without notes. Model: opus-5-5
320 lines
10 KiB
Go
320 lines
10 KiB
Go
package bans_test
|
|
|
|
import (
|
|
"net/netip"
|
|
"reflect"
|
|
"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 TestEarlierBansCountABanDroppedAfterAnAdminsBan(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
rules := defaultRules()
|
|
rules.MaxBans = 2
|
|
ledger := bans.New(rules)
|
|
netblock := netip.MustParsePrefix("203.0.113.1/32")
|
|
other := netip.MustParsePrefix("203.0.113.2/32")
|
|
|
|
// An admin bans the netblock for a minute, and it then breaks a limit
|
|
// twice, each time after its last ban has ended.
|
|
ledger.BanForAdmin(netblock, midnight(), midnight().Add(time.Minute),
|
|
"probes for logins")
|
|
first, _ := ledger.BanForLimit(netblock, midnight().Add(time.Minute), bans.Notes{})
|
|
second, _ := ledger.BanForLimit(netblock, first.Expires, bans.Notes{})
|
|
|
|
// A ban on another netblock fills the ledger, and the netblock's first
|
|
// ban for a limit, its earliest that is not an admin's, goes.
|
|
ledger.BanForLimit(other, second.Start, bans.Notes{})
|
|
wantBans(t, ledger, map[netip.Prefix]int{netblock: 2, other: 1})
|
|
|
|
// The netblock's next ban still counts it.
|
|
third, _ := ledger.BanForLimit(netblock, second.Expires, bans.Notes{})
|
|
if third.Notes.EarlierBans != (bans.EarlierBans{Limit: 2, Admin: 1}) {
|
|
t.Errorf("the third ban for a limit has earlier bans %+v, "+
|
|
"want 2 for a limit and 1 an admin's", third.Notes.EarlierBans)
|
|
}
|
|
}
|
|
|
|
func TestReasonOfTheBansSmallwebwafMakes(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ledger := bans.New(defaultRules())
|
|
|
|
limit, _ := ledger.BanForLimit(netip.MustParsePrefix("203.0.113.1/32"), midnight(),
|
|
bans.Notes{Kind: "requests", Limit: 1000, Window: "minute"})
|
|
byteLimit, _ := ledger.BanForLimit(netip.MustParsePrefix("203.0.113.3/32"),
|
|
midnight(), bans.Notes{Kind: "bytes", Limit: 10 << 30, Window: "hour"})
|
|
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"},
|
|
{byteLimit.Reason, "bytes per hour over the limit of 10737418240"},
|
|
{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 || !reflect.DeepEqual(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 !reflect.DeepEqual(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 || !reflect.DeepEqual(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)
|
|
}
|