Bans an admin makes or lifts: the admin cause, a reason, lifted bans kept (closes #86)
check / check (push) Successful in 3m27s
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:
@@ -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(¶ms)
|
||||
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(¶ms)
|
||||
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()
|
||||
|
||||
Reference in New Issue
Block a user