check / check (push) In progress
smallwebwaf watches SWWAF_STATE_DIR with fsnotify and takes in a saved edit of a state file in place of what it held. It tells its own writes from an admin's 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 file's next write. Every ban on a netblock is checked, and the next ban is worked out from the one that ended last. Two metrics count the edits taken in and set aside. 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
292 lines
8.9 KiB
Go
292 lines
8.9 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)
|
|
|
|
ban, banned := ledger.Check(netip.MustParseAddr("203.0.113.9"), 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)
|
|
}
|
|
}
|