check / check (push) Waiting to run
Every *.rules file in SWWAF_RULES_DIR is read at start and on each change. Each request is checked against the rules after the rate limits: log notes a match, block refuses with 403, ban refuses and bans the netblock for SWWAF_ATTACK_BAN_DURATION, made permanent by its next request or attack. path, query and uri are matched as the request line sent them; header:Host and header:Transfer-Encoding are refused. Bans gain a cause. The image ships 00-default.rules. Judgement call: a header sent twice is matched with its values joined by ", ". Judgement call: SWWAF_MAX_BAN_DURATION does not cap a ban for an attack. Not in this unit: offences for rule matches, with the error burst. Model: opus-5-5
388 lines
12 KiB
Go
388 lines
12 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 != (bans.EarlierBans{Limit: i}) {
|
|
t.Fatalf("ban %d lasts %s with earlier bans %+v, want %d hours and %d for a limit",
|
|
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 != (bans.EarlierBans{Limit: 1}) {
|
|
t.Errorf("second ban lasts %s with earlier bans %+v, want %s and 1 for a limit",
|
|
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 != (bans.EarlierBans{Limit: 1}) {
|
|
t.Errorf("the ledger holds %+v, want only the second ban, "+
|
|
"with 1 earlier ban for a limit", held)
|
|
}
|
|
}
|
|
|
|
func TestRequestDuringAnAttackBanMakesItPermanent(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ledger := bans.New(defaultRules())
|
|
netblock := netip.MustParsePrefix("203.0.113.9/32")
|
|
notes := bans.Notes{RuleID: "env-file", Target: "path"}
|
|
|
|
ban := ledger.BanForAttack(netblock, midnight(), notes)
|
|
if !ban.Expires.Equal(midnight().Add(7*day)) || ban.Cause != bans.CauseAttack ||
|
|
ban.Notes.RuleID != "env-file" || ledger.Made(bans.CauseAttack) != 1 ||
|
|
ledger.Made(bans.CauseLimit) != 0 {
|
|
t.Fatalf("the ban is %+v, with %d made for an attack and %d for a limit, "+
|
|
"want one for an attack, of seven days", ban,
|
|
ledger.Made(bans.CauseAttack), ledger.Made(bans.CauseLimit))
|
|
}
|
|
|
|
wantChanged(t, ledger, true)
|
|
|
|
// In observe mode the ban refuses nothing, and stays as it is.
|
|
got, _ := ledger.Find(netblock.Addr(), midnight().Add(time.Hour))
|
|
if got.Permanent() {
|
|
t.Fatal("a request found under the ban made it permanent")
|
|
}
|
|
|
|
// A request it refuses makes it permanent, and bans.json due.
|
|
got, _ = ledger.Check(netblock.Addr(), midnight().Add(time.Hour))
|
|
if !got.Permanent() || !ledger.Bans(netblock)[0].Permanent() {
|
|
t.Fatalf("after a request during the ban, it is %+v, want it permanent", got)
|
|
}
|
|
|
|
wantChanged(t, ledger, true)
|
|
|
|
_, banned := ledger.Check(netblock.Addr(), midnight().Add(100*365*day))
|
|
if !banned {
|
|
t.Error("the permanent ban ended")
|
|
}
|
|
}
|
|
|
|
func TestAttackAfterAnAttackBanHasEndedBansPermanently(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ledger := bans.New(defaultRules())
|
|
netblock := netip.MustParsePrefix("203.0.113.9/32")
|
|
|
|
// A ban for a broken limit before does not count.
|
|
first := ledger.BanForLimit(netblock, midnight(), bans.Notes{})
|
|
second := ledger.BanForAttack(netblock, first.Expires, bans.Notes{})
|
|
|
|
if second.Expires.Sub(second.Start) != 7*day {
|
|
t.Fatalf("the first ban for an attack lasts %s, want 7 days",
|
|
second.Expires.Sub(second.Start))
|
|
}
|
|
|
|
// Once that has run out without a request, the netblock is served, and
|
|
// its next clear sign of attack bans it for good.
|
|
_, banned := ledger.Check(netblock.Addr(), second.Expires)
|
|
if banned {
|
|
t.Fatal("the ban did not end")
|
|
}
|
|
|
|
// Its notes show the earlier ban for an attack that makes it permanent,
|
|
// beside the one for a limit.
|
|
third := ledger.BanForAttack(netblock, second.Expires.Add(30*day), bans.Notes{})
|
|
if !third.Permanent() ||
|
|
third.Notes.EarlierBans != (bans.EarlierBans{Limit: 1, Attack: 1}) {
|
|
t.Errorf("the next ban for an attack is %+v, want a permanent one, "+
|
|
"with 1 earlier ban for a limit and 1 for an attack", third)
|
|
}
|
|
}
|
|
|
|
func TestAttackBanDoesNotLengthenTheNextBanForALimit(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ledger := bans.New(defaultRules())
|
|
netblock := netip.MustParsePrefix("203.0.113.9/32")
|
|
|
|
// Three times the seven days would be permanent; a limit broken as the
|
|
// ban for an attack ends bans for an hour, as a first broken limit does.
|
|
attack := ledger.BanForAttack(netblock, midnight(), bans.Notes{})
|
|
limit := ledger.BanForLimit(netblock, attack.Expires, bans.Notes{})
|
|
|
|
if limit.Expires.Sub(limit.Start) != time.Hour || limit.Cause != bans.CauseLimit {
|
|
t.Errorf("the ban for a limit is %+v, want one of an hour", limit)
|
|
}
|
|
|
|
// And a request during the ban for a limit leaves it as it is.
|
|
got, _ := ledger.Check(netblock.Addr(), limit.Start)
|
|
if got.Permanent() {
|
|
t.Error("a request during a ban for a limit made it permanent")
|
|
}
|
|
}
|
|
|
|
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,
|
|
AttackBanDuration: 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)
|
|
}
|
|
}
|
|
}
|