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