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:
+168
-74
@@ -1,11 +1,13 @@
|
||||
// Package bans is the ban ledger: the bans smallwebwaf makes on the
|
||||
// netblocks of clients that break a rate limit or show a clear sign of
|
||||
// attack, with their notes, as the "Bans" section of SPEC.md describes.
|
||||
// The bans are kept in memory, and written to bans.json and read from it
|
||||
// by the state package.
|
||||
// attack, and those an admin makes, with their notes, as the "Bans"
|
||||
// section of SPEC.md describes. The bans are kept in memory, and written
|
||||
// to bans.json and read from it by the state package.
|
||||
package bans
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"net/netip"
|
||||
"slices"
|
||||
"strings"
|
||||
@@ -15,13 +17,15 @@ import (
|
||||
"github.com/hashicorp/golang-lru/v2/simplelru"
|
||||
)
|
||||
|
||||
// The causes of the bans smallwebwaf makes. A ban an admin adds to
|
||||
// bans.json may have no cause.
|
||||
// The causes of bans.
|
||||
const (
|
||||
// CauseLimit is a ban for a broken limit.
|
||||
// CauseLimit is a ban smallwebwaf made for a broken limit.
|
||||
CauseLimit = "limit"
|
||||
// CauseAttack is a ban for a clear sign of attack.
|
||||
// CauseAttack is a ban smallwebwaf made for a clear sign of attack.
|
||||
CauseAttack = "attack"
|
||||
// CauseAdmin is a ban an admin made, or one smallwebwaf made that an
|
||||
// admin keeps. It is never dropped.
|
||||
CauseAdmin = "admin"
|
||||
)
|
||||
|
||||
// repeatFactor is how many times as long as the netblock's last ban a ban
|
||||
@@ -46,9 +50,10 @@ type Rules struct {
|
||||
// AttackBanDuration is how long a first ban for a clear sign of attack
|
||||
// lasts.
|
||||
AttackBanDuration time.Duration
|
||||
// MaxBans is the most bans held, at least one. Past it, the earliest
|
||||
// ban of the netblock that has gone longest without a request is
|
||||
// dropped.
|
||||
// MaxBans is the most bans held whose cause is not CauseAdmin, at
|
||||
// least one. Past it, the earliest such ban of the netblock that has
|
||||
// gone longest without a request is dropped. Bans whose cause is
|
||||
// CauseAdmin are held besides, and never dropped.
|
||||
MaxBans int
|
||||
}
|
||||
|
||||
@@ -58,10 +63,16 @@ type Ban struct {
|
||||
Start time.Time
|
||||
// Expires is when the ban ends, zero for a permanent ban.
|
||||
Expires time.Time
|
||||
// Cause is CauseLimit or CauseAttack, or "" for a ban an admin added
|
||||
// without one.
|
||||
// Cause is CauseLimit, CauseAttack or CauseAdmin.
|
||||
Cause string
|
||||
Notes Notes
|
||||
// Reason is a short text: for a ban smallwebwaf made, the limit broken
|
||||
// or the rule that matched; for an admin's, what the admin wrote.
|
||||
Reason string
|
||||
// Lifted is when an admin lifted the ban, zero while no admin has. A
|
||||
// lifted ban refuses nothing, and does not make the netblock's next
|
||||
// ban longer.
|
||||
Lifted time.Time
|
||||
Notes Notes
|
||||
}
|
||||
|
||||
// Permanent reports whether the ban never runs out.
|
||||
@@ -69,9 +80,10 @@ func (b Ban) Permanent() bool {
|
||||
return b.Expires.IsZero()
|
||||
}
|
||||
|
||||
// ActiveAt reports whether the ban refuses requests at now.
|
||||
// ActiveAt reports whether the ban refuses requests at now: it has not
|
||||
// been lifted, and has not run out.
|
||||
func (b Ban) ActiveAt(now time.Time) bool {
|
||||
return b.Permanent() || now.Before(b.Expires)
|
||||
return b.Lifted.IsZero() && (b.Permanent() || now.Before(b.Expires))
|
||||
}
|
||||
|
||||
// Notes are what an admin needs to decide whether to lift a ban. The
|
||||
@@ -107,13 +119,10 @@ type Notes struct {
|
||||
}
|
||||
|
||||
// EarlierBans counts a netblock's bans before a ban, by cause.
|
||||
//
|
||||
//nolint:tagliatelle // the state files use snake_case, as the request log does
|
||||
type EarlierBans struct {
|
||||
Limit int `json:"limit"`
|
||||
Attack int `json:"attack"`
|
||||
// WithoutCause counts the bans an admin added without a cause.
|
||||
WithoutCause int `json:"without_cause"`
|
||||
Admin int `json:"admin"`
|
||||
}
|
||||
|
||||
// Request is a request in a ban's notes. Each text is cut to 256 bytes.
|
||||
@@ -141,9 +150,11 @@ type Ledger struct {
|
||||
// netblocks holds each banned netblock's bans, oldest first. Check and
|
||||
// Find make each netblock they find the most recently seen.
|
||||
netblocks *simplelru.LRU[netip.Prefix, *[]Ban]
|
||||
// held is how many bans netblocks holds, at most rules.MaxBans.
|
||||
// held is how many bans netblocks holds whose cause is not CauseAdmin,
|
||||
// at most rules.MaxBans.
|
||||
held int
|
||||
// made is how many bans the ledger has made since the start, by cause.
|
||||
// made is how many bans have been made since the start, by cause: by
|
||||
// the ledger, and by an admin in an edit of bans.json.
|
||||
made map[string]int
|
||||
// v4Lengths and v6Lengths are the lengths of the IPv4 and IPv6
|
||||
// netblocks that have been banned. Check looks for a ban at each of
|
||||
@@ -155,9 +166,10 @@ type Ledger struct {
|
||||
|
||||
// New returns a Ledger with no ban yet.
|
||||
func New(rules Rules) *Ledger {
|
||||
// Every netblock held has a ban, so there are never more netblocks
|
||||
// than rules.MaxBans, and the LRU never drops one itself.
|
||||
netblocks, err := simplelru.NewLRU[netip.Prefix, *[]Ban](rules.MaxBans, nil)
|
||||
// The ledger drops bans itself, and never those whose cause is
|
||||
// CauseAdmin, however many there are, so the LRU has no limit of its
|
||||
// own: it keeps the netblocks in the order they were last seen.
|
||||
netblocks, err := simplelru.NewLRU[netip.Prefix, *[]Ban](math.MaxInt, nil)
|
||||
if err != nil {
|
||||
panic(err) // NewLRU fails only for a size below one
|
||||
}
|
||||
@@ -233,21 +245,26 @@ func activeBan(bans []Ban, now time.Time) *Ban {
|
||||
// BanForLimit bans netblock at now for a broken limit, with notes, and
|
||||
// returns the ban. A first ban lasts LimitBanDuration. A ban made within
|
||||
// LimitBanRepeatWindow after the netblock's ban that ended last, other
|
||||
// than one for a clear sign of attack, lasts repeatFactor times as long as
|
||||
// that one. A ban that would be longer than MaxBanDuration is permanent
|
||||
// instead. If a ban on netblock is still active, as when two of its
|
||||
// requests break a limit at once, that ban is returned and no other is
|
||||
// made. The ledger fills in the notes' Refused and EarlierBans itself.
|
||||
// than one for a clear sign of attack or a lifted one, lasts repeatFactor
|
||||
// times as long as that one. A ban that would be longer than
|
||||
// MaxBanDuration is permanent instead. If a ban on netblock is still
|
||||
// active, as when two of its requests break a limit at once, that ban is
|
||||
// returned and no other is made. The ledger fills in the notes' Refused
|
||||
// and EarlierBans itself, and gives the ban the reason "requests per
|
||||
// <Window> over the limit of <Limit>", from the notes.
|
||||
func (l *Ledger) BanForLimit(netblock netip.Prefix, now time.Time, notes Notes) Ban {
|
||||
return l.ban(netblock, now, CauseLimit, notes)
|
||||
reason := fmt.Sprintf("requests per %s over the limit of %d",
|
||||
notes.Window, notes.Limit)
|
||||
|
||||
return l.ban(netblock, now, CauseLimit, reason, notes)
|
||||
}
|
||||
|
||||
// BanForAttack bans netblock at now for a clear sign of attack, with
|
||||
// notes, and returns the ban, as BanForLimit does. A first ban lasts
|
||||
// AttackBanDuration; once the netblock has had one, the next is
|
||||
// permanent.
|
||||
// AttackBanDuration; once the netblock has had one that was not lifted,
|
||||
// the next is permanent. Its reason is "matched the rule <RuleID>".
|
||||
func (l *Ledger) BanForAttack(netblock netip.Prefix, now time.Time, notes Notes) Ban {
|
||||
return l.ban(netblock, now, CauseAttack, notes)
|
||||
return l.ban(netblock, now, CauseAttack, "matched the rule "+notes.RuleID, notes)
|
||||
}
|
||||
|
||||
// Bans returns the bans held on netblock, oldest first. It is not a
|
||||
@@ -264,8 +281,10 @@ func (l *Ledger) Bans(netblock netip.Prefix) []Ban {
|
||||
return slices.Clone(*bans)
|
||||
}
|
||||
|
||||
// Made returns how many bans for cause the ledger has made since the
|
||||
// start; bans read from bans.json are not among them.
|
||||
// Made returns how many bans for cause have been made since the start:
|
||||
// for CauseLimit and CauseAttack, by the ledger; for CauseAdmin, by an
|
||||
// admin in an edit of bans.json, as LoadEdit counts them. The bans read
|
||||
// from bans.json at the start are not among them.
|
||||
func (l *Ledger) Made(cause string) int {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
@@ -274,7 +293,7 @@ func (l *Ledger) Made(cause string) int {
|
||||
}
|
||||
|
||||
// Count returns how many of the bans held are active at now, and how many
|
||||
// are permanent.
|
||||
// of those are permanent. A lifted ban is neither.
|
||||
func (l *Ledger) Count(now time.Time) (int, int) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
@@ -283,10 +302,12 @@ func (l *Ledger) Count(now time.Time) (int, int) {
|
||||
|
||||
for _, bans := range l.netblocks.Values() {
|
||||
for _, ban := range *bans {
|
||||
if ban.ActiveAt(now) {
|
||||
active++
|
||||
if !ban.ActiveAt(now) {
|
||||
continue
|
||||
}
|
||||
|
||||
active++
|
||||
|
||||
if ban.Permanent() {
|
||||
permanent++
|
||||
}
|
||||
@@ -314,36 +335,81 @@ func (l *Ledger) Snapshot() []Ban {
|
||||
return held
|
||||
}
|
||||
|
||||
// Load puts bans read from bans.json into the ledger, in place of the
|
||||
// bans it holds, in the order they started, so that a netblock whose last
|
||||
// ban started latest counts as the most recently seen. Each netblock is
|
||||
// masked to its length, so that 203.0.113.9/24 is 203.0.113.0/24, and
|
||||
// each text in the notes is cut to 256 bytes. Past MaxBans the earliest
|
||||
// bans are dropped, as when they are made.
|
||||
// Load puts bans read from bans.json at the start into the ledger, in
|
||||
// place of the bans it holds, in the order they started, so that a
|
||||
// netblock whose last ban started latest counts as the most recently
|
||||
// seen. A ban without a cause is an admin's, and gets CauseAdmin. Each
|
||||
// netblock is masked to its length, so that 203.0.113.9/24 is
|
||||
// 203.0.113.0/24, and each text in the notes is cut to 256 bytes. Past
|
||||
// MaxBans the earliest bans whose cause is not CauseAdmin are dropped, as
|
||||
// when they are made.
|
||||
func (l *Ledger) Load(bans []Ban) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
|
||||
l.load(bans)
|
||||
}
|
||||
|
||||
// LoadEdit is Load for an admin's edit of bans.json, taken in while
|
||||
// smallwebwaf runs. Each ban in it whose cause is CauseAdmin, and which
|
||||
// the ledger did not hold, with the same netblock and start, is one the
|
||||
// admin made, and is counted among the bans made.
|
||||
func (l *Ledger) LoadEdit(bans []Ban) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
|
||||
l.made[CauseAdmin] += l.load(bans)
|
||||
}
|
||||
|
||||
// load does what Load describes, and returns how many of bans are bans
|
||||
// whose cause is CauseAdmin that the ledger did not hold before.
|
||||
func (l *Ledger) load(bans []Ban) int {
|
||||
bans = slices.Clone(bans)
|
||||
added := 0
|
||||
|
||||
for i := range bans {
|
||||
ban := &bans[i]
|
||||
ban.Netblock = ban.Netblock.Masked()
|
||||
ban.Notes.Request = ban.Notes.Request.cut()
|
||||
|
||||
if ban.Cause == "" {
|
||||
ban.Cause = CauseAdmin
|
||||
}
|
||||
|
||||
if ban.Cause == CauseAdmin && !l.holds(ban.Netblock, ban.Start) {
|
||||
added++
|
||||
}
|
||||
}
|
||||
|
||||
slices.SortStableFunc(bans, func(a, b Ban) int {
|
||||
return a.Start.Compare(b.Start)
|
||||
})
|
||||
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
|
||||
l.netblocks.Purge()
|
||||
l.held = 0
|
||||
l.v4Lengths, l.v6Lengths = nil, nil
|
||||
|
||||
for _, ban := range bans {
|
||||
ban.Netblock = ban.Netblock.Masked()
|
||||
ban.Notes.Request = ban.Notes.Request.cut()
|
||||
l.add(ban)
|
||||
}
|
||||
|
||||
return added
|
||||
}
|
||||
|
||||
// ban bans netblock at now for cause, with notes, as BanForLimit and
|
||||
// BanForAttack describe, and returns the ban.
|
||||
// holds reports whether the ledger holds a ban on netblock that started
|
||||
// at start.
|
||||
func (l *Ledger) holds(netblock netip.Prefix, start time.Time) bool {
|
||||
bans, found := l.netblocks.Peek(netblock)
|
||||
|
||||
return found && slices.ContainsFunc(*bans, func(ban Ban) bool {
|
||||
return ban.Start.Equal(start)
|
||||
})
|
||||
}
|
||||
|
||||
// ban bans netblock at now for cause, with reason and notes, as
|
||||
// BanForLimit and BanForAttack describe, and returns the ban.
|
||||
func (l *Ledger) ban(
|
||||
netblock netip.Prefix, now time.Time, cause string, notes Notes,
|
||||
netblock netip.Prefix, now time.Time, cause, reason string, notes Notes,
|
||||
) Ban {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
@@ -363,7 +429,7 @@ func (l *Ledger) ban(
|
||||
}
|
||||
|
||||
notes.Request = notes.Request.cut()
|
||||
ban := Ban{Netblock: netblock, Start: now, Cause: cause, Notes: notes}
|
||||
ban := Ban{Netblock: netblock, Start: now, Cause: cause, Reason: reason, Notes: notes}
|
||||
|
||||
if cause == CauseAttack {
|
||||
ban.Expires = l.attackExpiry(held, now)
|
||||
@@ -391,8 +457,8 @@ func earlierBans(held []Ban) EarlierBans {
|
||||
earlier.Limit++
|
||||
case CauseAttack:
|
||||
earlier.Attack++
|
||||
default:
|
||||
earlier.WithoutCause++
|
||||
case CauseAdmin:
|
||||
earlier.Admin++
|
||||
}
|
||||
}
|
||||
|
||||
@@ -431,9 +497,11 @@ func (l *Ledger) active(client netip.Addr, now time.Time) *Ban {
|
||||
}
|
||||
|
||||
// add adds ban to its netblock's bans, after the last, and makes its
|
||||
// netblock the most recently seen. With MaxBans held, it drops one first.
|
||||
// netblock the most recently seen. With MaxBans held, it drops one first,
|
||||
// unless ban's cause is CauseAdmin, which does not count toward MaxBans.
|
||||
func (l *Ledger) add(ban Ban) {
|
||||
if l.held == l.rules.MaxBans {
|
||||
counted := ban.Cause != CauseAdmin
|
||||
if counted && l.held == l.rules.MaxBans {
|
||||
l.dropOne()
|
||||
}
|
||||
|
||||
@@ -446,7 +514,10 @@ func (l *Ledger) add(ban Ban) {
|
||||
}
|
||||
|
||||
*bans = append(*bans, ban)
|
||||
l.held++
|
||||
|
||||
if counted {
|
||||
l.held++
|
||||
}
|
||||
|
||||
lengths := &l.v6Lengths
|
||||
if ban.Netblock.Addr().Is4() {
|
||||
@@ -461,16 +532,17 @@ func (l *Ledger) add(ban Ban) {
|
||||
// limitExpiry returns when a ban for a broken limit made at now ends, or
|
||||
// zero when it is permanent. held are the netblock's bans, none of them
|
||||
// active, of which the one that ended last, other than a ban for a clear
|
||||
// sign of attack, can make the new ban longer. A ban an admin adds to
|
||||
// bans.json can start after another and end before it, so that one is
|
||||
// looked for among them all.
|
||||
// sign of attack or a lifted one, can make the new ban longer. A ban an
|
||||
// admin adds to bans.json can start after another and end before it, so
|
||||
// that one is looked for among them all.
|
||||
func (l *Ledger) limitExpiry(held []Ban, now time.Time) time.Time {
|
||||
length := l.rules.LimitBanDuration
|
||||
|
||||
var last *Ban
|
||||
|
||||
for i, ban := range held {
|
||||
if ban.Cause != CauseAttack && (last == nil || ban.Expires.After(last.Expires)) {
|
||||
if ban.Cause != CauseAttack && ban.Lifted.IsZero() &&
|
||||
(last == nil || ban.Expires.After(last.Expires)) {
|
||||
last = &held[i]
|
||||
}
|
||||
}
|
||||
@@ -495,11 +567,11 @@ func (l *Ledger) limitExpiry(held []Ban, now time.Time) time.Time {
|
||||
|
||||
// attackExpiry returns when a ban for a clear sign of attack made at now
|
||||
// ends. held are the netblock's bans, none of them active: if one of them
|
||||
// is for a clear sign of attack too, the new ban is permanent, and its
|
||||
// end zero; otherwise it ends AttackBanDuration later.
|
||||
// is for a clear sign of attack too, and was not lifted, the new ban is
|
||||
// permanent, and its end zero; otherwise it ends AttackBanDuration later.
|
||||
func (l *Ledger) attackExpiry(held []Ban, now time.Time) time.Time {
|
||||
for _, ban := range held {
|
||||
if ban.Cause == CauseAttack {
|
||||
if ban.Cause == CauseAttack && ban.Lifted.IsZero() {
|
||||
return time.Time{}
|
||||
}
|
||||
}
|
||||
@@ -507,17 +579,39 @@ func (l *Ledger) attackExpiry(held []Ban, now time.Time) time.Time {
|
||||
return now.Add(l.rules.AttackBanDuration)
|
||||
}
|
||||
|
||||
// dropOne drops the earliest ban of the netblock that has gone longest
|
||||
// without a request, and the netblock with it if that was its only ban.
|
||||
// dropOne drops the earliest ban whose cause is not CauseAdmin of the
|
||||
// netblock that has gone longest without a request, of those that hold
|
||||
// such a ban, and the netblock with it if that was its only ban. It is
|
||||
// called with at least one such ban held. It looks at each netblock once
|
||||
// at most, and drops nothing when none holds such a ban.
|
||||
func (l *Ledger) dropOne() {
|
||||
netblock, bans, _ := l.netblocks.GetOldest()
|
||||
if len(*bans) == 1 {
|
||||
l.netblocks.Remove(netblock)
|
||||
} else {
|
||||
*bans = slices.Delete(*bans, 0, 1)
|
||||
}
|
||||
for range l.netblocks.Len() {
|
||||
netblock, bans, _ := l.netblocks.GetOldest()
|
||||
|
||||
l.held--
|
||||
i := slices.IndexFunc(*bans, func(ban Ban) bool {
|
||||
return ban.Cause != CauseAdmin
|
||||
})
|
||||
if i < 0 {
|
||||
// Its bans are all an admin's, and never dropped. Get makes
|
||||
// it the most recently seen, so that the next netblock is
|
||||
// looked at; when it was seen matters only for dropping a
|
||||
// ban, and a ban added to it makes it the most recently seen
|
||||
// anyway.
|
||||
l.netblocks.Get(netblock)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if len(*bans) == 1 {
|
||||
l.netblocks.Remove(netblock)
|
||||
} else {
|
||||
*bans = slices.Delete(*bans, i, i+1)
|
||||
}
|
||||
|
||||
l.held--
|
||||
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// cut returns r with each text cut to maxTextBytes and copied, so that
|
||||
|
||||
Reference in New Issue
Block a user