check / check (push) Waiting to run
SWWAF_MODE (default enforce) takes enforce or observe. In observe mode a request that SWWAF_DENY_NETS, a ban, the country lists or a rate limit would refuse is passed to the app, and its log line names that refusal in would_action. The size and time limits and the 401 still apply. A broken limit makes no ban; bans read from bans.json are kept but refuse nothing, and Ledger.Find reads them without counting a refusal in their notes. Judgement call: in observe mode a broken limit does not reset the client's counters, since the reset comes with the ban. Judgement call: a request a ban would refuse keeps ban_expires. Model: opus-5-5
292 lines
8.6 KiB
Go
292 lines
8.6 KiB
Go
package bans_test
|
|
|
|
import (
|
|
"net/netip"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"sneak.berlin/go/smallwebwaf/internal/bans"
|
|
)
|
|
|
|
const day = 24 * time.Hour
|
|
|
|
func TestRepeatsTripleUntilPermanent(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ledger := bans.New(defaultRules())
|
|
netblock := netip.MustParsePrefix("203.0.113.9/32")
|
|
now := midnight()
|
|
|
|
// Each ban is followed by another as soon as it ends: 1, 3, 9, 27 and
|
|
// 81 hours.
|
|
for i, hours := range []int{1, 3, 9, 27, 81} {
|
|
ban := ledger.BanForLimit(netblock, now, bans.Notes{})
|
|
|
|
length := time.Duration(hours) * time.Hour
|
|
if !ban.Expires.Equal(now.Add(length)) || ban.Notes.EarlierBans != i {
|
|
t.Fatalf("ban %d lasts %s with %d earlier bans, want %d hours and %d",
|
|
i+1, ban.Expires.Sub(now), ban.Notes.EarlierBans, hours, i)
|
|
}
|
|
|
|
now = ban.Expires
|
|
}
|
|
|
|
// The sixth would last 243 hours, more than seven days: it is
|
|
// permanent, and never ends.
|
|
ban := ledger.BanForLimit(netblock, now, bans.Notes{})
|
|
if !ban.Permanent() {
|
|
t.Fatalf("sixth ban ends at %s, want a permanent one", ban.Expires)
|
|
}
|
|
|
|
_, banned := ledger.Check(netblock.Addr(), now.Add(100*365*day))
|
|
if !banned {
|
|
t.Error("a permanent ban ended")
|
|
}
|
|
}
|
|
|
|
func TestRepeatWindowRunsOut(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
for _, tc := range []struct {
|
|
name string
|
|
// gap is the time between the end of the first ban and the second.
|
|
gap time.Duration
|
|
want time.Duration
|
|
}{
|
|
{"broken again as the window ends", day, 3 * time.Hour},
|
|
{"broken again after the window", day + time.Nanosecond, time.Hour},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ledger := bans.New(defaultRules())
|
|
netblock := netip.MustParsePrefix("203.0.113.9/32")
|
|
|
|
first := ledger.BanForLimit(netblock, midnight(), bans.Notes{})
|
|
second := ledger.BanForLimit(netblock, first.Expires.Add(tc.gap), bans.Notes{})
|
|
|
|
if second.Expires.Sub(second.Start) != tc.want || second.Notes.EarlierBans != 1 {
|
|
t.Errorf("second ban lasts %s with %d earlier bans, want %s and 1",
|
|
second.Expires.Sub(second.Start), second.Notes.EarlierBans, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestFirstBanLongerThanTheMaximumIsPermanent(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
rules := defaultRules()
|
|
rules.LimitBanDuration = rules.MaxBanDuration + time.Hour
|
|
ledger := bans.New(rules)
|
|
|
|
ban := ledger.BanForLimit(netip.MustParsePrefix("203.0.113.9/32"), midnight(),
|
|
bans.Notes{})
|
|
if !ban.Permanent() {
|
|
t.Errorf("first ban ends at %s, want a permanent one", ban.Expires)
|
|
}
|
|
}
|
|
|
|
func TestLongestBanSetFarOffDoesNotOverflow(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// With bans of up to 100,000 days, the 14th ban in a row, of 3^13
|
|
// hours, is within the maximum, and three times as long would not fit
|
|
// in a time.Duration. The 15th is permanent.
|
|
rules := defaultRules()
|
|
rules.MaxBanDuration = 100000 * day
|
|
ledger := bans.New(rules)
|
|
netblock := netip.MustParsePrefix("203.0.113.9/32")
|
|
now := midnight()
|
|
|
|
for i := range 14 {
|
|
ban := ledger.BanForLimit(netblock, now, bans.Notes{})
|
|
if !ban.Expires.After(ban.Start) {
|
|
t.Fatalf("ban %d starts at %s and ends at %s", i+1, ban.Start, ban.Expires)
|
|
}
|
|
|
|
now = ban.Expires
|
|
}
|
|
|
|
ban := ledger.BanForLimit(netblock, now, bans.Notes{})
|
|
if !ban.Permanent() {
|
|
t.Errorf("15th ban ends at %s, want a permanent one", ban.Expires)
|
|
}
|
|
}
|
|
|
|
func TestBrokenLimitDuringABanMakesNoOther(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ledger := bans.New(defaultRules())
|
|
netblock := netip.MustParsePrefix("203.0.113.9/32")
|
|
|
|
first := ledger.BanForLimit(netblock, midnight(), bans.Notes{})
|
|
again := ledger.BanForLimit(netblock, midnight().Add(time.Minute), bans.Notes{})
|
|
|
|
if again != first || len(ledger.Bans(netblock)) != 1 {
|
|
t.Errorf("a limit broken during a ban gave %+v and %d bans, want %+v and 1",
|
|
again, len(ledger.Bans(netblock)), first)
|
|
}
|
|
}
|
|
|
|
func TestCheckRefusesWhileTheBanLastsAndCountsTheRefusals(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ledger := bans.New(defaultRules())
|
|
netblock := netip.MustParsePrefix("203.0.113.9/32")
|
|
ban := ledger.BanForLimit(netblock, midnight(), bans.Notes{Requests: 5})
|
|
|
|
for range 3 {
|
|
got, banned := ledger.Check(netblock.Addr(), ban.Expires.Add(-time.Nanosecond))
|
|
if !banned || got.Start != ban.Start {
|
|
t.Fatalf("check during the ban gives %+v and %t", got, banned)
|
|
}
|
|
}
|
|
|
|
_, banned := ledger.Check(netip.MustParseAddr("203.0.113.10"), midnight())
|
|
if banned {
|
|
t.Error("another netblock is banned")
|
|
}
|
|
|
|
_, banned = ledger.Check(netblock.Addr(), ban.Expires)
|
|
if banned {
|
|
t.Error("the ban did not end")
|
|
}
|
|
|
|
// The netblock's requests went from 5 to 8 with the three refused.
|
|
notes := ledger.Bans(netblock)[0].Notes
|
|
if notes.Refused != 3 || notes.Requests != 8 {
|
|
t.Errorf("the notes count %d refused requests of %d, want 3 of 8",
|
|
notes.Refused, notes.Requests)
|
|
}
|
|
}
|
|
|
|
func TestFindCountsNothing(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ledger := bans.New(defaultRules())
|
|
netblock := netip.MustParsePrefix("203.0.113.9/32")
|
|
ban := ledger.BanForLimit(netblock, midnight(), bans.Notes{Requests: 5})
|
|
|
|
got, banned := ledger.Find(netblock.Addr(), ban.Expires.Add(-time.Nanosecond))
|
|
if !banned || got != ban {
|
|
t.Errorf("find during the ban gives %+v and %t, want %+v", got, banned, ban)
|
|
}
|
|
|
|
_, banned = ledger.Find(netblock.Addr(), ban.Expires)
|
|
if banned {
|
|
t.Error("the ban did not end")
|
|
}
|
|
|
|
if notes := ledger.Bans(netblock)[0].Notes; notes != ban.Notes {
|
|
t.Errorf("the notes are %+v, want them unchanged, %+v", notes, ban.Notes)
|
|
}
|
|
}
|
|
|
|
func TestMaxBansDropsTheEarliestBanOfTheNetblockSeenLongestAgo(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
rules := defaultRules()
|
|
rules.MaxBans = 3
|
|
ledger := bans.New(rules)
|
|
a := netip.MustParsePrefix("203.0.113.1/32")
|
|
b := netip.MustParsePrefix("203.0.113.2/32")
|
|
c := netip.MustParsePrefix("203.0.113.3/32")
|
|
d := netip.MustParsePrefix("2001:db8::/64")
|
|
now := midnight()
|
|
|
|
first := ledger.BanForLimit(a, now, bans.Notes{})
|
|
ledger.BanForLimit(b, now, bans.Notes{})
|
|
ledger.BanForLimit(c, now, bans.Notes{})
|
|
|
|
// A request from a makes b the netblock seen longest ago, and its ban
|
|
// goes to make room for d's.
|
|
ledger.Check(a.Addr(), now)
|
|
ledger.BanForLimit(d, now, bans.Notes{})
|
|
wantBans(t, ledger, map[netip.Prefix]int{a: 1, b: 0, c: 1, d: 1})
|
|
|
|
// a is banned again once its ban has ended; c, seen longest ago, goes.
|
|
ledger.BanForLimit(a, first.Expires, bans.Notes{})
|
|
wantBans(t, ledger, map[netip.Prefix]int{a: 2, c: 0, d: 1})
|
|
|
|
// With d seen since, a is seen longest ago, and its earlier ban goes
|
|
// first.
|
|
ledger.Check(d.Addr(), first.Expires)
|
|
ledger.BanForLimit(b, first.Expires, bans.Notes{})
|
|
wantBans(t, ledger, map[netip.Prefix]int{a: 1, b: 1, d: 1})
|
|
|
|
if !ledger.Bans(a)[0].Start.Equal(first.Expires) {
|
|
t.Errorf("a kept its ban of %s, want the later one", ledger.Bans(a)[0].Start)
|
|
}
|
|
}
|
|
|
|
func TestFullLedgerDropsTheEarlierBanOfTheNetblockBannedAgain(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// With room for one ban, the netblock's ended ban goes to make room for
|
|
// its new one, whose notes still count it.
|
|
rules := defaultRules()
|
|
rules.MaxBans = 1
|
|
ledger := bans.New(rules)
|
|
netblock := netip.MustParsePrefix("203.0.113.9/32")
|
|
|
|
first := ledger.BanForLimit(netblock, midnight(), bans.Notes{})
|
|
second := ledger.BanForLimit(netblock, first.Expires, bans.Notes{})
|
|
|
|
held := ledger.Bans(netblock)
|
|
if len(held) != 1 || held[0] != second || held[0].Notes.EarlierBans != 1 {
|
|
t.Errorf("the ledger holds %+v, want only the second ban, with 1 earlier ban",
|
|
held)
|
|
}
|
|
}
|
|
|
|
func TestRequestTextsAreCutTo256Bytes(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ledger := bans.New(defaultRules())
|
|
netblock := netip.MustParsePrefix("203.0.113.9/32")
|
|
long := strings.Repeat("a", 300)
|
|
request := bans.Request{
|
|
Time: midnight(), Method: long, Host: long, Path: long, Status: 403, UserAgent: long,
|
|
}
|
|
|
|
ban := ledger.BanForLimit(netblock, midnight(), bans.Notes{Request: request})
|
|
|
|
cut := long[:256]
|
|
want := bans.Request{
|
|
Time: midnight(), Method: cut, Host: cut, Path: cut, Status: 403, UserAgent: cut,
|
|
}
|
|
|
|
if ban.Notes.Request != want || ledger.Bans(netblock)[0].Notes.Request != want {
|
|
t.Errorf("the notes keep %+v, want each text cut to 256 bytes", ban.Notes.Request)
|
|
}
|
|
}
|
|
|
|
// defaultRules are the rules at the settings' defaults.
|
|
func defaultRules() bans.Rules {
|
|
return bans.Rules{
|
|
LimitBanDuration: time.Hour,
|
|
LimitBanRepeatWindow: day,
|
|
MaxBanDuration: 7 * day,
|
|
MaxBans: 5000,
|
|
}
|
|
}
|
|
|
|
// midnight is when the tests' first bans are made.
|
|
func midnight() time.Time {
|
|
return time.Date(2026, 10, 6, 0, 0, 0, 0, time.UTC)
|
|
}
|
|
|
|
// wantBans checks how many bans the ledger holds on each netblock.
|
|
func wantBans(t *testing.T, ledger *bans.Ledger, want map[netip.Prefix]int) {
|
|
t.Helper()
|
|
|
|
for netblock, count := range want {
|
|
got := len(ledger.Bans(netblock))
|
|
if got != count {
|
|
t.Errorf("%s has %d bans, want %d", netblock, got, count)
|
|
}
|
|
}
|
|
}
|