check / check (push) Successful in 4m1s
smallwebwaf watches SWWAF_STATE_DIR with fsnotify and takes in a saved edit of a state file in place of what it held. It knows its own writes by the SHA-256 of what it last read or wrote; each write first takes in an edit made since. An edit that does not parse is renamed to <name>.bad at the next write. Each edit taken in or set aside is logged and counted. Every ban on a netblock is checked, and the next ban is worked out from the one that ended last. README.md says how to add and lift a ban. Judgement call: a broken edit is set aside at the next write, since an editor's file can be read half written. Model: opus-5-5
298 lines
9.1 KiB
Go
298 lines
9.1 KiB
Go
package bans_test
|
|
|
|
import (
|
|
"net/netip"
|
|
"slices"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"sneak.berlin/go/smallwebwaf/internal/bans"
|
|
)
|
|
|
|
func TestChangedAfterABanIsMade(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ledger := bans.New(defaultRules())
|
|
netblock := netip.MustParsePrefix("203.0.113.9/32")
|
|
|
|
wantChanged(t, ledger, false)
|
|
|
|
ledger.BanForLimit(netblock, midnight(), bans.Notes{})
|
|
wantChanged(t, ledger, true)
|
|
|
|
// A limit broken during the ban makes no other, and a refusal changes
|
|
// only the counts in the notes, which wait for the interval's write.
|
|
ledger.BanForLimit(netblock, midnight().Add(time.Minute), bans.Notes{})
|
|
ledger.Check(netblock.Addr(), midnight().Add(time.Minute))
|
|
wantChanged(t, ledger, false)
|
|
|
|
// Two bans before the value is read leave one.
|
|
ledger.BanForLimit(netip.MustParsePrefix("203.0.113.10/32"), midnight(), bans.Notes{})
|
|
ledger.BanForLimit(netip.MustParsePrefix("203.0.113.11/32"), midnight(), bans.Notes{})
|
|
wantChanged(t, ledger, true)
|
|
wantChanged(t, ledger, false)
|
|
}
|
|
|
|
func TestSnapshotListsEveryBanByNetblock(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ledger := bans.New(defaultRules())
|
|
v6 := netip.MustParsePrefix("2001:db8::/64")
|
|
high := netip.MustParsePrefix("203.0.113.10/32")
|
|
low := netip.MustParsePrefix("203.0.113.9/32")
|
|
|
|
first := ledger.BanForLimit(v6, midnight(), bans.Notes{})
|
|
ledger.BanForLimit(high, midnight(), bans.Notes{})
|
|
ledger.BanForLimit(low, midnight(), bans.Notes{})
|
|
ledger.BanForLimit(v6, first.Expires, bans.Notes{})
|
|
|
|
snapshot := ledger.Snapshot()
|
|
|
|
got := make([]string, 0, len(snapshot))
|
|
for _, ban := range snapshot {
|
|
got = append(got, ban.Netblock.String()+" "+ban.Start.Format(time.Kitchen))
|
|
}
|
|
|
|
want := []string{
|
|
"203.0.113.9/32 12:00AM", "203.0.113.10/32 12:00AM",
|
|
"2001:db8::/64 12:00AM", "2001:db8::/64 1:00AM",
|
|
}
|
|
if !slices.Equal(got, want) {
|
|
t.Errorf("snapshot %v, want %v", got, want)
|
|
}
|
|
}
|
|
|
|
func TestLoadedBansCarryOn(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
before := bans.New(defaultRules())
|
|
netblock := netip.MustParsePrefix("203.0.113.9/32")
|
|
ban := before.BanForLimit(netblock, midnight(), bans.Notes{Limit: 1})
|
|
|
|
// Loaded into a new ledger, as across a restart, the ban still refuses
|
|
// while it lasts, and once it has ended a broken limit bans for three
|
|
// times as long, with the loaded ban counted among the earlier ones.
|
|
after := bans.New(defaultRules())
|
|
after.Load(before.Snapshot())
|
|
|
|
_, banned := after.Check(netblock.Addr(), ban.Expires.Add(-time.Second))
|
|
if !banned {
|
|
t.Error("the loaded ban does not refuse")
|
|
}
|
|
|
|
again := after.BanForLimit(netblock, ban.Expires, bans.Notes{})
|
|
if again.Expires.Sub(again.Start) != 3*time.Hour || again.Notes.EarlierBans != 1 {
|
|
t.Errorf("the next ban lasts %s with %d earlier bans, want 3h and 1",
|
|
again.Expires.Sub(again.Start), again.Notes.EarlierBans)
|
|
}
|
|
}
|
|
|
|
func TestLoadedBanRefusesEveryClientInItsNetblock(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// Two entries as an admin might write them, with addresses not masked
|
|
// to their lengths, the IPv6 one shorter than the /64 an IPv6 client's
|
|
// ban covers, beside a ban the ledger makes on one IPv4 address.
|
|
ledger := bans.New(defaultRules())
|
|
ledger.Load([]bans.Ban{
|
|
{Netblock: netip.MustParsePrefix("203.0.113.9/24"), Start: midnight()},
|
|
{Netblock: netip.MustParsePrefix("2001:db8::1/48"), Start: midnight()},
|
|
})
|
|
ledger.BanForLimit(netip.MustParsePrefix("198.51.100.7/32"), midnight(), bans.Notes{})
|
|
|
|
for client, want := range map[string]bool{
|
|
"203.0.113.0": true,
|
|
"203.0.113.200": true,
|
|
"203.0.114.1": false,
|
|
"2001:db8:0:5::1": true,
|
|
"2001:db8:1::1": false,
|
|
"198.51.100.7": true,
|
|
"198.51.100.8": false,
|
|
} {
|
|
_, banned := ledger.Check(netip.MustParseAddr(client), midnight())
|
|
if banned != want {
|
|
t.Errorf("%s is refused: %t, want %t", client, banned, want)
|
|
}
|
|
}
|
|
|
|
// The loaded netblocks are written back masked.
|
|
snapshot := ledger.Snapshot()
|
|
|
|
got := make([]string, 0, len(snapshot))
|
|
for _, ban := range snapshot {
|
|
got = append(got, ban.Netblock.String())
|
|
}
|
|
|
|
want := []string{"198.51.100.7/32", "203.0.113.0/24", "2001:db8::/48"}
|
|
if !slices.Equal(got, want) {
|
|
t.Errorf("the ledger holds bans on %v, want %v", got, want)
|
|
}
|
|
}
|
|
|
|
func TestPermanentBanStartedBeforeAnEndedOneRefuses(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// As when an admin adds a permanent ban to bans.json with a start
|
|
// before that of the netblock's ban that has ended.
|
|
netblock := netip.MustParsePrefix("203.0.113.0/24")
|
|
permanent := bans.Ban{Netblock: netblock, Start: midnight().Add(-time.Hour)}
|
|
ended := bans.Ban{
|
|
Netblock: netblock,
|
|
Start: midnight(),
|
|
Expires: midnight().Add(time.Hour),
|
|
}
|
|
|
|
ledger := bans.New(defaultRules())
|
|
ledger.Load([]bans.Ban{permanent, ended})
|
|
|
|
now := midnight().Add(2 * time.Hour)
|
|
client := netip.MustParseAddr("203.0.113.9")
|
|
|
|
ban, banned := ledger.Find(client, now)
|
|
if !banned || !ban.Permanent() {
|
|
t.Errorf("find gives %+v and %t, want the permanent ban", ban, banned)
|
|
}
|
|
|
|
ban, banned = ledger.Check(client, now)
|
|
if !banned || !ban.Permanent() {
|
|
t.Errorf("the client is refused: %t, under %+v, want under the permanent ban",
|
|
banned, ban)
|
|
}
|
|
|
|
// A limit broken now makes no shorter ban over the permanent one.
|
|
ban = ledger.BanForLimit(netblock, now, bans.Notes{})
|
|
if !ban.Permanent() || len(ledger.Bans(netblock)) != 2 {
|
|
t.Errorf("a broken limit returned %+v and left the netblock %d bans, "+
|
|
"want the permanent ban and 2", ban, len(ledger.Bans(netblock)))
|
|
}
|
|
}
|
|
|
|
func TestNextBanWorkedOutFromTheBanThatEndedLast(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// A 9-hour ban smallwebwaf made, the third in a row, and an admin's
|
|
// 1-hour ban added to bans.json over it, with no notes.
|
|
netblock := netip.MustParsePrefix("203.0.113.9/32")
|
|
nineHours := bans.Ban{
|
|
Netblock: netblock,
|
|
Start: midnight(),
|
|
Expires: midnight().Add(9 * time.Hour),
|
|
Notes: bans.Notes{EarlierBans: 2},
|
|
}
|
|
admins := bans.Ban{
|
|
Netblock: netblock,
|
|
Start: midnight().Add(time.Hour),
|
|
Expires: midnight().Add(2 * time.Hour),
|
|
}
|
|
|
|
ledger := bans.New(defaultRules())
|
|
ledger.Load([]bans.Ban{nineHours, admins})
|
|
|
|
// Once both have ended, a limit broken within the repeat window bans
|
|
// for three times the 9 hours, and the notes count the two bans
|
|
// before the 9-hour one, it, and the admin's.
|
|
ban := ledger.BanForLimit(netblock, nineHours.Expires.Add(time.Hour), bans.Notes{})
|
|
if ban.Expires.Sub(ban.Start) != 27*time.Hour || ban.Notes.EarlierBans != 4 {
|
|
t.Errorf("the next ban lasts %s with %d earlier bans, want 27h and 4",
|
|
ban.Expires.Sub(ban.Start), ban.Notes.EarlierBans)
|
|
}
|
|
}
|
|
|
|
func TestLoadKeepsAtMostMaxBansDroppingTheEarliest(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// bans.json lists the bans by netblock, not in the order they began.
|
|
later := bans.Ban{Netblock: netip.MustParsePrefix("203.0.113.1/32"), Start: midnight()}
|
|
earlier := bans.Ban{
|
|
Netblock: netip.MustParsePrefix("203.0.113.2/32"),
|
|
Start: midnight().Add(-time.Hour),
|
|
}
|
|
|
|
rules := defaultRules()
|
|
rules.MaxBans = 1
|
|
ledger := bans.New(rules)
|
|
ledger.Load([]bans.Ban{later, earlier})
|
|
|
|
held := ledger.Snapshot()
|
|
if len(held) != 1 || held[0] != later {
|
|
t.Errorf("the ledger holds %+v, want only the ban that began later", held)
|
|
}
|
|
}
|
|
|
|
func TestLoadReplacesTheBansHeld(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// Room for three bans, so that the second load, were it added to the
|
|
// two bans held, would drop none of them to make room.
|
|
rules := defaultRules()
|
|
rules.MaxBans = 3
|
|
ledger := bans.New(rules)
|
|
kept := bans.Ban{Netblock: netip.MustParsePrefix("2001:db8::/64"), Start: midnight()}
|
|
ledger.Load([]bans.Ban{
|
|
{Netblock: netip.MustParsePrefix("203.0.113.0/24"), Start: midnight()},
|
|
kept,
|
|
})
|
|
|
|
// Loaded again without the first ban, as when an admin's edit of
|
|
// bans.json is taken in, that ban is lifted.
|
|
ledger.Load([]bans.Ban{kept})
|
|
|
|
_, banned := ledger.Check(netip.MustParseAddr("203.0.113.9"), midnight())
|
|
if banned {
|
|
t.Error("a ban left out of the second load still refuses")
|
|
}
|
|
|
|
// The ledger holds one ban, so it makes two more without dropping any.
|
|
first := ledger.BanForLimit(netip.MustParsePrefix("198.51.100.7/32"), midnight(),
|
|
bans.Notes{})
|
|
second := ledger.BanForLimit(netip.MustParsePrefix("198.51.100.8/32"), midnight(),
|
|
bans.Notes{})
|
|
|
|
want := []bans.Ban{first, second, kept}
|
|
if got := ledger.Snapshot(); !slices.Equal(got, want) {
|
|
t.Errorf("the ledger holds %+v, want %+v", got, want)
|
|
}
|
|
}
|
|
|
|
func TestLoadCutsTheTextsTo256Bytes(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
long := strings.Repeat("a", 300)
|
|
ban := bans.Ban{
|
|
Netblock: netip.MustParsePrefix("203.0.113.9/32"),
|
|
Start: midnight(),
|
|
Notes: bans.Notes{Request: bans.Request{
|
|
Method: long, Host: long, Path: long, UserAgent: long,
|
|
}},
|
|
}
|
|
|
|
ledger := bans.New(defaultRules())
|
|
ledger.Load([]bans.Ban{ban})
|
|
|
|
cut := long[:256]
|
|
want := bans.Request{Method: cut, Host: cut, Path: cut, UserAgent: cut}
|
|
|
|
got := ledger.Snapshot()[0].Notes.Request
|
|
if got != want {
|
|
t.Errorf("the notes keep %+v, want each text cut to 256 bytes", got)
|
|
}
|
|
}
|
|
|
|
// wantChanged checks whether the ledger's Changed has a value to read.
|
|
func wantChanged(t *testing.T, ledger *bans.Ledger, want bool) {
|
|
t.Helper()
|
|
|
|
got := false
|
|
|
|
select {
|
|
case <-ledger.Changed():
|
|
got = true
|
|
default:
|
|
}
|
|
|
|
if got != want {
|
|
t.Errorf("Changed has a value: %t, want %t", got, want)
|
|
}
|
|
}
|