Alerts to a JSON webhook, with a cooldown and an hourly summary (closes #26)
check / check (push) Waiting to run
check / check (push) Waiting to run
SWWAF_ALERT_WEBHOOK_URL gets one JSON POST per alert, in SPEC.md's schema, with SWWAF_ALERT_WEBHOOK_HEADERS: ban and permanent_ban, with the ban's notes, in observe mode too, marked mode observe and worked out only when the alert would be sent; source_failure for GeoJS; file_error for a rule or state file with an error. SWWAF_ALERT_EVENTS chooses; SWWAF_ALERT_COOLDOWN holds back repeats by netblock, file or source; past SWWAF_ALERT_MAX_PER_HOUR the hour ends in one summary. A bounded queue, retried with backoff, holds up no request; a 4xx other than 408 and 429 gives the alert up. alerts.json keeps the queue, the cooldowns and the hour. Nothing shows the URL's path or query. Judgement call: the summary's event is summary, which SPEC.md omits. Judgement call: an admin's ban raises no alert. Model: opus-5-5
This commit is contained in:
+10
-10
@@ -65,9 +65,9 @@ func TestReasonOfTheBansSmallwebwafMakes(t *testing.T) {
|
||||
|
||||
ledger := bans.New(defaultRules())
|
||||
|
||||
limit := ledger.BanForLimit(netip.MustParsePrefix("203.0.113.1/32"), midnight(),
|
||||
limit, _ := ledger.BanForLimit(netip.MustParsePrefix("203.0.113.1/32"), midnight(),
|
||||
bans.Notes{Limit: 1000, Window: "minute"})
|
||||
attack := ledger.BanForAttack(netip.MustParsePrefix("203.0.113.2/32"), midnight(),
|
||||
attack, _ := ledger.BanForAttack(netip.MustParsePrefix("203.0.113.2/32"), midnight(),
|
||||
bans.Notes{RuleID: "git-dir", Target: "path"})
|
||||
|
||||
for _, tc := range []struct{ got, want string }{
|
||||
@@ -101,12 +101,12 @@ func TestLiftedBanForALimitRefusesNothingAndMakesNoBanLonger(t *testing.T) {
|
||||
// kept, and counted among the earlier bans.
|
||||
now := midnight().Add(30 * time.Minute)
|
||||
|
||||
_, banned := ledger.Check(netblock.Addr(), now)
|
||||
_, banned, _ := ledger.Check(netblock.Addr(), now)
|
||||
if banned {
|
||||
t.Error("the lifted ban refuses")
|
||||
}
|
||||
|
||||
ban := ledger.BanForLimit(netblock, now, bans.Notes{})
|
||||
ban, _ := ledger.BanForLimit(netblock, now, bans.Notes{})
|
||||
if ban.Expires.Sub(ban.Start) != time.Hour ||
|
||||
ban.Notes.EarlierBans != (bans.EarlierBans{Limit: 1}) {
|
||||
t.Errorf("the next ban lasts %s with earlier bans %+v, want 1h and 1 for a limit",
|
||||
@@ -134,7 +134,7 @@ func TestLiftedBanForAnAttackRefusesNothingAndMakesNoBanLonger(t *testing.T) {
|
||||
|
||||
now := midnight().Add(2 * time.Hour)
|
||||
|
||||
_, banned := ledger.Find(netblock.Addr(), now)
|
||||
_, banned, _ := ledger.Find(netblock.Addr(), now)
|
||||
if banned {
|
||||
t.Error("the lifted ban refuses")
|
||||
}
|
||||
@@ -145,7 +145,7 @@ func TestLiftedBanForAnAttackRefusesNothingAndMakesNoBanLonger(t *testing.T) {
|
||||
}
|
||||
|
||||
// The next clear sign of attack bans for seven days, as a first does.
|
||||
ban := ledger.BanForAttack(netblock, now, bans.Notes{})
|
||||
ban, _ := ledger.BanForAttack(netblock, now, bans.Notes{})
|
||||
if ban.Expires.Sub(ban.Start) != 7*day {
|
||||
t.Errorf("the next ban for an attack ends at %s, want seven days on", ban.Expires)
|
||||
}
|
||||
@@ -155,7 +155,7 @@ func TestLoadEditCountsTheBansAnAdminMade(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ledger := bans.New(defaultRules())
|
||||
made := ledger.BanForLimit(netip.MustParsePrefix("203.0.113.1/32"), midnight(),
|
||||
made, _ := ledger.BanForLimit(netip.MustParsePrefix("203.0.113.1/32"), midnight(),
|
||||
bans.Notes{})
|
||||
atStart := bans.Ban{
|
||||
Netblock: netip.MustParsePrefix("203.0.113.2/32"),
|
||||
@@ -220,7 +220,7 @@ func TestAdminsBanIsMadeWhileAnotherLasts(t *testing.T) {
|
||||
}
|
||||
|
||||
// It refuses once the ban for the limit has ended.
|
||||
ban, banned := ledger.Find(netblock.Addr(), midnight().Add(2*time.Hour))
|
||||
ban, banned, _ := ledger.Find(netblock.Addr(), midnight().Add(2*time.Hour))
|
||||
if !banned || ban != want {
|
||||
t.Errorf("after the limit's ban the netblock is under %+v (%t), want %+v",
|
||||
ban, banned, want)
|
||||
@@ -261,11 +261,11 @@ func TestLiftLiftsEveryActiveBanCoveringTheClient(t *testing.T) {
|
||||
|
||||
wantChanged(t, ledger, true)
|
||||
|
||||
if _, banned := ledger.Check(client, now); banned {
|
||||
if _, banned, _ := ledger.Check(client, now); banned {
|
||||
t.Error("the client is still banned")
|
||||
}
|
||||
|
||||
if _, banned := ledger.Check(other.Addr(), now); !banned {
|
||||
if _, banned, _ := ledger.Check(other.Addr(), now); !banned {
|
||||
t.Error("the other client's ban was lifted")
|
||||
}
|
||||
|
||||
|
||||
+90
-30
@@ -194,39 +194,43 @@ func (l *Ledger) Changed() <-chan struct{} {
|
||||
// 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) {
|
||||
// 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
|
||||
return Ban{}, false, false
|
||||
}
|
||||
|
||||
ban.Notes.Requests++
|
||||
ban.Notes.Refused++
|
||||
|
||||
if ban.Cause == CauseAttack && !ban.Permanent() {
|
||||
madePermanent := ban.Cause == CauseAttack && !ban.Permanent()
|
||||
if madePermanent {
|
||||
ban.Expires = time.Time{}
|
||||
|
||||
l.markChanged()
|
||||
}
|
||||
|
||||
return *ban, true
|
||||
return *ban, true, madePermanent
|
||||
}
|
||||
|
||||
// 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) {
|
||||
// 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
|
||||
return Ban{}, false, false
|
||||
}
|
||||
|
||||
return *ban, true
|
||||
return *ban, true, ban.Cause == CauseAttack && !ban.Permanent()
|
||||
}
|
||||
|
||||
// activeBan returns the ban in bans, a netblock's bans oldest first, that
|
||||
@@ -244,28 +248,78 @@ 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 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
|
||||
// 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 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 {
|
||||
reason := fmt.Sprintf("requests per %s over the limit of %d",
|
||||
notes.Window, notes.Limit)
|
||||
// returned with false, 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, bool) {
|
||||
return l.ban(netblock, now, CauseLimit, limitReason(notes), notes, true)
|
||||
}
|
||||
|
||||
return l.ban(netblock, now, CauseLimit, reason, notes)
|
||||
// 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, 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 <RuleID>".
|
||||
func (l *Ledger) BanForAttack(netblock netip.Prefix, now time.Time, notes Notes) Ban {
|
||||
return l.ban(netblock, now, CauseAttack, "matched the rule "+notes.RuleID, notes)
|
||||
// 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 <RuleID>".
|
||||
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("requests per %s over the limit of %d", 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
|
||||
@@ -483,10 +537,12 @@ func (l *Ledger) holds(netblock netip.Prefix, start time.Time) bool {
|
||||
}
|
||||
|
||||
// ban bans netblock at now for cause, with reason and notes, as
|
||||
// BanForLimit and BanForAttack describe, and returns the ban.
|
||||
// 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,
|
||||
) Ban {
|
||||
netblock netip.Prefix, now time.Time, cause, reason string, notes Notes, keep bool,
|
||||
) (Ban, bool) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
|
||||
@@ -497,7 +553,7 @@ func (l *Ledger) ban(
|
||||
if found {
|
||||
active := activeBan(*bans, now)
|
||||
if active != nil {
|
||||
return *active
|
||||
return *active, false
|
||||
}
|
||||
|
||||
held = *bans
|
||||
@@ -513,11 +569,15 @@ func (l *Ledger) ban(
|
||||
ban.Expires = l.limitExpiry(held, now)
|
||||
}
|
||||
|
||||
if !keep {
|
||||
return ban, true
|
||||
}
|
||||
|
||||
l.add(ban)
|
||||
l.made[cause]++
|
||||
l.markChanged()
|
||||
|
||||
return ban
|
||||
return ban, true
|
||||
}
|
||||
|
||||
// earlierBans returns how many bans a netblock with the bans held, oldest
|
||||
|
||||
+140
-44
@@ -21,7 +21,7 @@ func TestRepeatsTripleUntilPermanent(t *testing.T) {
|
||||
// Each ban is followed by another as soon as it ends: 1, 3, 9, 27 and
|
||||
// 81 hours.
|
||||
for i, hours := range []int{1, 3, 9, 27, 81} {
|
||||
ban := ledger.BanForLimit(netblock, now, bans.Notes{})
|
||||
ban, _ := ledger.BanForLimit(netblock, now, bans.Notes{})
|
||||
|
||||
length := time.Duration(hours) * time.Hour
|
||||
if !ban.Expires.Equal(now.Add(length)) ||
|
||||
@@ -35,12 +35,12 @@ func TestRepeatsTripleUntilPermanent(t *testing.T) {
|
||||
|
||||
// The sixth would last 243 hours, more than seven days: it is
|
||||
// permanent, and never ends.
|
||||
ban := ledger.BanForLimit(netblock, now, bans.Notes{})
|
||||
ban, _ := ledger.BanForLimit(netblock, now, bans.Notes{})
|
||||
if !ban.Permanent() {
|
||||
t.Fatalf("sixth ban ends at %s, want a permanent one", ban.Expires)
|
||||
}
|
||||
|
||||
_, banned := ledger.Check(netblock.Addr(), now.Add(100*365*day))
|
||||
_, banned, _ := ledger.Check(netblock.Addr(), now.Add(100*365*day))
|
||||
if !banned {
|
||||
t.Error("a permanent ban ended")
|
||||
}
|
||||
@@ -64,8 +64,8 @@ func TestRepeatWindowRunsOut(t *testing.T) {
|
||||
ledger := bans.New(defaultRules())
|
||||
netblock := netip.MustParsePrefix("203.0.113.9/32")
|
||||
|
||||
first := ledger.BanForLimit(netblock, midnight(), bans.Notes{})
|
||||
second := ledger.BanForLimit(netblock, first.Expires.Add(tc.gap), bans.Notes{})
|
||||
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 != (bans.EarlierBans{Limit: 1}) {
|
||||
@@ -83,7 +83,7 @@ func TestFirstBanLongerThanTheMaximumIsPermanent(t *testing.T) {
|
||||
rules.LimitBanDuration = rules.MaxBanDuration + time.Hour
|
||||
ledger := bans.New(rules)
|
||||
|
||||
ban := ledger.BanForLimit(netip.MustParsePrefix("203.0.113.9/32"), midnight(),
|
||||
ban, _ := ledger.BanForLimit(netip.MustParsePrefix("203.0.113.9/32"), midnight(),
|
||||
bans.Notes{})
|
||||
if !ban.Permanent() {
|
||||
t.Errorf("first ban ends at %s, want a permanent one", ban.Expires)
|
||||
@@ -103,7 +103,7 @@ func TestLongestBanSetFarOffDoesNotOverflow(t *testing.T) {
|
||||
now := midnight()
|
||||
|
||||
for i := range 14 {
|
||||
ban := ledger.BanForLimit(netblock, now, bans.Notes{})
|
||||
ban, _ := ledger.BanForLimit(netblock, now, bans.Notes{})
|
||||
if !ban.Expires.After(ban.Start) {
|
||||
t.Fatalf("ban %d starts at %s and ends at %s", i+1, ban.Start, ban.Expires)
|
||||
}
|
||||
@@ -111,7 +111,7 @@ func TestLongestBanSetFarOffDoesNotOverflow(t *testing.T) {
|
||||
now = ban.Expires
|
||||
}
|
||||
|
||||
ban := ledger.BanForLimit(netblock, now, bans.Notes{})
|
||||
ban, _ := ledger.BanForLimit(netblock, now, bans.Notes{})
|
||||
if !ban.Permanent() {
|
||||
t.Errorf("15th ban ends at %s, want a permanent one", ban.Expires)
|
||||
}
|
||||
@@ -123,12 +123,22 @@ func TestBrokenLimitDuringABanMakesNoOther(t *testing.T) {
|
||||
ledger := bans.New(defaultRules())
|
||||
netblock := netip.MustParsePrefix("203.0.113.9/32")
|
||||
|
||||
first := ledger.BanForLimit(netblock, midnight(), bans.Notes{})
|
||||
again := ledger.BanForLimit(netblock, midnight().Add(time.Minute), bans.Notes{})
|
||||
first, made := ledger.BanForLimit(netblock, midnight(), bans.Notes{})
|
||||
if !made {
|
||||
t.Error("the first ban was not made")
|
||||
}
|
||||
|
||||
if again != first || len(ledger.Bans(netblock)) != 1 {
|
||||
t.Errorf("a limit broken during a ban gave %+v and %d bans, want %+v and 1",
|
||||
again, len(ledger.Bans(netblock)), first)
|
||||
again, made := ledger.BanForLimit(netblock, midnight().Add(time.Minute), bans.Notes{})
|
||||
|
||||
if made || again != first || len(ledger.Bans(netblock)) != 1 {
|
||||
t.Errorf("a limit broken during a ban gave %+v, made %t, and %d bans, "+
|
||||
"want %+v, not made, and 1", again, made, len(ledger.Bans(netblock)), first)
|
||||
}
|
||||
|
||||
again, made = ledger.BanForAttack(netblock, midnight().Add(time.Minute), bans.Notes{})
|
||||
if made || again != first {
|
||||
t.Errorf("an attack during a ban gave %+v, made %t, want %+v, not made",
|
||||
again, made, first)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -137,21 +147,21 @@ func TestCheckRefusesWhileTheBanLastsAndCountsTheRefusals(t *testing.T) {
|
||||
|
||||
ledger := bans.New(defaultRules())
|
||||
netblock := netip.MustParsePrefix("203.0.113.9/32")
|
||||
ban := ledger.BanForLimit(netblock, midnight(), bans.Notes{Requests: 5})
|
||||
ban, _ := ledger.BanForLimit(netblock, midnight(), bans.Notes{Requests: 5})
|
||||
|
||||
for range 3 {
|
||||
got, banned := ledger.Check(netblock.Addr(), ban.Expires.Add(-time.Nanosecond))
|
||||
got, banned, _ := ledger.Check(netblock.Addr(), ban.Expires.Add(-time.Nanosecond))
|
||||
if !banned || got.Start != ban.Start {
|
||||
t.Fatalf("check during the ban gives %+v and %t", got, banned)
|
||||
}
|
||||
}
|
||||
|
||||
_, banned := ledger.Check(netip.MustParseAddr("203.0.113.10"), midnight())
|
||||
_, banned, _ := ledger.Check(netip.MustParseAddr("203.0.113.10"), midnight())
|
||||
if banned {
|
||||
t.Error("another netblock is banned")
|
||||
}
|
||||
|
||||
_, banned = ledger.Check(netblock.Addr(), ban.Expires)
|
||||
_, banned, _ = ledger.Check(netblock.Addr(), ban.Expires)
|
||||
if banned {
|
||||
t.Error("the ban did not end")
|
||||
}
|
||||
@@ -169,14 +179,14 @@ func TestFindCountsNothing(t *testing.T) {
|
||||
|
||||
ledger := bans.New(defaultRules())
|
||||
netblock := netip.MustParsePrefix("203.0.113.9/32")
|
||||
ban := ledger.BanForLimit(netblock, midnight(), bans.Notes{Requests: 5})
|
||||
ban, _ := ledger.BanForLimit(netblock, midnight(), bans.Notes{Requests: 5})
|
||||
|
||||
got, banned := ledger.Find(netblock.Addr(), ban.Expires.Add(-time.Nanosecond))
|
||||
got, banned, _ := ledger.Find(netblock.Addr(), ban.Expires.Add(-time.Nanosecond))
|
||||
if !banned || got != ban {
|
||||
t.Errorf("find during the ban gives %+v and %t, want %+v", got, banned, ban)
|
||||
}
|
||||
|
||||
_, banned = ledger.Find(netblock.Addr(), ban.Expires)
|
||||
_, banned, _ = ledger.Find(netblock.Addr(), ban.Expires)
|
||||
if banned {
|
||||
t.Error("the ban did not end")
|
||||
}
|
||||
@@ -198,7 +208,7 @@ func TestMaxBansDropsTheEarliestBanOfTheNetblockSeenLongestAgo(t *testing.T) {
|
||||
d := netip.MustParsePrefix("2001:db8::/64")
|
||||
now := midnight()
|
||||
|
||||
first := ledger.BanForLimit(a, now, bans.Notes{})
|
||||
first, _ := ledger.BanForLimit(a, now, bans.Notes{})
|
||||
ledger.BanForLimit(b, now, bans.Notes{})
|
||||
ledger.BanForLimit(c, now, bans.Notes{})
|
||||
|
||||
@@ -233,8 +243,8 @@ func TestFullLedgerDropsTheEarlierBanOfTheNetblockBannedAgain(t *testing.T) {
|
||||
ledger := bans.New(rules)
|
||||
netblock := netip.MustParsePrefix("203.0.113.9/32")
|
||||
|
||||
first := ledger.BanForLimit(netblock, midnight(), bans.Notes{})
|
||||
second := ledger.BanForLimit(netblock, first.Expires, bans.Notes{})
|
||||
first, _ := ledger.BanForLimit(netblock, midnight(), bans.Notes{})
|
||||
second, _ := ledger.BanForLimit(netblock, first.Expires, bans.Notes{})
|
||||
|
||||
held := ledger.Bans(netblock)
|
||||
if len(held) != 1 || held[0] != second ||
|
||||
@@ -251,7 +261,7 @@ func TestRequestDuringAnAttackBanMakesItPermanent(t *testing.T) {
|
||||
netblock := netip.MustParsePrefix("203.0.113.9/32")
|
||||
notes := bans.Notes{RuleID: "env-file", Target: "path"}
|
||||
|
||||
ban := ledger.BanForAttack(netblock, midnight(), notes)
|
||||
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 {
|
||||
@@ -262,23 +272,31 @@ func TestRequestDuringAnAttackBanMakesItPermanent(t *testing.T) {
|
||||
|
||||
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")
|
||||
// In observe mode the ban refuses nothing, and stays as it is, while
|
||||
// Find tells that the request would have made it permanent.
|
||||
got, _, wouldMakePermanent := ledger.Find(netblock.Addr(), midnight().Add(time.Hour))
|
||||
if got.Permanent() || ledger.Bans(netblock)[0].Permanent() || !wouldMakePermanent {
|
||||
t.Fatalf("a request found under the ban left it %+v, would have made it "+
|
||||
"permanent %t, want it as it was, and true", got, wouldMakePermanent)
|
||||
}
|
||||
|
||||
// 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, false)
|
||||
|
||||
// A request it refuses makes it permanent, says so, and makes
|
||||
// bans.json due.
|
||||
got, _, madePermanent := ledger.Check(netblock.Addr(), midnight().Add(time.Hour))
|
||||
if !madePermanent || !got.Permanent() || !ledger.Bans(netblock)[0].Permanent() {
|
||||
t.Fatalf("after a request during the ban, it is %+v, made permanent %t, "+
|
||||
"want it made permanent", got, madePermanent)
|
||||
}
|
||||
|
||||
wantChanged(t, ledger, true)
|
||||
|
||||
_, banned := ledger.Check(netblock.Addr(), midnight().Add(100*365*day))
|
||||
if !banned {
|
||||
t.Error("the permanent ban ended")
|
||||
// The next request finds it permanent already.
|
||||
_, banned, madePermanent := ledger.Check(netblock.Addr(), midnight().Add(100*365*day))
|
||||
if !banned || madePermanent {
|
||||
t.Errorf("a later request is banned %t, and made the ban permanent %t, "+
|
||||
"want banned by the permanent ban", banned, madePermanent)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -289,8 +307,8 @@ func TestAttackAfterAnAttackBanHasEndedBansPermanently(t *testing.T) {
|
||||
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{})
|
||||
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",
|
||||
@@ -299,14 +317,14 @@ func TestAttackAfterAnAttackBanHasEndedBansPermanently(t *testing.T) {
|
||||
|
||||
// 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)
|
||||
_, 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{})
|
||||
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, "+
|
||||
@@ -314,6 +332,84 @@ func TestAttackAfterAnAttackBanHasEndedBansPermanently(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestWouldBanGivesTheBanWithoutMakingIt(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ledger := bans.New(defaultRules())
|
||||
netblock := netip.MustParsePrefix("203.0.113.9/32")
|
||||
first, _ := ledger.BanForLimit(netblock, midnight(), bans.Notes{})
|
||||
wantChanged(t, ledger, true)
|
||||
|
||||
// While the first ban lasts, none would be made.
|
||||
during, would := ledger.WouldBanForAttack(netblock, midnight(), bans.Notes{})
|
||||
if would || during != first {
|
||||
t.Errorf("during the first ban, would ban %t with %+v, want false with %+v",
|
||||
would, during, first)
|
||||
}
|
||||
|
||||
// As it ends, a clear sign of attack would ban for seven days, and a
|
||||
// limit broken again for three hours, but neither is made.
|
||||
limitNotes := bans.Notes{Limit: 1, Window: "minute"}
|
||||
attack, wouldAttack := ledger.WouldBanForAttack(netblock, first.Expires,
|
||||
bans.Notes{RuleID: "git-dir"})
|
||||
limit, wouldLimit := ledger.WouldBanForLimit(netblock, first.Expires, limitNotes)
|
||||
|
||||
if !wouldAttack || !attack.Expires.Equal(first.Expires.Add(7*day)) ||
|
||||
attack.Reason != "matched the rule git-dir" || !wouldLimit ||
|
||||
!limit.Expires.Equal(first.Expires.Add(3*time.Hour)) ||
|
||||
limit.Reason != "requests per minute over the limit of 1" {
|
||||
t.Errorf("would ban with %+v and %+v, want seven days for the attack and "+
|
||||
"three hours for the limit", attack, limit)
|
||||
}
|
||||
|
||||
if len(ledger.Bans(netblock)) != 1 || ledger.Made(bans.CauseLimit) != 1 ||
|
||||
ledger.Made(bans.CauseAttack) != 0 {
|
||||
t.Errorf("the ledger holds %+v, want the first ban alone", ledger.Bans(netblock))
|
||||
}
|
||||
|
||||
wantChanged(t, ledger, false)
|
||||
|
||||
// The ban made is the one that would have been.
|
||||
made, _ := ledger.BanForLimit(netblock, first.Expires, limitNotes)
|
||||
if made != limit {
|
||||
t.Errorf("the ban made is %+v, want %+v", made, limit)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWouldBePermanentAnswersAsTheBanWouldBeMade(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ledger := bans.New(defaultRules())
|
||||
netblock := netip.MustParsePrefix("203.0.113.9/32")
|
||||
now := midnight()
|
||||
|
||||
// Five bans for a limit in a row, of 1, 3, 9, 27 and 81 hours, are not
|
||||
// permanent. The sixth, of 243 hours, would be, while a first ban for
|
||||
// an attack would not.
|
||||
for i := range 5 {
|
||||
if ledger.WouldBePermanent(netblock, now, bans.CauseLimit) {
|
||||
t.Fatalf("ban %d for a limit would be permanent", i+1)
|
||||
}
|
||||
|
||||
ban, _ := ledger.BanForLimit(netblock, now, bans.Notes{})
|
||||
now = ban.Expires
|
||||
}
|
||||
|
||||
if !ledger.WouldBePermanent(netblock, now, bans.CauseLimit) {
|
||||
t.Error("the sixth ban for a limit would not be permanent")
|
||||
}
|
||||
|
||||
if ledger.WouldBePermanent(netblock, now, bans.CauseAttack) {
|
||||
t.Error("a first ban for an attack would be permanent")
|
||||
}
|
||||
|
||||
// Once a first ban for an attack has ended, the next would be permanent.
|
||||
attack, _ := ledger.BanForAttack(netblock, now, bans.Notes{})
|
||||
if !ledger.WouldBePermanent(netblock, attack.Expires, bans.CauseAttack) {
|
||||
t.Error("a second ban for an attack would not be permanent")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAttackBanDoesNotLengthenTheNextBanForALimit(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -322,16 +418,16 @@ func TestAttackBanDoesNotLengthenTheNextBanForALimit(t *testing.T) {
|
||||
|
||||
// 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{})
|
||||
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() {
|
||||
got, _, madePermanent := ledger.Check(netblock.Addr(), limit.Start)
|
||||
if got.Permanent() || madePermanent {
|
||||
t.Error("a request during a ban for a limit made it permanent")
|
||||
}
|
||||
}
|
||||
@@ -346,7 +442,7 @@ func TestRequestTextsAreCutTo256Bytes(t *testing.T) {
|
||||
Time: midnight(), Method: long, Host: long, Path: long, Status: 403, UserAgent: long,
|
||||
}
|
||||
|
||||
ban := ledger.BanForLimit(netblock, midnight(), bans.Notes{Request: request})
|
||||
ban, _ := ledger.BanForLimit(netblock, midnight(), bans.Notes{Request: request})
|
||||
|
||||
cut := long[:256]
|
||||
want := bans.Request{
|
||||
|
||||
@@ -42,7 +42,7 @@ func TestSnapshotListsEveryBanByNetblock(t *testing.T) {
|
||||
high := netip.MustParsePrefix("203.0.113.10/32")
|
||||
low := netip.MustParsePrefix("203.0.113.9/32")
|
||||
|
||||
first := ledger.BanForLimit(v6, midnight(), bans.Notes{})
|
||||
first, _ := ledger.BanForLimit(v6, midnight(), bans.Notes{})
|
||||
ledger.BanForLimit(high, midnight(), bans.Notes{})
|
||||
ledger.BanForLimit(low, midnight(), bans.Notes{})
|
||||
ledger.BanForLimit(v6, first.Expires, bans.Notes{})
|
||||
@@ -68,7 +68,7 @@ func TestLoadedBansCarryOn(t *testing.T) {
|
||||
|
||||
before := bans.New(defaultRules())
|
||||
netblock := netip.MustParsePrefix("203.0.113.9/32")
|
||||
ban := before.BanForLimit(netblock, midnight(), bans.Notes{Limit: 1})
|
||||
ban, _ := before.BanForLimit(netblock, midnight(), bans.Notes{Limit: 1})
|
||||
|
||||
// Loaded into a new ledger, as across a restart, the ban still refuses
|
||||
// while it lasts, and once it has ended a broken limit bans for three
|
||||
@@ -76,12 +76,12 @@ func TestLoadedBansCarryOn(t *testing.T) {
|
||||
after := bans.New(defaultRules())
|
||||
after.Load(before.Snapshot())
|
||||
|
||||
_, banned := after.Check(netblock.Addr(), ban.Expires.Add(-time.Second))
|
||||
_, banned, _ := after.Check(netblock.Addr(), ban.Expires.Add(-time.Second))
|
||||
if !banned {
|
||||
t.Error("the loaded ban does not refuse")
|
||||
}
|
||||
|
||||
again := after.BanForLimit(netblock, ban.Expires, bans.Notes{})
|
||||
again, _ := after.BanForLimit(netblock, ban.Expires, bans.Notes{})
|
||||
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",
|
||||
@@ -111,7 +111,7 @@ func TestLoadedBanRefusesEveryClientInItsNetblock(t *testing.T) {
|
||||
"198.51.100.7": true,
|
||||
"198.51.100.8": false,
|
||||
} {
|
||||
_, banned := ledger.Check(netip.MustParseAddr(client), midnight())
|
||||
_, banned, _ := ledger.Check(netip.MustParseAddr(client), midnight())
|
||||
if banned != want {
|
||||
t.Errorf("%s is refused: %t, want %t", client, banned, want)
|
||||
}
|
||||
@@ -150,19 +150,19 @@ func TestPermanentBanStartedBeforeAnEndedOneRefuses(t *testing.T) {
|
||||
now := midnight().Add(2 * time.Hour)
|
||||
client := netip.MustParseAddr("203.0.113.9")
|
||||
|
||||
ban, banned := ledger.Find(client, now)
|
||||
ban, banned, _ := ledger.Find(client, now)
|
||||
if !banned || !ban.Permanent() {
|
||||
t.Errorf("find gives %+v and %t, want the permanent ban", ban, banned)
|
||||
}
|
||||
|
||||
ban, banned = ledger.Check(client, now)
|
||||
ban, banned, _ = ledger.Check(client, now)
|
||||
if !banned || !ban.Permanent() {
|
||||
t.Errorf("the client is refused: %t, under %+v, want under the permanent ban",
|
||||
banned, ban)
|
||||
}
|
||||
|
||||
// A limit broken now makes no shorter ban over the permanent one.
|
||||
ban = ledger.BanForLimit(netblock, now, bans.Notes{})
|
||||
ban, _ = ledger.BanForLimit(netblock, now, bans.Notes{})
|
||||
if !ban.Permanent() || len(ledger.Bans(netblock)) != 2 {
|
||||
t.Errorf("a broken limit returned %+v and left the netblock %d bans, "+
|
||||
"want the permanent ban and 2", ban, len(ledger.Bans(netblock)))
|
||||
@@ -194,7 +194,7 @@ 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 and it, for a limit, and the admin's.
|
||||
ban := ledger.BanForLimit(netblock, nineHours.Expires.Add(time.Hour), bans.Notes{})
|
||||
ban, _ := ledger.BanForLimit(netblock, nineHours.Expires.Add(time.Hour), bans.Notes{})
|
||||
if ban.Expires.Sub(ban.Start) != 27*time.Hour ||
|
||||
ban.Notes.EarlierBans != (bans.EarlierBans{Limit: 3, Admin: 1}) {
|
||||
t.Errorf("the next ban lasts %s with earlier bans %+v, "+
|
||||
@@ -255,15 +255,15 @@ func TestLoadReplacesTheBansHeld(t *testing.T) {
|
||||
// bans.json is taken in, that ban is lifted.
|
||||
ledger.Load([]bans.Ban{kept})
|
||||
|
||||
_, banned := ledger.Check(netip.MustParseAddr("203.0.113.9"), midnight())
|
||||
_, banned, _ := ledger.Check(netip.MustParseAddr("203.0.113.9"), midnight())
|
||||
if banned {
|
||||
t.Error("a ban left out of the second load still refuses")
|
||||
}
|
||||
|
||||
// The ledger holds one ban, so it makes two more without dropping any.
|
||||
first := ledger.BanForLimit(netip.MustParsePrefix("198.51.100.7/32"), midnight(),
|
||||
first, _ := ledger.BanForLimit(netip.MustParsePrefix("198.51.100.7/32"), midnight(),
|
||||
bans.Notes{})
|
||||
second := ledger.BanForLimit(netip.MustParsePrefix("198.51.100.8/32"), midnight(),
|
||||
second, _ := ledger.BanForLimit(netip.MustParsePrefix("198.51.100.8/32"), midnight(),
|
||||
bans.Notes{})
|
||||
|
||||
want := []bans.Ban{first, second, kept}
|
||||
|
||||
Reference in New Issue
Block a user