// Package bans is the ban ledger: the bans smallwebwaf makes on the // netblocks of clients that break a rate limit or a byte limit or show a // clear sign of 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" "sync" "time" "github.com/hashicorp/golang-lru/v2/simplelru" ) // The causes of bans. const ( // CauseLimit is a ban smallwebwaf made for a broken limit. CauseLimit = "limit" // 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 // 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 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 } // 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, CauseAttack or CauseAdmin. Cause string // 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. func (b Ban) Permanent() bool { return b.Expires.IsZero() } // 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.Lifted.IsZero() && (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 { // ASN, ASName and Country are the client's AS number, AS name and // country, when they were looked up: when the request that caused the // ban was made, or when GeoJS answered about the client afterwards. ASN string `json:"asn"` ASName string `json:"as_name"` Country string `json:"country"` // Kind, Limit, Window and Count are, for a ban for a broken limit, // what the limit was on, "requests" for a rate limit or "bytes" for a // byte limit, the limit that was broken, its window, "minute", "hour" // or "day", and the count reached: the client's requests, or bytes, in // the window, those of the request that broke the limit included. // These are what counted toward the ban, and the window is the time // over which they came. Kind string `json:"kind,omitempty"` 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 whose bytes broke // it, 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. type EarlierBans struct { Limit int `json:"limit"` Attack int `json:"attack"` Admin int `json:"admin"` } // 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 whose cause is not CauseAdmin, // at most rules.MaxBans. held int // made is how many bans have been made since the start, by cause: by // the ledger, and by an admin, through BanForAdmin or 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 // 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 { // 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 } return &Ledger{ rules: rules, changed: make(chan struct{}, 1), netblocks: netblocks, made: map[string]int{}, } } // Changed receives a value after a ban is made, lifted 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. // The last result reports whether the request made the ban permanent. func (l *Ledger) Check(client netip.Addr, now time.Time) (Ban, bool, bool) { l.mu.Lock() defer l.mu.Unlock() ban := l.active(client, now) if ban == nil { return Ban{}, false, false } ban.Notes.Requests++ ban.Notes.Refused++ madePermanent := ban.Cause == CauseAttack && !ban.Permanent() if madePermanent { ban.Expires = time.Time{} l.markChanged() } return *ban, true, madePermanent } // Find is Check without counting the request among those the ban // refused, and without making the ban permanent: in observe mode a ban // refuses nothing. The last result reports whether Check would have made // the ban permanent. func (l *Ledger) Find(client netip.Addr, now time.Time) (Ban, bool, bool) { l.mu.Lock() defer l.mu.Unlock() ban := l.active(client, now) if ban == nil { return Ban{}, false, false } return *ban, true, ban.Cause == CauseAttack && !ban.Permanent() } // 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, and true. 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 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 with false, and no other is made. The ledger fills in the // notes' Refused and EarlierBans itself, and gives the ban the reason // " per over the limit of ", from the notes, such // as "requests per minute over the limit of 1000". func (l *Ledger) BanForLimit( netblock netip.Prefix, now time.Time, notes Notes, ) (Ban, bool) { return l.ban(netblock, now, CauseLimit, limitReason(notes), notes, true) } // WouldBanForLimit returns what BanForLimit would, without making the ban: // what observe mode would have done. func (l *Ledger) WouldBanForLimit( netblock netip.Prefix, now time.Time, notes Notes, ) (Ban, bool) { return l.ban(netblock, now, CauseLimit, limitReason(notes), notes, false) } // BanForAttack bans netblock at now for a clear sign of attack, with // notes, and returns the ban, and whether it made it, as BanForLimit // does. A first ban lasts AttackBanDuration; once the netblock has had // one that was not lifted, the next is permanent. Its reason is "matched // the rule ". func (l *Ledger) BanForAttack( netblock netip.Prefix, now time.Time, notes Notes, ) (Ban, bool) { return l.ban(netblock, now, CauseAttack, attackReason(notes), notes, true) } // WouldBanForAttack returns what BanForAttack would, without making the // ban: what observe mode would have done. func (l *Ledger) WouldBanForAttack( netblock netip.Prefix, now time.Time, notes Notes, ) (Ban, bool) { return l.ban(netblock, now, CauseAttack, attackReason(notes), notes, false) } // WouldBePermanent reports whether a ban on netblock for cause, CauseLimit // or CauseAttack, made at now would be permanent, as BanForLimit or // BanForAttack would make it. It works out nothing else of the ban. func (l *Ledger) WouldBePermanent( netblock netip.Prefix, now time.Time, cause string, ) bool { l.mu.Lock() defer l.mu.Unlock() var held []Ban if bans, found := l.netblocks.Peek(netblock); found { held = *bans } if cause == CauseAttack { return l.attackExpiry(held, now).IsZero() } return l.limitExpiry(held, now).IsZero() } // limitReason is the reason of a ban for a broken limit, with notes. func limitReason(notes Notes) string { return fmt.Sprintf("%s per %s over the limit of %d", notes.Kind, notes.Window, notes.Limit) } // attackReason is the reason of a ban for a clear sign of attack, with // notes. func attackReason(notes Notes) string { return "matched the rule " + notes.RuleID } // BanForAdmin bans netblock at now for an admin, with reason, until // expires, or for good when expires is zero, and returns the ban, whose // cause is CauseAdmin. Unlike BanForLimit and BanForAttack, it makes the // ban even while another on netblock is active, since the admin asked // for this one. The ledger fills in the notes' EarlierBans, and counts // the ban among those made. func (l *Ledger) BanForAdmin( netblock netip.Prefix, now, expires time.Time, reason string, ) Ban { l.mu.Lock() defer l.mu.Unlock() ban := Ban{ Netblock: netblock.Masked(), Start: now, Expires: expires, Cause: CauseAdmin, Reason: reason, } held, found := l.netblocks.Get(ban.Netblock) if found { ban.Notes.EarlierBans = earlierBans(*held) } l.add(ban) l.made[CauseAdmin]++ l.markChanged() return ban } // Lift lifts, at now, every ban active then on a netblock client is in, // as an admin does, and returns those bans. A lifted ban is kept, refuses // nothing, and does not make the netblock's next ban longer. func (l *Ledger) Lift(client netip.Addr, now time.Time) []Ban { l.mu.Lock() defer l.mu.Unlock() var lifted []Ban for _, bans := range l.covering(client) { for i := range *bans { ban := &(*bans)[i] if ban.ActiveAt(now) { ban.Lifted = now lifted = append(lifted, *ban) } } } if len(lifted) > 0 { l.markChanged() } return lifted } // Covering returns every ban held on a netblock client is in, active or // not, sorted by netblock, and each netblock's bans oldest first. It is // not a request from client, and leaves when the netblocks were last seen // unchanged. func (l *Ledger) Covering(client netip.Addr) []Ban { l.mu.Lock() defer l.mu.Unlock() var held []Ban for _, bans := range l.covering(client) { held = append(held, *bans...) } slices.SortStableFunc(held, func(a, b Ban) int { return a.Netblock.Compare(b.Netblock) }) return held } // 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) } // AddLookup gives the notes of netblock's bans that have no AS number, AS // name or country yet those of a client in it, as the lookup answered // about it. It is not a request from netblock, and leaves when it was last // seen unchanged. It does not have bans.json written at once: the notes // are written with its next write, as the counts in them are. func (l *Ledger) AddLookup(netblock netip.Prefix, asn, asName, country string) { l.mu.Lock() defer l.mu.Unlock() bans, found := l.netblocks.Peek(netblock) if !found { return } for i := range *bans { notes := &(*bans)[i].Notes if notes.ASN == "" && notes.ASName == "" && notes.Country == "" { notes.ASN, notes.ASName, notes.Country = asn, asName, country } } } // 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, with BanForAdmin or 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() return l.made[cause] } // Count returns how many of the bans held are active at now, and how many // 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() active, permanent := 0, 0 for _, bans := range l.netblocks.Values() { for _, ban := range *bans { if !ban.ActiveAt(now) { continue } 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 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.netblocks.Purge() l.held = 0 l.v4Lengths, l.v6Lengths = nil, nil for _, ban := range bans { l.add(ban) } return added } // 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, and whether // it made it. Unless keep is true, the ban is not made, only returned: it // is the ban that would have been made. func (l *Ledger) ban( netblock netip.Prefix, now time.Time, cause, reason string, notes Notes, keep bool, ) (Ban, bool) { 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, false } held = *bans notes.EarlierBans = earlierBans(held) } notes.Request = notes.Request.cut() ban := Ban{Netblock: netblock, Start: now, Cause: cause, Reason: reason, Notes: notes} if cause == CauseAttack { ban.Expires = l.attackExpiry(held, now) } else { ban.Expires = l.limitExpiry(held, now) } if !keep { return ban, true } l.add(ban) l.made[cause]++ l.markChanged() return ban, true } // 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++ case CauseAdmin: earlier.Admin++ } } 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 } // covering returns the bans of each netblock held that client is in, // leaving when the netblocks were last seen unchanged. func (l *Ledger) covering(client netip.Addr) []*[]Ban { lengths := l.v6Lengths if client.Is4() { lengths = l.v4Lengths } var found []*[]Ban for _, length := range lengths { bans, ok := l.netblocks.Peek(netip.PrefixFrom(client, length).Masked()) if ok { found = append(found, bans) } } return found } // 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, // unless ban's cause is CauseAdmin, which does not count toward MaxBans. func (l *Ledger) add(ban Ban) { counted := ban.Cause != CauseAdmin if counted && 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) if counted { 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 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 && ban.Lifted.IsZero() && (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, 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 && ban.Lifted.IsZero() { return time.Time{} } } return now.Add(l.rules.AttackBanDuration) } // 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() { for range l.netblocks.Len() { netblock, bans, _ := l.netblocks.GetOldest() 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 // 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)]) }