check / check (push) Successful in 3m13s
Every *.rules file in SWWAF_RULES_DIR is read at start and on each change, and 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 clear sign of attack. path, query and uri are matched as the request line sent them. bans.json gains each ban's cause, and ban notes count earlier bans by 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
303 lines
9.3 KiB
Go
303 lines
9.3 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 != (bans.EarlierBans{Limit: 1}) {
|
|
t.Errorf("the next ban lasts %s with earlier bans %+v, want 3h and 1 for a limit",
|
|
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 TestPermanentBanStartedBeforeAnEndedOneRefuses(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// As when an admin adds a permanent ban to bans.json with a start
|
|
// before that of the netblock's ban that has ended.
|
|
netblock := netip.MustParsePrefix("203.0.113.0/24")
|
|
permanent := bans.Ban{Netblock: netblock, Start: midnight().Add(-time.Hour)}
|
|
ended := bans.Ban{
|
|
Netblock: netblock,
|
|
Start: midnight(),
|
|
Expires: midnight().Add(time.Hour),
|
|
}
|
|
|
|
ledger := bans.New(defaultRules())
|
|
ledger.Load([]bans.Ban{permanent, ended})
|
|
|
|
now := midnight().Add(2 * time.Hour)
|
|
client := netip.MustParseAddr("203.0.113.9")
|
|
|
|
ban, banned := ledger.Find(client, now)
|
|
if !banned || !ban.Permanent() {
|
|
t.Errorf("find gives %+v and %t, want the permanent ban", ban, banned)
|
|
}
|
|
|
|
ban, banned = ledger.Check(client, now)
|
|
if !banned || !ban.Permanent() {
|
|
t.Errorf("the client is refused: %t, under %+v, want under the permanent ban",
|
|
banned, ban)
|
|
}
|
|
|
|
// A limit broken now makes no shorter ban over the permanent one.
|
|
ban = ledger.BanForLimit(netblock, now, bans.Notes{})
|
|
if !ban.Permanent() || len(ledger.Bans(netblock)) != 2 {
|
|
t.Errorf("a broken limit returned %+v and left the netblock %d bans, "+
|
|
"want the permanent ban and 2", ban, len(ledger.Bans(netblock)))
|
|
}
|
|
}
|
|
|
|
func TestNextBanWorkedOutFromTheBanThatEndedLast(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// A 9-hour ban smallwebwaf made, the third in a row, and an admin's
|
|
// 1-hour ban added to bans.json over it, with no notes.
|
|
netblock := netip.MustParsePrefix("203.0.113.9/32")
|
|
nineHours := bans.Ban{
|
|
Netblock: netblock,
|
|
Start: midnight(),
|
|
Expires: midnight().Add(9 * time.Hour),
|
|
Cause: bans.CauseLimit,
|
|
Notes: bans.Notes{EarlierBans: bans.EarlierBans{Limit: 2}},
|
|
}
|
|
admins := bans.Ban{
|
|
Netblock: netblock,
|
|
Start: midnight().Add(time.Hour),
|
|
Expires: midnight().Add(2 * time.Hour),
|
|
}
|
|
|
|
ledger := bans.New(defaultRules())
|
|
ledger.Load([]bans.Ban{nineHours, admins})
|
|
|
|
// Once both have ended, a limit broken within the repeat window bans
|
|
// for three times the 9 hours, and the notes count the two bans
|
|
// before the 9-hour one and it, for a limit, and the admin's, without
|
|
// a cause.
|
|
ban := ledger.BanForLimit(netblock, nineHours.Expires.Add(time.Hour), bans.Notes{})
|
|
if ban.Expires.Sub(ban.Start) != 27*time.Hour ||
|
|
ban.Notes.EarlierBans != (bans.EarlierBans{Limit: 3, WithoutCause: 1}) {
|
|
t.Errorf("the next ban lasts %s with earlier bans %+v, "+
|
|
"want 27h, 3 for a limit and 1 without a cause",
|
|
ban.Expires.Sub(ban.Start), ban.Notes.EarlierBans)
|
|
}
|
|
}
|
|
|
|
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 TestLoadReplacesTheBansHeld(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// Room for three bans, so that the second load, were it added to the
|
|
// two bans held, would drop none of them to make room.
|
|
rules := defaultRules()
|
|
rules.MaxBans = 3
|
|
ledger := bans.New(rules)
|
|
kept := bans.Ban{Netblock: netip.MustParsePrefix("2001:db8::/64"), Start: midnight()}
|
|
ledger.Load([]bans.Ban{
|
|
{Netblock: netip.MustParsePrefix("203.0.113.0/24"), Start: midnight()},
|
|
kept,
|
|
})
|
|
|
|
// Loaded again without the first ban, as when an admin's edit of
|
|
// bans.json is taken in, that ban is lifted.
|
|
ledger.Load([]bans.Ban{kept})
|
|
|
|
_, banned := ledger.Check(netip.MustParseAddr("203.0.113.9"), midnight())
|
|
if banned {
|
|
t.Error("a ban left out of the second load still refuses")
|
|
}
|
|
|
|
// The ledger holds one ban, so it makes two more without dropping any.
|
|
first := ledger.BanForLimit(netip.MustParsePrefix("198.51.100.7/32"), midnight(),
|
|
bans.Notes{})
|
|
second := ledger.BanForLimit(netip.MustParsePrefix("198.51.100.8/32"), midnight(),
|
|
bans.Notes{})
|
|
|
|
want := []bans.Ban{first, second, kept}
|
|
if got := ledger.Snapshot(); !slices.Equal(got, want) {
|
|
t.Errorf("the ledger holds %+v, want %+v", got, want)
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|