Bans an admin makes or lifts: the admin cause, a reason, lifted bans kept (closes #86)
check / check (push) Successful in 3m27s

A bans.json entry without a cause gets the cause admin, written back so.
Bans whose cause is admin are never dropped and do not count toward
SWWAF_MAX_BANS, so setting a ban's cause to admin keeps it. Bans
smallwebwaf makes get a reason: the limit broken or the rule matched. A
lifted ban refuses nothing, is kept, and makes no later ban longer.
smallwebwaf_bans_made_total counts admin bans an edit adds while running;
earlier_bans counts admin in place of without_cause.

Judgement call: lifted lifts at once, whatever time it gives.
Judgement call: a lifted ban still counts in earlier_bans.
Known gap: a ban dropped from behind an admin's ban on its netblock leaves that netblock's later earlier_bans.

Model: opus-5-5
This commit was merged in pull request #88.
This commit is contained in:
2026-10-06 23:09:52 +02:00
parent 0797e5def2
commit ee9ba08a8a
11 changed files with 677 additions and 153 deletions
+36 -14
View File
@@ -48,7 +48,7 @@ var (
errVersion = errors.New("unknown version")
// errMissing is for an entry without a field it needs.
errMissing = errors.New("has no")
errCause = errors.New("is not limit or attack")
errCause = errors.New("is not limit, attack or admin")
)
// Params are what Load needs.
@@ -96,12 +96,15 @@ type bansFile struct {
}
// banEntry is a ban as bans.json holds it: a permanent ban's expires is
// null, and a ban an admin added may have no cause.
// null, a ban an admin added may have no cause, which makes it an
// admin's, and lifted is left out until an admin lifts the ban.
type banEntry struct {
Netblock netip.Prefix `json:"netblock"`
Start time.Time `json:"start"`
Expires *time.Time `json:"expires"`
Cause string `json:"cause,omitempty"`
Cause string `json:"cause"`
Reason string `json:"reason,omitempty"`
Lifted *time.Time `json:"lifted,omitempty"`
Notes bans.Notes `json:"notes"`
}
@@ -262,7 +265,7 @@ func (f *Files) fileChanged(name string) {
// runs, by Watch or by a write, is taken in here. An edit that does not
// parse is neither counted nor logged, and takeIn's error returned.
func (f *Files) takeInEdit(name string, data []byte) error {
_, err := f.takeIn(name, data)
_, err := f.takeIn(name, data, true)
if err != nil {
return err
}
@@ -284,7 +287,7 @@ func (f *Files) read(name string) (int, error) {
return 0, err
}
return f.takeIn(name, data)
return f.takeIn(name, data, false)
}
// readChanged returns what the state file name holds, and whether that
@@ -308,9 +311,11 @@ func (f *Files) readChanged(name string) ([]byte, bool, error) {
// takeIn parses data, what the state file name holds, puts it into the
// part that keeps that state, in place of what the part held, and returns
// how many entries the file holds. An error names the file and, where the
// JSON decoder tells it, the line and column, or else the entry.
func (f *Files) takeIn(name string, data []byte) (int, error) {
// how many entries the file holds. edit is whether data is an admin's
// edit taken in while smallwebwaf runs, rather than the file read at the
// start. An error names the file and, where the JSON decoder tells it,
// the line and column, or else the entry.
func (f *Files) takeIn(name string, data []byte, edit bool) (int, error) {
path := filepath.Join(f.params.Dir, name)
var entries int
@@ -329,7 +334,12 @@ func (f *Files) takeIn(name string, data []byte) (int, error) {
held = append(held, entry.ban())
}
f.params.Ledger.Load(held)
if edit {
f.params.Ledger.LoadEdit(held)
} else {
f.params.Ledger.Load(held)
}
entries = len(held)
case clientsJSON:
var file clientsFile
@@ -447,22 +457,34 @@ func (f *Files) encode(name string) ([]byte, error) {
// newBanEntry returns ban as bans.json holds it.
func newBanEntry(ban bans.Ban) banEntry {
entry := banEntry{
Netblock: ban.Netblock, Start: ban.Start, Cause: ban.Cause, Notes: ban.Notes,
Netblock: ban.Netblock, Start: ban.Start, Cause: ban.Cause, Reason: ban.Reason,
Notes: ban.Notes,
}
if !ban.Permanent() {
entry.Expires = &ban.Expires
}
if !ban.Lifted.IsZero() {
entry.Lifted = &ban.Lifted
}
return entry
}
// ban returns the ban an entry of bans.json holds.
func (e banEntry) ban() bans.Ban {
ban := bans.Ban{Netblock: e.Netblock, Start: e.Start, Cause: e.Cause, Notes: e.Notes}
ban := bans.Ban{
Netblock: e.Netblock, Start: e.Start, Cause: e.Cause, Reason: e.Reason,
Notes: e.Notes,
}
if e.Expires != nil {
ban.Expires = *e.Expires
}
if e.Lifted != nil {
ban.Lifted = *e.Lifted
}
return ban
}
@@ -470,8 +492,8 @@ func (e banEntry) ban() bans.Ban {
// client, a start, from which the length of the netblock's next ban is
// worked out, or an expires, which would make it permanent. A permanent
// ban's expires is null, which Bans cannot tell from a missing one, so
// each expires is read again as written. A cause other than limit or
// attack, most likely misspelt, is refused too.
// each expires is read again as written. A cause other than limit,
// attack or admin, most likely misspelt, is refused too.
func (f *bansFile) check(data []byte) error {
var written struct {
Bans []struct {
@@ -493,7 +515,7 @@ func (f *bansFile) check(data []byte) error {
case written.Bans[i].Expires == nil:
return missing(i, "expires")
case entry.Cause != "" && entry.Cause != bans.CauseLimit &&
entry.Cause != bans.CauseAttack:
entry.Cause != bans.CauseAttack && entry.Cause != bans.CauseAdmin:
return fmt.Errorf("entry %d's cause %q %w", i+1, entry.Cause, errCause)
}
}
+170 -8
View File
@@ -48,6 +48,8 @@ const permanentBansJSON = `{
"netblock": "2001:db8::/64",
"start": "2026-10-06T00:00:00Z",
"expires": null,
"cause": "admin",
"reason": "scrapes every commit",
"notes": {
"country": "DE",
"limit": 1000,
@@ -66,7 +68,7 @@ const permanentBansJSON = `{
"earlier_bans": {
"limit": 3,
"attack": 1,
"without_cause": 1
"admin": 1
}
}
}
@@ -74,6 +76,15 @@ const permanentBansJSON = `{
}
`
// liftedClient is the client whose ban liftedBansJSON holds.
const liftedClient = "203.0.113.9"
// liftedBansJSON is bans.json holding an hour's ban for a broken limit on
// liftedClient, from midnight, that an admin lifted ten minutes in.
const liftedBansJSON = `{"version": 1, "bans": [{"netblock": "203.0.113.9/32", ` +
`"start": "2026-10-06T00:00:00Z", "expires": "2026-10-06T01:00:00Z", ` +
`"cause": "limit", "lifted": "2026-10-06T00:10:00Z"}]}`
func TestFilesWrittenAndReadBack(t *testing.T) {
t.Parallel()
@@ -267,15 +278,17 @@ func TestEntryWithoutAFieldItNeedsStopsTheStart(t *testing.T) {
}
}
func TestBanWithACauseSmallwebwafDoesNotGiveStopsTheStart(t *testing.T) {
func TestBanWithAnotherCauseStopsTheStart(t *testing.T) {
t.Parallel()
wantRefused(t, bansJSON, `{"version": 1, "bans": [`+
`{"netblock": "203.0.113.9/32", "start": "2026-10-06T00:00:00Z", `+
`"expires": null, "cause": "attack"}, `+
`{"netblock": "203.0.113.10/32", "start": "2026-10-06T00:00:00Z", `+
`"expires": null, "cause": "admin"}, `+
`{"netblock": "203.0.113.11/32", "start": "2026-10-06T00:00:00Z", `+
`"expires": null, "cause": "atack"}]}`,
`: entry 2's cause "atack" is not limit or attack`)
`: entry 3's cause "atack" is not limit, attack or admin`)
}
func TestUnknownVersionStopsTheStart(t *testing.T) {
@@ -611,7 +624,7 @@ func TestEditOfEachFileTakenIn(t *testing.T) {
`"start": "2026-10-06T00:00:00Z", "expires": null}]}`)
wantTakenIn(t, lines, dir, bansJSON)
wantEqual(t, bansJSON, params.Ledger.Snapshot(),
[]bans.Ban{{Netblock: client, Start: midnight()}})
[]bans.Ban{{Netblock: client, Start: midnight(), Cause: bans.CauseAdmin}})
edit(t, dir, clientsJSON, `{"version": 1, "clients": [`+
`{"client": "198.51.100.7/32", "history": {"requests": 7}}]}`)
@@ -710,6 +723,100 @@ func TestBanAddedAndLiftedThroughBansJSON(t *testing.T) {
}
}
func TestBanWithoutACauseTakenInAsAnAdmins(t *testing.T) {
t.Parallel()
const reason = "probes for logins"
// adminsBansJSON is bans.json as an admin writes it, with a ban on
// netblock without a cause, and adminsBans the bans it holds.
adminsBansJSON := func(netblock string) string {
return `{"version": 1, "bans": [{"netblock": "` + netblock + `", ` +
`"start": "2026-10-06T00:00:00Z", "expires": null, "reason": "` +
reason + `"}]}`
}
adminsBans := func(netblock string) []bans.Ban {
return []bans.Ban{{
Netblock: netip.MustParsePrefix(netblock),
Start: midnight(),
Cause: bans.CauseAdmin,
Reason: reason,
}}
}
// Read at the start, the ban is taken in as an admin's, though not
// counted among the bans made since the start, and written back with
// that cause and the admin's reason.
dir := t.TempDir()
edit(t, dir, bansJSON, adminsBansJSON("203.0.113.0/24"))
params := newParams(dir)
lines := logInto(&params)
files := load(t, params)
wantEqual(t, bansJSON, params.Ledger.Snapshot(), adminsBans("203.0.113.0/24"))
wantMadeByAnAdmin(t, params.Ledger, 0)
err := files.WriteAll()
if err != nil {
t.Fatalf("write: %v", err)
}
var written struct {
Bans []struct {
Cause string `json:"cause"`
Reason string `json:"reason"`
} `json:"bans"`
}
err = json.Unmarshal([]byte(readFile(t, filepath.Join(dir, bansJSON))), &written)
if err != nil || len(written.Bans) != 1 || written.Bans[0].Cause != bans.CauseAdmin ||
written.Bans[0].Reason != reason {
t.Errorf("bans.json holds %+v (%v), want the ban with the cause admin "+
"and the reason %q", written, err, reason)
}
// Taken in while smallwebwaf runs, a ban on another netblock is an
// admin's too, and one made since the start.
watch(t, files, lines)
edit(t, dir, bansJSON, adminsBansJSON("198.51.100.0/24"))
wantTakenIn(t, lines, dir, bansJSON)
wantEqual(t, bansJSON, params.Ledger.Snapshot(), adminsBans("198.51.100.0/24"))
wantMadeByAnAdmin(t, params.Ledger, 1)
}
func TestLiftedBanReadAtTheStart(t *testing.T) {
t.Parallel()
dir := t.TempDir()
edit(t, dir, bansJSON, liftedBansJSON)
params := newParams(dir)
wantLiftedBanKept(t, load(t, params), dir, params.Ledger)
}
func TestBanLiftedByAnEditWhileRunning(t *testing.T) {
t.Parallel()
dir := t.TempDir()
params := newParams(dir)
lines := logInto(&params)
files := load(t, params)
watch(t, files, lines)
// The ban that liftedBansJSON lifts, before it is lifted.
netblock := netip.MustParsePrefix(liftedClient + "/32")
params.Ledger.BanForLimit(netblock, midnight(), bans.Notes{})
_, banned := params.Ledger.Find(netblock.Addr(), afterLifting())
if !banned {
t.Fatal("the ban does not refuse before it is lifted")
}
edit(t, dir, bansJSON, liftedBansJSON)
wantTakenIn(t, lines, dir, bansJSON)
wantLiftedBanKept(t, files, dir, params.Ledger)
}
func TestBrokenEditSetAsideAtTheNextWrite(t *testing.T) {
t.Parallel()
@@ -909,9 +1016,9 @@ func newParams(dir string) state.Params {
}
}
// fill puts a permanent ban without a cause, as an admin adds one, a ban
// for a broken limit and one for a clear sign of attack, clients with
// counts and histories, and GeoJS answers into the parts of params.
// fill puts a permanent ban an admin made, a ban for a broken limit and
// one for a clear sign of attack, clients with counts and histories, and
// GeoJS answers into the parts of params.
func fill(params state.Params) {
now := midnight()
client := netip.MustParsePrefix("203.0.113.9/32")
@@ -943,6 +1050,8 @@ func permanentBan() bans.Ban {
return bans.Ban{
Netblock: netip.MustParsePrefix("2001:db8::/64"),
Start: midnight(),
Cause: bans.CauseAdmin,
Reason: "scrapes every commit",
Notes: bans.Notes{
Country: "DE",
Limit: 1000,
@@ -958,11 +1067,64 @@ func permanentBan() bans.Ban {
},
Requests: 1500,
Refused: 3,
EarlierBans: bans.EarlierBans{Limit: 3, Attack: 1, WithoutCause: 1},
EarlierBans: bans.EarlierBans{Limit: 3, Attack: 1, Admin: 1},
},
}
}
// afterLifting is a time after the ban liftedBansJSON holds was lifted,
// while it would still last.
func afterLifting() time.Time {
return midnight().Add(30 * time.Minute)
}
// wantLiftedBanKept checks that ledger holds the ban liftedBansJSON holds,
// which refuses nothing and does not make the next ban for a broken limit
// longer, and that files write it to bans.json, in dir, still lifted.
func wantLiftedBanKept(
t *testing.T, files *state.Files, dir string, ledger *bans.Ledger,
) {
t.Helper()
err := files.WriteAll()
if err != nil {
t.Fatalf("write: %v", err)
}
const lifted = `"lifted": "2026-10-06T00:10:00Z"`
if got := readFile(t, filepath.Join(dir, bansJSON)); !strings.Contains(got, lifted) {
t.Errorf("bans.json holds\n%s\nwant the ban with %s", got, lifted)
}
netblock := netip.MustParsePrefix(liftedClient + "/32")
_, banned := ledger.Check(netblock.Addr(), afterLifting())
if banned {
t.Error("the lifted ban refuses")
}
// Were the lifted ban counted, the next would last three hours.
ban := ledger.BanForLimit(netblock, afterLifting(), bans.Notes{})
if ban.Expires.Sub(ban.Start) != time.Hour {
t.Errorf("the next ban lasts %s, want 1h", ban.Expires.Sub(ban.Start))
}
held := ledger.Bans(netblock)
if len(held) != 2 || !held[0].Lifted.Equal(midnight().Add(10*time.Minute)) {
t.Errorf("the ledger holds %+v, want the lifted ban and the new one", held)
}
}
// wantMadeByAnAdmin checks how many bans ledger counts as made by an
// admin since the start.
func wantMadeByAnAdmin(t *testing.T, ledger *bans.Ledger, want int) {
t.Helper()
if got := ledger.Made(bans.CauseAdmin); got != want {
t.Errorf("%d bans made by an admin, want %d", got, want)
}
}
// load reads the state files into the parts of params.
func load(t *testing.T, params state.Params) *state.Files {
t.Helper()