Compare commits

..
1 Commits
Author SHA1 Message Date
clawbot 669524cf8e Per-client request rate limits over a minute, an hour and a day (closes #43)
check / check (push) Successful in 2m20s
Each client, one IPv4 address or one IPv6 /64, is counted in two buckets
per window, the earlier weighted by how much of it the window covers; at
most 20,000 clients are kept, least recently seen dropped first. A
request over SWWAF_RATE_LIMIT_PER_MINUTE, _HOUR or _DAY (1000, 10000,
50000, or off) gets 429 before reaching the app. Refused requests count,
413s included. A clock set back over a second behind a bucket's start
restarts that window. The log line gains limit_hit and the action
rate_limited.

Deviation from SPEC.md, per the issue: the 20,000 bound and /64 are fixed.
Judgement call: golang-lru/v2 holds the table; httprate does not count refused requests.
Deviation: go.mod and go.sum hand-written; no make target tidies them.

Model: opus-5-5
2026-10-04 01:57:57 +00:00
2 changed files with 24 additions and 4 deletions
+5 -4
View File
@@ -109,12 +109,13 @@ bytes). Rate limits are whole numbers of requests. Netblocks are in CIDR form,
and a bare address stands for itself alone. `off` switches a timeout, a size
limit or a rate limit off.
Two limits are fixed rather than settings: the request line and headers may take
up to 32 KiB, above which the answer is `431` and nothing reaches the app, and a
kept-open connection that sends nothing for 120 seconds is closed. That is
Four limits are fixed rather than settings. The request line and headers may
take up to 32 KiB, above which the answer is `431` and nothing reaches the app.
A kept-open connection that sends nothing for 120 seconds is closed. That is
longer than the 90 seconds after which traefik closes a connection it is not
using, so traefik never sends a request on a connection `smallwebwaf` is
closing.
closing. At most 20,000 clients are kept for the rate limits, and an IPv6 client
is counted by its /64.
## Request log
+19
View File
@@ -54,6 +54,25 @@ func TestEachWindowRefusesAtItsLimitAndLetsTheClientBack(t *testing.T) {
}
}
func TestClientBackAfterAWholeBucketIsWithinTheLimitAtOnce(t *testing.T) {
t.Parallel()
limiter := ratelimit.New(ratelimit.Limits{PerHour: limit})
client := netip.MustParsePrefix("203.0.113.9/32")
start := midnight()
for range limit {
wantCount(t, limiter, client, start, "")
}
wantCount(t, limiter, client, start, hour)
// No request in the whole next bucket, so a quarter into the one after
// it the window covers none of the four requests: 1 is within the
// limit. Were they counted as the bucket before, 3 + 1 would be over.
wantCount(t, limiter, client, start.Add(2*time.Hour+time.Hour/4), "")
}
func TestRefusedRequestsCount(t *testing.T) {
t.Parallel()