check / check (push) Successful in 3m24s
smallwebwaf now copies its state to bans.json, clients.json and lookups.json in SWWAF_STATE_DIR, as "Persistent state" in SPEC.md describes, and reads them back at start, so a restart lifts no ban and gives no client a fresh allowance. Each client gains a history, and a ban's notes count the netblock's requests. bans.json is written SWWAF_STATE_WRITE_DELAY after a ban, and every file every SWWAF_STATE_COUNTER_INTERVAL and at the stop. A ban read back is masked to its netblock and refuses every client in it. A file that does not parse, an unknown version, an entry without a field it needs, or an unwritable directory stops the start. Deviation: no AS number or name, and no ban cause, reason or lifting yet. Model: opus-5-5
117 lines
2.8 KiB
Go
117 lines
2.8 KiB
Go
package ratelimit_test
|
|
|
|
import (
|
|
"net/netip"
|
|
"testing"
|
|
"time"
|
|
|
|
"sneak.berlin/go/smallwebwaf/internal/ratelimit"
|
|
)
|
|
|
|
func TestHistoryKeepsEveryRequest(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
limiter := ratelimit.New(ratelimit.Limits{})
|
|
client := netip.MustParsePrefix("203.0.113.9/32")
|
|
start := midnight()
|
|
|
|
for i, r := range []ratelimit.Request{
|
|
{Country: "DE", Forwarded: true, Status: 200, RequestBytes: 10, ResponseBytes: 100},
|
|
{Forwarded: true, Status: 101},
|
|
{Forwarded: true, Status: 304, RequestBytes: 5},
|
|
{Country: "FR", Status: 403, ResponseBytes: 10, BrokeLimit: true},
|
|
{Forwarded: true, Status: 502, ResponseBytes: 12},
|
|
// Closed without an answer: refused, and no response.
|
|
{Status: 0},
|
|
} {
|
|
limiter.AddToHistory(client, start.Add(time.Duration(i)*time.Minute), r)
|
|
}
|
|
|
|
want := ratelimit.History{
|
|
FirstSeen: start,
|
|
LastSeen: start.Add(5 * time.Minute),
|
|
Country: "FR",
|
|
LookedUp: start.Add(3 * time.Minute),
|
|
Requests: 6,
|
|
Forwarded: 4,
|
|
Refused: 2,
|
|
RequestBytes: 15,
|
|
ResponseBytes: 122,
|
|
Responses: ratelimit.Responses{
|
|
Status1xx: 1, Status2xx: 1, Status3xx: 1, Status4xx: 1, Status5xx: 1,
|
|
},
|
|
Offences: ratelimit.Offences{Limit: 1},
|
|
}
|
|
|
|
got := historyOf(t, limiter, client)
|
|
if got != want {
|
|
t.Errorf("history\n%+v\nwant\n%+v", got, want)
|
|
}
|
|
}
|
|
|
|
func TestResetKeepsTheHistory(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
limiter := ratelimit.New(ratelimit.Limits{PerMinute: limit})
|
|
client := netip.MustParsePrefix("203.0.113.9/32")
|
|
start := midnight()
|
|
|
|
for range limit {
|
|
wantCount(t, limiter, client, start, "")
|
|
limiter.AddToHistory(client, start, ratelimit.Request{Forwarded: true})
|
|
}
|
|
|
|
limiter.Reset(client)
|
|
|
|
if got := historyOf(t, limiter, client).Requests; got != limit {
|
|
t.Errorf("the history counts %d requests, want %d", got, limit)
|
|
}
|
|
}
|
|
|
|
func TestRequestsAddsUpTheClientsInsideTheNetblock(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
limiter := ratelimit.New(ratelimit.Limits{})
|
|
|
|
for client, requests := range map[string]int{
|
|
"198.51.100.9/32": 2,
|
|
"198.51.100.10/32": 3,
|
|
"192.0.2.1/32": 5,
|
|
"2001:db8:5::/64": 7,
|
|
} {
|
|
for range requests {
|
|
limiter.AddToHistory(netip.MustParsePrefix(client), midnight(),
|
|
ratelimit.Request{})
|
|
}
|
|
}
|
|
|
|
for netblock, want := range map[string]int64{
|
|
"198.51.100.9/32": 2,
|
|
"198.51.100.0/24": 5,
|
|
"2001:db8:5::/64": 7,
|
|
"203.0.113.0/24": 0,
|
|
} {
|
|
got := limiter.Requests(netip.MustParsePrefix(netblock))
|
|
if got != want {
|
|
t.Errorf("%s has sent %d requests, want %d", netblock, got, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// historyOf returns client's history.
|
|
func historyOf(
|
|
t *testing.T, limiter *ratelimit.Limiter, client netip.Prefix,
|
|
) ratelimit.History {
|
|
t.Helper()
|
|
|
|
for _, c := range limiter.Snapshot() {
|
|
if c.Client == client {
|
|
return c.History
|
|
}
|
|
}
|
|
|
|
t.Fatalf("%s is not in the table", client)
|
|
|
|
return ratelimit.History{}
|
|
}
|