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) } }