check / check (push) Successful in 3m13s
The program is now sww, built from cmd/sww, with two modes: sww rproxy and sww discriminator. servewaf becomes discriminator everywhere it names that daemon: the package (internal/discriminator), its types, functions, files, the discriminator_line log field, and its docs and tests. smallwebwaf is left only where sneak's directive allows it: the documents' product name, the git.eeqj.de/sneak/smallwebwaf URLs, and the Go module path sneak.berlin/go/smallwebwaf. Everywhere else a name containing it takes the sww form: the package (internal/sww), metric names (sww_*), the X-Sww-* headers, the /_sww/ admin endpoints, the OS user, and the state and rules directories. The SWWAF_ environment variables take the SWW_ form. No logic changes. make check passes. Model: deepseek-v4-pro
344 lines
11 KiB
Go
344 lines
11 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 sww 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 sww 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 TestEarlierBansCountABanAnAdminKeepsOnce(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ledger := bans.New(defaultRules())
|
|
netblock := netip.MustParsePrefix("203.0.113.1/32")
|
|
|
|
// The netblock breaks a limit twice, the second time after its first
|
|
// ban has ended, and the second ban's notes count the first. An admin
|
|
// then keeps the first ban.
|
|
first, _ := ledger.BanForLimit(netblock, midnight(), bans.Notes{})
|
|
second, _ := ledger.BanForLimit(netblock, first.Expires, bans.Notes{})
|
|
|
|
kept := first
|
|
kept.Cause = bans.CauseAdmin
|
|
ledger.LoadEdit([]bans.Ban{kept, second})
|
|
|
|
// The netblock's next ban counts the kept ban once, as an admin's.
|
|
third, _ := ledger.BanForLimit(netblock, second.Expires, bans.Notes{})
|
|
if third.Notes.EarlierBans != (bans.EarlierBans{Limit: 1, Admin: 1}) {
|
|
t.Errorf("the third ban for a limit has earlier bans %+v, "+
|
|
"want 1 for a limit and 1 an admin's", third.Notes.EarlierBans)
|
|
}
|
|
}
|
|
|
|
func TestReasonOfTheBansSwwMakes(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 sww 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)
|
|
}
|