check / check (push) Successful in 3m24s
smallwebwaf now copies its state to bans.json, clients.json and lookups.json in SWWAF_STATE_DIR, as "Persistent state" in SPEC.md describes, and reads them back at start, so a restart lifts no ban and gives no client a fresh allowance. Each client gains a history, and a ban's notes count the netblock's requests. bans.json is written SWWAF_STATE_WRITE_DELAY after a ban, and every file every SWWAF_STATE_COUNTER_INTERVAL and at the stop. A ban read back is masked to its netblock and refuses every client in it. A file that does not parse, an unknown version, an entry without a field it needs, or an unwritable directory stops the start. Deviation: no AS number or name, and no ban cause, reason or lifting yet. Model: opus-5-5
194 lines
5.6 KiB
Go
194 lines
5.6 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 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)
|
|
}
|
|
}
|