Rule files, and bans for a clear sign of attack (closes #24)
check / check (push) Successful in 3m28s
check / check (push) Successful in 3m28s
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
This commit is contained in:
+183
-83
@@ -1,7 +1,8 @@
|
||||
// Package bans is the ban ledger: the bans smallwebwaf makes on the
|
||||
// netblocks of clients that break a rate limit, 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.
|
||||
// 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 (
|
||||
@@ -14,6 +15,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.
|
||||
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
|
||||
@@ -21,32 +31,37 @@ 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 for a broken limit lasts, and how many bans
|
||||
// are held.
|
||||
// Rules are how long a ban lasts, and how many bans are held.
|
||||
type Rules struct {
|
||||
// LimitBanDuration is how long a first ban lasts.
|
||||
// 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 a broken limit counts as a repeat, which bans
|
||||
// for repeatFactor times as long as that ban.
|
||||
// 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; a ban that would be longer is
|
||||
// permanent instead.
|
||||
// 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 for a broken limit, the only kind of ban
|
||||
// smallwebwaf makes so far.
|
||||
// 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
|
||||
Notes Notes
|
||||
// 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.
|
||||
@@ -66,23 +81,39 @@ func (b Ban) ActiveAt(now time.Time) bool {
|
||||
type Notes struct {
|
||||
// Country is the client's country, when it was looked up.
|
||||
Country string `json:"country"`
|
||||
// Limit, Window and Count are 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"`
|
||||
Window string `json:"window"`
|
||||
Count float64 `json:"count"`
|
||||
// Request is the request that broke the limit.
|
||||
// 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.
|
||||
EarlierBans int `json:"earlier_bans"`
|
||||
// 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.
|
||||
@@ -112,8 +143,8 @@ type Ledger struct {
|
||||
netblocks *simplelru.LRU[netip.Prefix, *[]Ban]
|
||||
// held is how many bans netblocks holds, at most rules.MaxBans.
|
||||
held int
|
||||
// made is how many bans BanForLimit has made since the start.
|
||||
made 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
|
||||
@@ -135,18 +166,21 @@ func New(rules Rules) *Ledger {
|
||||
rules: rules,
|
||||
changed: make(chan struct{}, 1),
|
||||
netblocks: netblocks,
|
||||
made: map[string]int{},
|
||||
}
|
||||
}
|
||||
|
||||
// Changed receives a value after a ban is made, so that bans.json can be
|
||||
// written. Several bans made before it is read leave one value.
|
||||
// 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.
|
||||
// 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()
|
||||
@@ -159,6 +193,12 @@ func (l *Ledger) Check(client netip.Addr, now time.Time) (Ban, bool) {
|
||||
ban.Notes.Requests++
|
||||
ban.Notes.Refused++
|
||||
|
||||
if ban.Cause == CauseAttack && !ban.Permanent() {
|
||||
ban.Expires = time.Time{}
|
||||
|
||||
l.markChanged()
|
||||
}
|
||||
|
||||
return *ban, true
|
||||
}
|
||||
|
||||
@@ -192,54 +232,22 @@ 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 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.
|
||||
// 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 {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
return l.ban(netblock, now, CauseLimit, notes)
|
||||
}
|
||||
|
||||
var last *Ban
|
||||
|
||||
bans, found := l.netblocks.Get(netblock)
|
||||
if found {
|
||||
active := activeBan(*bans, now)
|
||||
if active != nil {
|
||||
return *active
|
||||
}
|
||||
|
||||
// No ban is active, so each has an end. A ban an admin adds to
|
||||
// bans.json can start after another and end before it, so the
|
||||
// ban that ended last is looked for among them all.
|
||||
ended := slices.MaxFunc(*bans, func(a, b Ban) int {
|
||||
return a.Expires.Compare(b.Expires)
|
||||
})
|
||||
last = &ended
|
||||
|
||||
// The netblock's first ban held counts the bans it had before that
|
||||
// one, since dropped to make room, and each ban held adds one.
|
||||
notes.EarlierBans = (*bans)[0].Notes.EarlierBans + len(*bans)
|
||||
}
|
||||
|
||||
notes.Request = notes.Request.cut()
|
||||
ban := Ban{
|
||||
Netblock: netblock,
|
||||
Start: now,
|
||||
Expires: l.expiry(last, now),
|
||||
Notes: notes,
|
||||
}
|
||||
l.add(ban)
|
||||
l.made++
|
||||
|
||||
select {
|
||||
case l.changed <- struct{}{}:
|
||||
default: // a value is waiting already
|
||||
}
|
||||
|
||||
return ban
|
||||
// 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
|
||||
@@ -256,13 +264,13 @@ func (l *Ledger) Bans(netblock netip.Prefix) []Ban {
|
||||
return slices.Clone(*bans)
|
||||
}
|
||||
|
||||
// Made returns how many bans the ledger has made since the start; bans
|
||||
// read from bans.json are not among them.
|
||||
func (l *Ledger) Made() int {
|
||||
// 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
|
||||
return l.made[cause]
|
||||
}
|
||||
|
||||
// Count returns how many of the bans held are active at now, and how many
|
||||
@@ -332,6 +340,73 @@ func (l *Ledger) Load(bans []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 {
|
||||
@@ -383,12 +458,23 @@ func (l *Ledger) add(ban Ban) {
|
||||
}
|
||||
}
|
||||
|
||||
// expiry returns when a ban for a broken limit made at now ends, or zero
|
||||
// when it is permanent. last is the netblock's ban that ended last, or nil
|
||||
// when it has none.
|
||||
func (l *Ledger) expiry(last *Ban, now time.Time) time.Time {
|
||||
// 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
|
||||
@@ -407,6 +493,20 @@ func (l *Ledger) expiry(last *Ban, now time.Time) 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() {
|
||||
|
||||
+103
-7
@@ -24,8 +24,9 @@ func TestRepeatsTripleUntilPermanent(t *testing.T) {
|
||||
ban := ledger.BanForLimit(netblock, now, bans.Notes{})
|
||||
|
||||
length := time.Duration(hours) * time.Hour
|
||||
if !ban.Expires.Equal(now.Add(length)) || ban.Notes.EarlierBans != i {
|
||||
t.Fatalf("ban %d lasts %s with %d earlier bans, want %d hours and %d",
|
||||
if !ban.Expires.Equal(now.Add(length)) ||
|
||||
ban.Notes.EarlierBans != (bans.EarlierBans{Limit: i}) {
|
||||
t.Fatalf("ban %d lasts %s with earlier bans %+v, want %d hours and %d for a limit",
|
||||
i+1, ban.Expires.Sub(now), ban.Notes.EarlierBans, hours, i)
|
||||
}
|
||||
|
||||
@@ -66,8 +67,9 @@ func TestRepeatWindowRunsOut(t *testing.T) {
|
||||
first := ledger.BanForLimit(netblock, midnight(), bans.Notes{})
|
||||
second := ledger.BanForLimit(netblock, first.Expires.Add(tc.gap), bans.Notes{})
|
||||
|
||||
if second.Expires.Sub(second.Start) != tc.want || second.Notes.EarlierBans != 1 {
|
||||
t.Errorf("second ban lasts %s with %d earlier bans, want %s and 1",
|
||||
if second.Expires.Sub(second.Start) != tc.want ||
|
||||
second.Notes.EarlierBans != (bans.EarlierBans{Limit: 1}) {
|
||||
t.Errorf("second ban lasts %s with earlier bans %+v, want %s and 1 for a limit",
|
||||
second.Expires.Sub(second.Start), second.Notes.EarlierBans, tc.want)
|
||||
}
|
||||
})
|
||||
@@ -235,9 +237,102 @@ func TestFullLedgerDropsTheEarlierBanOfTheNetblockBannedAgain(t *testing.T) {
|
||||
second := ledger.BanForLimit(netblock, first.Expires, bans.Notes{})
|
||||
|
||||
held := ledger.Bans(netblock)
|
||||
if len(held) != 1 || held[0] != second || held[0].Notes.EarlierBans != 1 {
|
||||
t.Errorf("the ledger holds %+v, want only the second ban, with 1 earlier ban",
|
||||
held)
|
||||
if len(held) != 1 || held[0] != second ||
|
||||
held[0].Notes.EarlierBans != (bans.EarlierBans{Limit: 1}) {
|
||||
t.Errorf("the ledger holds %+v, want only the second ban, "+
|
||||
"with 1 earlier ban for a limit", held)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequestDuringAnAttackBanMakesItPermanent(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ledger := bans.New(defaultRules())
|
||||
netblock := netip.MustParsePrefix("203.0.113.9/32")
|
||||
notes := bans.Notes{RuleID: "env-file", Target: "path"}
|
||||
|
||||
ban := ledger.BanForAttack(netblock, midnight(), notes)
|
||||
if !ban.Expires.Equal(midnight().Add(7*day)) || ban.Cause != bans.CauseAttack ||
|
||||
ban.Notes.RuleID != "env-file" || ledger.Made(bans.CauseAttack) != 1 ||
|
||||
ledger.Made(bans.CauseLimit) != 0 {
|
||||
t.Fatalf("the ban is %+v, with %d made for an attack and %d for a limit, "+
|
||||
"want one for an attack, of seven days", ban,
|
||||
ledger.Made(bans.CauseAttack), ledger.Made(bans.CauseLimit))
|
||||
}
|
||||
|
||||
wantChanged(t, ledger, true)
|
||||
|
||||
// In observe mode the ban refuses nothing, and stays as it is.
|
||||
got, _ := ledger.Find(netblock.Addr(), midnight().Add(time.Hour))
|
||||
if got.Permanent() {
|
||||
t.Fatal("a request found under the ban made it permanent")
|
||||
}
|
||||
|
||||
// A request it refuses makes it permanent, and bans.json due.
|
||||
got, _ = ledger.Check(netblock.Addr(), midnight().Add(time.Hour))
|
||||
if !got.Permanent() || !ledger.Bans(netblock)[0].Permanent() {
|
||||
t.Fatalf("after a request during the ban, it is %+v, want it permanent", got)
|
||||
}
|
||||
|
||||
wantChanged(t, ledger, true)
|
||||
|
||||
_, banned := ledger.Check(netblock.Addr(), midnight().Add(100*365*day))
|
||||
if !banned {
|
||||
t.Error("the permanent ban ended")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAttackAfterAnAttackBanHasEndedBansPermanently(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ledger := bans.New(defaultRules())
|
||||
netblock := netip.MustParsePrefix("203.0.113.9/32")
|
||||
|
||||
// A ban for a broken limit before does not count.
|
||||
first := ledger.BanForLimit(netblock, midnight(), bans.Notes{})
|
||||
second := ledger.BanForAttack(netblock, first.Expires, bans.Notes{})
|
||||
|
||||
if second.Expires.Sub(second.Start) != 7*day {
|
||||
t.Fatalf("the first ban for an attack lasts %s, want 7 days",
|
||||
second.Expires.Sub(second.Start))
|
||||
}
|
||||
|
||||
// Once that has run out without a request, the netblock is served, and
|
||||
// its next clear sign of attack bans it for good.
|
||||
_, banned := ledger.Check(netblock.Addr(), second.Expires)
|
||||
if banned {
|
||||
t.Fatal("the ban did not end")
|
||||
}
|
||||
|
||||
// Its notes show the earlier ban for an attack that makes it permanent,
|
||||
// beside the one for a limit.
|
||||
third := ledger.BanForAttack(netblock, second.Expires.Add(30*day), bans.Notes{})
|
||||
if !third.Permanent() ||
|
||||
third.Notes.EarlierBans != (bans.EarlierBans{Limit: 1, Attack: 1}) {
|
||||
t.Errorf("the next ban for an attack is %+v, want a permanent one, "+
|
||||
"with 1 earlier ban for a limit and 1 for an attack", third)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAttackBanDoesNotLengthenTheNextBanForALimit(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ledger := bans.New(defaultRules())
|
||||
netblock := netip.MustParsePrefix("203.0.113.9/32")
|
||||
|
||||
// Three times the seven days would be permanent; a limit broken as the
|
||||
// ban for an attack ends bans for an hour, as a first broken limit does.
|
||||
attack := ledger.BanForAttack(netblock, midnight(), bans.Notes{})
|
||||
limit := ledger.BanForLimit(netblock, attack.Expires, bans.Notes{})
|
||||
|
||||
if limit.Expires.Sub(limit.Start) != time.Hour || limit.Cause != bans.CauseLimit {
|
||||
t.Errorf("the ban for a limit is %+v, want one of an hour", limit)
|
||||
}
|
||||
|
||||
// And a request during the ban for a limit leaves it as it is.
|
||||
got, _ := ledger.Check(netblock.Addr(), limit.Start)
|
||||
if got.Permanent() {
|
||||
t.Error("a request during a ban for a limit made it permanent")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -269,6 +364,7 @@ func defaultRules() bans.Rules {
|
||||
LimitBanDuration: time.Hour,
|
||||
LimitBanRepeatWindow: day,
|
||||
MaxBanDuration: 7 * day,
|
||||
AttackBanDuration: 7 * day,
|
||||
MaxBans: 5000,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,8 +82,9 @@ func TestLoadedBansCarryOn(t *testing.T) {
|
||||
}
|
||||
|
||||
again := after.BanForLimit(netblock, ban.Expires, bans.Notes{})
|
||||
if again.Expires.Sub(again.Start) != 3*time.Hour || again.Notes.EarlierBans != 1 {
|
||||
t.Errorf("the next ban lasts %s with %d earlier bans, want 3h and 1",
|
||||
if again.Expires.Sub(again.Start) != 3*time.Hour ||
|
||||
again.Notes.EarlierBans != (bans.EarlierBans{Limit: 1}) {
|
||||
t.Errorf("the next ban lasts %s with earlier bans %+v, want 3h and 1 for a limit",
|
||||
again.Expires.Sub(again.Start), again.Notes.EarlierBans)
|
||||
}
|
||||
}
|
||||
@@ -178,7 +179,8 @@ func TestNextBanWorkedOutFromTheBanThatEndedLast(t *testing.T) {
|
||||
Netblock: netblock,
|
||||
Start: midnight(),
|
||||
Expires: midnight().Add(9 * time.Hour),
|
||||
Notes: bans.Notes{EarlierBans: 2},
|
||||
Cause: bans.CauseLimit,
|
||||
Notes: bans.Notes{EarlierBans: bans.EarlierBans{Limit: 2}},
|
||||
}
|
||||
admins := bans.Ban{
|
||||
Netblock: netblock,
|
||||
@@ -191,10 +193,13 @@ func TestNextBanWorkedOutFromTheBanThatEndedLast(t *testing.T) {
|
||||
|
||||
// Once both have ended, a limit broken within the repeat window bans
|
||||
// for three times the 9 hours, and the notes count the two bans
|
||||
// before the 9-hour one, it, and the admin's.
|
||||
// before the 9-hour one and it, for a limit, and the admin's, without
|
||||
// a cause.
|
||||
ban := ledger.BanForLimit(netblock, nineHours.Expires.Add(time.Hour), bans.Notes{})
|
||||
if ban.Expires.Sub(ban.Start) != 27*time.Hour || ban.Notes.EarlierBans != 4 {
|
||||
t.Errorf("the next ban lasts %s with %d earlier bans, want 27h and 4",
|
||||
if ban.Expires.Sub(ban.Start) != 27*time.Hour ||
|
||||
ban.Notes.EarlierBans != (bans.EarlierBans{Limit: 3, WithoutCause: 1}) {
|
||||
t.Errorf("the next ban lasts %s with earlier bans %+v, "+
|
||||
"want 27h, 3 for a limit and 1 without a cause",
|
||||
ban.Expires.Sub(ban.Start), ban.Notes.EarlierBans)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user