check / check (push) Successful in 3m33s
smallwebwaf watches SWWAF_STATE_DIR with fsnotify and takes in an edit of bans.json, clients.json or lookups.json as soon as it is saved, 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, and each write takes in an edit made since first. An edit that does not parse is renamed to <name>.bad at the file's next write, which writes the file again from memory and logs the file and where the error is. README.md says how to add and lift a ban. Judgement call: a broken edit is set aside at the file's next write, not when seen, since an editor's file can be read half written. Judgement call: a state file that cannot be read is not written over. Model: opus-5-5
223 lines
6.5 KiB
Go
223 lines
6.5 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 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()
|
|
|
|
rules := defaultRules()
|
|
rules.MaxBans = 2
|
|
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, and the ledger, holding
|
|
// one ban, makes another without dropping any.
|
|
ledger.Load([]bans.Ban{kept})
|
|
made := ledger.BanForLimit(netip.MustParsePrefix("198.51.100.7/32"), midnight(),
|
|
bans.Notes{})
|
|
|
|
_, banned := ledger.Check(netip.MustParseAddr("203.0.113.9"), midnight())
|
|
if banned {
|
|
t.Error("a ban left out of the second load still refuses")
|
|
}
|
|
|
|
if got, want := ledger.Snapshot(), []bans.Ban{made, kept}; !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)
|
|
}
|
|
}
|