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 11 additions and 17 deletions
+1 -2
View File
@@ -53,9 +53,8 @@ const ipv6GroupPrefix = 64
// clientGroup is the client a request is counted toward: its IPv4
// address, or the /64 its IPv6 address is in, since one abuser usually
// holds a whole /64. An IPv4 address in IPv6 form counts as IPv4.
// holds a whole /64.
func clientGroup(addr netip.Addr) netip.Prefix {
addr = addr.Unmap()
if addr.Is6() {
return netip.PrefixFrom(addr, ipv6GroupPrefix).Masked()
}
+10 -15
View File
@@ -21,23 +21,18 @@ func TestRateLimitRefusesWith429BeforeTheApp(t *testing.T) {
rateLimitPerMinute: "1",
})
const otherClient = "203.0.113.10"
// With a limit of one request a minute, a client's second request is
// refused. A client is one IPv4 address, or one IPv6 /64; an IPv4
// address in IPv6 form is that IPv4 address.
// refused. A client is one IPv4 address, or one IPv6 /64.
requests := []struct {
client string // as X-Forwarded-For names it
logged string // as the log line's client_ip names it
client string
want int
}{
{client, client, http.StatusOK},
{client, client, http.StatusTooManyRequests},
{otherClient, otherClient, http.StatusOK},
{"::ffff:" + otherClient, otherClient, http.StatusTooManyRequests},
{"2001:db8::1", "2001:db8::1", http.StatusOK},
{"2001:db8::8000:0:0:1", "2001:db8::8000:0:0:1", http.StatusTooManyRequests},
{"2001:db8:0:1::1", "2001:db8:0:1::1", http.StatusOK},
{client, http.StatusOK},
{client, http.StatusTooManyRequests},
{"203.0.113.10", http.StatusOK},
{"2001:db8::1", http.StatusOK},
{"2001:db8::8000:0:0:1", http.StatusTooManyRequests},
{"2001:db8:0:1::1", http.StatusOK},
}
for i, sent := range requests {
@@ -46,8 +41,8 @@ func TestRateLimitRefusesWith429BeforeTheApp(t *testing.T) {
wantStatus(t, do(t, req), sent.want)
line := out.requestLines(t, i+1)[i]
if line.ClientIP != sent.logged {
t.Errorf("log line has client_ip %q, want %q", line.ClientIP, sent.logged)
if line.ClientIP != sent.client {
t.Errorf("log line has client_ip %q, want %q", line.ClientIP, sent.client)
}
if sent.want == http.StatusOK {