Compare commits

..
1 Commits
Author SHA1 Message Date
clawbot 9501aad890 Per-client request rate limits over a minute, an hour and a day (closes #43)
check / check (push) Successful in 3m31s
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 02:14:51 +00:00
2 changed files with 17 additions and 11 deletions
+2 -1
View File
@@ -53,8 +53,9 @@ 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.
// holds a whole /64. An IPv4 address in IPv6 form counts as IPv4.
func clientGroup(addr netip.Addr) netip.Prefix {
addr = addr.Unmap()
if addr.Is6() {
return netip.PrefixFrom(addr, ipv6GroupPrefix).Masked()
}
+15 -10
View File
@@ -21,18 +21,23 @@ 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.
// refused. A client is one IPv4 address, or one IPv6 /64; an IPv4
// address in IPv6 form is that IPv4 address.
requests := []struct {
client string
client string // as X-Forwarded-For names it
logged string // as the log line's client_ip names it
want int
}{
{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},
{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},
}
for i, sent := range requests {
@@ -41,8 +46,8 @@ func TestRateLimitRefusesWith429BeforeTheApp(t *testing.T) {
wantStatus(t, do(t, req), sent.want)
line := out.requestLines(t, i+1)[i]
if line.ClientIP != sent.client {
t.Errorf("log line has client_ip %q, want %q", line.ClientIP, sent.client)
if line.ClientIP != sent.logged {
t.Errorf("log line has client_ip %q, want %q", line.ClientIP, sent.logged)
}
if sent.want == http.StatusOK {