Trap paths, and the error burst banning a client refused too often (closes #115)
check / check (push) Waiting to run

SWWAF_TRAP_PATHS: a request whose path, as a path rule sees it, is one
of them is a clear sign of attack, banned as a ban rule's match is; the
ban's notes give its trap_path. Checked after the rate limits, before the
rule files.

SWWAF_ERROR_BURST_THRESHOLD (default 30, or off): more refusals in a
minute after a block or ban rule or a trap path, or for a missing or
wrong token, ban the client as a broken limit does. Counted in
clients.json's minute_refusals; limit_hit error_burst, notes kind
refusals.

A token refusal is now the offence token_refused, and
smallwebwaf_offences_total counts every kind the history does.

Judgement call: the threshold is not lowered by a client's limit percentage.

Model: opus-5-5
This commit was merged in pull request #118.
This commit is contained in:
2026-10-08 06:44:55 +02:00
parent 5f3fb48809
commit 54779f08de
21 changed files with 1208 additions and 318 deletions
+65 -28
View File
@@ -25,6 +25,9 @@ const (
KindRequests = "requests"
// KindBytes is a byte limit, on a client's bytes.
KindBytes = "bytes"
// KindRefusals is the error burst, on a client's requests smallwebwaf
// refused after a rule file match or for a missing or wrong token.
KindRefusals = "refusals"
)
// Limits are the most requests a client may make in a minute, an hour and
@@ -50,18 +53,20 @@ type Limiter struct {
}
// Client is a client in the table, as clients.json holds it: its buckets
// of requests and of bytes in each window, and its history.
// of requests and of bytes in each window, its buckets of refusals in the
// minute, which the error burst counts, and its history.
//
//nolint:tagliatelle // the state files use snake_case, as the request log does
type Client struct {
Client netip.Prefix `json:"client"`
Minute Buckets `json:"minute"`
Hour Buckets `json:"hour"`
Day Buckets `json:"day"`
MinuteBytes Buckets `json:"minute_bytes"`
HourBytes Buckets `json:"hour_bytes"`
DayBytes Buckets `json:"day_bytes"`
History History `json:"history"`
Client netip.Prefix `json:"client"`
Minute Buckets `json:"minute"`
Hour Buckets `json:"hour"`
Day Buckets `json:"day"`
MinuteBytes Buckets `json:"minute_bytes"`
HourBytes Buckets `json:"hour_bytes"`
DayBytes Buckets `json:"day_bytes"`
MinuteRefusals Buckets `json:"minute_refusals"`
History History `json:"history"`
}
// Buckets are a client's two buckets in one window: the requests, or the
@@ -116,12 +121,15 @@ type Responses struct {
//
//nolint:tagliatelle // the state files use snake_case, as the request log does
type Offences struct {
// Limit is its requests that broke a rate limit or a byte limit,
// Attack those that matched a ban rule, a clear sign of attack, and
// RuleBlocked those a block rule refused.
Limit int64 `json:"limit"`
Attack int64 `json:"attack"`
RuleBlocked int64 `json:"rule_blocked"`
// Limit is its requests that broke a rate limit, a byte limit or the
// error burst, Attack those that were a clear sign of attack, a match
// of a ban rule or a request for a trap path, RuleBlocked those a block
// rule refused, and TokenRefused those refused for a missing or wrong
// token.
Limit int64 `json:"limit"`
Attack int64 `json:"attack"`
RuleBlocked int64 `json:"rule_blocked"`
TokenRefused int64 `json:"token_refused"`
}
// Request is what a client's history keeps of one of its requests.
@@ -138,12 +146,14 @@ type Request struct {
// and of its response.
RequestBytes int64
ResponseBytes int64
// BrokeLimit is true for a request that broke a rate limit or a byte
// limit, Attack for one that matched a ban rule, and RuleBlocked for
// one a block rule refused.
BrokeLimit bool
Attack bool
RuleBlocked bool
// BrokeLimit is true for a request that broke a rate limit, a byte
// limit or the error burst, Attack for one that matched a ban rule or
// asked for a trap path, RuleBlocked for one a block rule refused, and
// TokenRefused for one refused for a missing or wrong token.
BrokeLimit bool
Attack bool
RuleBlocked bool
TokenRefused bool
}
// New returns a Limiter for limits, with no client counted yet, whose
@@ -175,17 +185,18 @@ func New(limits Limits, maxClients int) *Limiter {
}
}
// Hit is a request that takes a client over a rate limit, or whose bytes
// take it over a byte limit.
// Hit is a request that takes a client over a rate limit or the error
// burst, or whose bytes take it over a byte limit.
type Hit struct {
// Kind is KindRequests for a rate limit, KindBytes for a byte limit.
// Kind is KindRequests for a rate limit, KindBytes for a byte limit,
// KindRefusals for the error burst.
Kind string
// Window is "minute", "hour" or "day".
Window string
// Limit is the window's limit, as the client's percentage of it.
Limit int64
// Count is the client's requests, or bytes, counted in the window,
// this request's included.
// Count is the client's requests, bytes or refusals counted in the
// window, this request's included.
Count float64
}
@@ -224,8 +235,25 @@ func (l *Limiter) CountBytes(
return l.count(client, now, 0, bytes, percent)
}
// Reset sets client's counts of requests and of bytes in every window
// back to zero. Its history keeps its totals.
// CountRefusal counts a request from client at now that smallwebwaf
// refused after a rule file match or for a missing or wrong token, and
// reports whether the client's refusals in the minute that ends at now,
// this one included, are more than threshold, which breaks the error
// burst, and the hit.
func (l *Limiter) CountRefusal(
client netip.Prefix, now time.Time, threshold int64,
) (Hit, bool) {
l.mu.Lock()
defer l.mu.Unlock()
count := l.get(client).MinuteRefusals.Add(now, time.Minute, 1)
hit := Hit{Kind: KindRefusals, Window: "minute", Limit: threshold, Count: count}
return hit, count > float64(threshold)
}
// Reset sets client's counts of requests, of bytes and of refusals in
// every window back to zero. Its history keeps its totals.
func (l *Limiter) Reset(client netip.Prefix) {
l.mu.Lock()
defer l.mu.Unlock()
@@ -234,6 +262,7 @@ func (l *Limiter) Reset(client netip.Prefix) {
if seen {
c.Minute, c.Hour, c.Day = Buckets{}, Buckets{}, Buckets{}
c.MinuteBytes, c.HourBytes, c.DayBytes = Buckets{}, Buckets{}, Buckets{}
c.MinuteRefusals = Buckets{}
}
}
@@ -274,6 +303,10 @@ func (l *Limiter) AddToHistory(client netip.Prefix, now time.Time, r Request) {
if r.RuleBlocked {
h.Offences.RuleBlocked++
}
if r.TokenRefused {
h.Offences.TokenRefused++
}
}
// AddLookup gives client's history its AS number, AS name and country, as
@@ -383,6 +416,10 @@ func (l *Limiter) Load(clients []Client, now time.Time) {
}
}
if c.MinuteRefusals.Passed(now, time.Minute) {
c.MinuteRefusals = Buckets{}
}
l.clients.Add(c.Client, &c)
}
}