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:
+36
-14
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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