Files
smallwebwaf/internal/bans/bans.go
T
clawbot 2c01d7f98f
check / check (push) Waiting to run
Rule files, and bans for a clear sign of attack (closes #24)
Every *.rules file in SWWAF_RULES_DIR is read at start and on each
change. 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 attack. path, query and uri are matched as the request line
sent them; header:Host and header:Transfer-Encoding are refused. Bans
gain a 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
2026-10-06 17:04:23 +00:00

538 lines
16 KiB
Go

// 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.
package bans
import (
"net/netip"
"slices"
"strings"
"sync"
"time"
"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.
const (
// CauseLimit is a ban for a broken limit.
CauseLimit = "limit"
// CauseAttack is a ban for a clear sign of attack.
CauseAttack = "attack"
)
// repeatFactor is how many times as long as the netblock's last ban a ban
// for a limit broken again within the repeat window lasts.
const repeatFactor = 3
// maxTextBytes is how much of each text in a ban's notes is kept.
const maxTextBytes = 256
// Rules are how long a ban lasts, and how many bans are held.
type Rules struct {
// LimitBanDuration is how long a first ban for a broken limit lasts.
LimitBanDuration time.Duration
// LimitBanRepeatWindow is how soon after the end of the netblock's
// ban that ended last, other than one for a clear sign of attack, a
// broken limit counts as a repeat, which bans for repeatFactor times as
// long as that ban.
LimitBanRepeatWindow time.Duration
// MaxBanDuration is the longest ban for a broken limit; one that would
// be longer is permanent instead.
MaxBanDuration time.Duration
// 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 int
}
// Ban is a ban on a netblock.
type Ban struct {
Netblock netip.Prefix
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 string
Notes Notes
}
// Permanent reports whether the ban never runs out.
func (b Ban) Permanent() bool {
return b.Expires.IsZero()
}
// ActiveAt reports whether the ban refuses requests at now.
func (b Ban) ActiveAt(now time.Time) bool {
return b.Permanent() || now.Before(b.Expires)
}
// Notes are what an admin needs to decide whether to lift a ban. The
// JSON names are those of bans.json.
//
//nolint:tagliatelle // the state files use snake_case, as the request log does
type Notes struct {
// Country is the client's country, when it was looked up.
Country string `json:"country"`
// Limit, Window and Count are, for a ban for a broken limit, the limit
// that was broken, its window, "minute", "hour" or "day", and the
// count reached: the client's requests in the window, the one that
// broke the limit included. These are the requests that counted
// toward the ban, and the window is the time over which they came.
Limit int64 `json:"limit,omitempty"`
Window string `json:"window,omitempty"`
Count float64 `json:"count,omitempty"`
// RuleID and Target are, for a ban for a clear sign of attack, the id
// of the rule file rule that matched, and its target.
RuleID string `json:"rule_id,omitempty"`
Target string `json:"target,omitempty"`
// Request is the request that broke the limit, or that was the clear
// sign of attack.
Request Request `json:"request"`
// Requests is how many requests the netblock has sent since it was
// first seen, and Refused how many of them the ban has refused so
// far. Both go up with each request the ban refuses.
Requests int64 `json:"requests"`
Refused int64 `json:"refused"`
// EarlierBans is how many bans the netblock had before this one, by
// cause.
EarlierBans EarlierBans `json:"earlier_bans"`
}
// 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"`
}
// Request is a request in a ban's notes. Each text is cut to 256 bytes.
//
//nolint:tagliatelle // the state files use snake_case, as the request log does
type Request struct {
Time time.Time `json:"time"`
Method string `json:"method"`
Host string `json:"host"`
// Path is the path with its query string.
Path string `json:"path"`
// Status is what the client was sent, 0 if nothing was.
Status int `json:"status"`
UserAgent string `json:"user_agent"`
}
// Ledger holds the bans. It is safe for concurrent use.
type Ledger struct {
rules Rules
// changed receives a value when a ban is made, unless one is waiting
// already.
changed chan struct{}
mu sync.Mutex
// 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 int
// made is how many bans the ledger has made since the start, by cause.
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
// them, so that a ban read from bans.json refuses every client in its
// netblock even when it was made with another SWWAF_BAN_SCOPE_V4_PREFIX,
// or another length of an IPv6 client's netblock.
v4Lengths, v6Lengths []int
}
// 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)
if err != nil {
panic(err) // NewLRU fails only for a size below one
}
return &Ledger{
rules: rules,
changed: make(chan struct{}, 1),
netblocks: netblocks,
made: map[string]int{},
}
}
// Changed receives a value after a ban is made or made permanent, so that
// bans.json can be written. Several changes before it is read leave one
// value.
func (l *Ledger) Changed() <-chan struct{} {
return l.changed
}
// Check is called for a request from client, at now. It reports whether
// a ban on a netblock client is in is active, and returns that ban, with
// the request counted among those it refused. A ban for a clear sign of
// attack is made permanent by the request: the netblock is malicious.
func (l *Ledger) Check(client netip.Addr, now time.Time) (Ban, bool) {
l.mu.Lock()
defer l.mu.Unlock()
ban := l.active(client, now)
if ban == nil {
return Ban{}, false
}
ban.Notes.Requests++
ban.Notes.Refused++
if ban.Cause == CauseAttack && !ban.Permanent() {
ban.Expires = time.Time{}
l.markChanged()
}
return *ban, true
}
// Find is Check without counting the request among those the ban
// refused: in observe mode a ban refuses nothing.
func (l *Ledger) Find(client netip.Addr, now time.Time) (Ban, bool) {
l.mu.Lock()
defer l.mu.Unlock()
ban := l.active(client, now)
if ban == nil {
return Ban{}, false
}
return *ban, true
}
// activeBan returns the ban in bans, a netblock's bans oldest first, that
// is active at now, or nil when none is. If several are, it returns the
// one that started last. Every ban is looked at, since a ban an admin adds
// to bans.json can start before the netblock's others and outlast them.
func activeBan(bans []Ban, now time.Time) *Ban {
for i := len(bans) - 1; i >= 0; i-- {
if bans[i].ActiveAt(now) {
return &bans[i]
}
}
return nil
}
// 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.
func (l *Ledger) BanForLimit(netblock netip.Prefix, now time.Time, notes Notes) Ban {
return l.ban(netblock, now, CauseLimit, 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.
func (l *Ledger) BanForAttack(netblock netip.Prefix, now time.Time, notes Notes) Ban {
return l.ban(netblock, now, CauseAttack, notes)
}
// Bans returns the bans held on netblock, oldest first. It is not a
// request from netblock, and leaves when it was last seen unchanged.
func (l *Ledger) Bans(netblock netip.Prefix) []Ban {
l.mu.Lock()
defer l.mu.Unlock()
bans, found := l.netblocks.Peek(netblock)
if !found {
return nil
}
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.
func (l *Ledger) Made(cause string) int {
l.mu.Lock()
defer l.mu.Unlock()
return l.made[cause]
}
// Count returns how many of the bans held are active at now, and how many
// are permanent.
func (l *Ledger) Count(now time.Time) (int, int) {
l.mu.Lock()
defer l.mu.Unlock()
active, permanent := 0, 0
for _, bans := range l.netblocks.Values() {
for _, ban := range *bans {
if ban.ActiveAt(now) {
active++
}
if ban.Permanent() {
permanent++
}
}
}
return active, permanent
}
// Snapshot returns every ban held, sorted by netblock, and each
// netblock's bans oldest first, as bans.json lists them.
func (l *Ledger) Snapshot() []Ban {
l.mu.Lock()
defer l.mu.Unlock()
held := make([]Ban, 0, l.held)
for _, bans := range l.netblocks.Values() {
held = append(held, *bans...)
}
slices.SortStableFunc(held, func(a, b Ban) int {
return a.Netblock.Compare(b.Netblock)
})
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.
func (l *Ledger) Load(bans []Ban) {
bans = slices.Clone(bans)
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)
}
}
// ban bans netblock at now for cause, with notes, as BanForLimit and
// BanForAttack describe, and returns the ban.
func (l *Ledger) ban(
netblock netip.Prefix, now time.Time, cause string, notes Notes,
) Ban {
l.mu.Lock()
defer l.mu.Unlock()
// held are the netblock's bans, none of them active.
var held []Ban
bans, found := l.netblocks.Get(netblock)
if found {
active := activeBan(*bans, now)
if active != nil {
return *active
}
held = *bans
notes.EarlierBans = earlierBans(held)
}
notes.Request = notes.Request.cut()
ban := Ban{Netblock: netblock, Start: now, Cause: cause, Notes: notes}
if cause == CauseAttack {
ban.Expires = l.attackExpiry(held, now)
} else {
ban.Expires = l.limitExpiry(held, now)
}
l.add(ban)
l.made[cause]++
l.markChanged()
return ban
}
// earlierBans returns how many bans a netblock with the bans held, oldest
// first, has had, by cause: the first ban held counts the bans the
// netblock had before that one, since dropped to make room, and each ban
// held adds one.
func earlierBans(held []Ban) EarlierBans {
earlier := held[0].Notes.EarlierBans
for _, ban := range held {
switch ban.Cause {
case CauseLimit:
earlier.Limit++
case CauseAttack:
earlier.Attack++
default:
earlier.WithoutCause++
}
}
return earlier
}
// markChanged has Changed receive a value, unless one is waiting already.
func (l *Ledger) markChanged() {
select {
case l.changed <- struct{}{}:
default: // a value is waiting already
}
}
// active returns the ban active at now on a netblock client is in, or
// nil.
func (l *Ledger) active(client netip.Addr, now time.Time) *Ban {
lengths := l.v6Lengths
if client.Is4() {
lengths = l.v4Lengths
}
for _, length := range lengths {
bans, found := l.netblocks.Get(netip.PrefixFrom(client, length).Masked())
if !found {
continue
}
ban := activeBan(*bans, now)
if ban != nil {
return ban
}
}
return nil
}
// 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.
func (l *Ledger) add(ban Ban) {
if l.held == l.rules.MaxBans {
l.dropOne()
}
// dropOne can have dropped the netblock's last ban, and the netblock
// with it.
bans, found := l.netblocks.Get(ban.Netblock)
if !found {
bans = &[]Ban{}
l.netblocks.Add(ban.Netblock, bans)
}
*bans = append(*bans, ban)
l.held++
lengths := &l.v6Lengths
if ban.Netblock.Addr().Is4() {
lengths = &l.v4Lengths
}
if !slices.Contains(*lengths, ban.Netblock.Bits()) {
*lengths = append(*lengths, ban.Netblock.Bits())
}
}
// 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.
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)) {
last = &held[i]
}
}
if last != nil && now.Sub(last.Expires) <= l.rules.LimitBanRepeatWindow {
lastLength := last.Expires.Sub(last.Start)
// This is repeatFactor * lastLength > MaxBanDuration, written so
// that it cannot overflow.
if lastLength > l.rules.MaxBanDuration/repeatFactor {
return time.Time{}
}
length = repeatFactor * lastLength
}
if length > l.rules.MaxBanDuration {
return time.Time{}
}
return now.Add(length)
}
// 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.
func (l *Ledger) attackExpiry(held []Ban, now time.Time) time.Time {
for _, ban := range held {
if ban.Cause == CauseAttack {
return 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.
func (l *Ledger) dropOne() {
netblock, bans, _ := l.netblocks.GetOldest()
if len(*bans) == 1 {
l.netblocks.Remove(netblock)
} else {
*bans = slices.Delete(*bans, 0, 1)
}
l.held--
}
// cut returns r with each text cut to maxTextBytes and copied, so that
// the notes do not keep the rest of the request in memory.
func (r Request) cut() Request {
r.Method = cutText(r.Method)
r.Host = cutText(r.Host)
r.Path = cutText(r.Path)
r.UserAgent = cutText(r.UserAgent)
return r
}
// cutText returns a copy of the first maxTextBytes of text.
func cutText(text string) string {
return strings.Clone(text[:min(len(text), maxTextBytes)])
}