Keep the bans, the clients and GeoJS's answers in state files (closes #17)
check / check (push) Successful in 3m24s
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
This commit was merged in pull request #72.
This commit is contained in:
@@ -0,0 +1,193 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user