From 669524cf8e229cf25a2317144b626b88657674a4 Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Sun, 4 Oct 2026 01:23:42 +0000 Subject: [PATCH] Per-client request rate limits over a minute, an hour and a day (closes #43) 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 --- README.md | 74 ++++++--- go.mod | 2 + go.sum | 2 + internal/config/config.go | 44 ++++- internal/config/config_test.go | 44 ++++- internal/proxy/client.go | 14 ++ internal/proxy/proxy.go | 7 + internal/proxy/proxy_test.go | 1 + internal/proxy/ratelimits_test.go | 62 +++++++ internal/proxy/request.go | 18 +- internal/ratelimit/ratelimit.go | 132 +++++++++++++++ internal/ratelimit/ratelimit_test.go | 200 +++++++++++++++++++++++ internal/requestlog/requestlog.go | 6 + internal/requestlog/requestlog_test.go | 4 +- internal/smallwebwaf/smallwebwaf_test.go | 3 + 15 files changed, 581 insertions(+), 32 deletions(-) create mode 100644 internal/proxy/ratelimits_test.go create mode 100644 internal/ratelimit/ratelimit.go create mode 100644 internal/ratelimit/ratelimit_test.go diff --git a/README.md b/README.md index 8fd4941..593e3f5 100644 --- a/README.md +++ b/README.md @@ -12,14 +12,15 @@ state in memory and in JSON files you can read and edit, and writes a detailed JSON log line for every request. Status: the first milestone is built -(https://git.eeqj.de/sneak/smallwebwaf/issues/13). `smallwebwaf` passes each -request to the app and the app's answer back, unchanged, within its timeouts and -size limits, works out each client's address, and writes a JSON log line for -every request. Rate limits per client, the country lists and the image an app -builds on come with milestone 2 -(https://git.eeqj.de/sneak/smallwebwaf/issues/14), and the rest of the design -after that, in the order of the build order in [`SPEC.md`](SPEC.md). The survey -of existing tools that led to the design is in [`EVALUATION.md`](EVALUATION.md). +(https://git.eeqj.de/sneak/smallwebwaf/issues/13), and the rate limits of the +second (https://git.eeqj.de/sneak/smallwebwaf/issues/14). `smallwebwaf` passes +each request to the app and the app's answer back, unchanged, within its +timeouts and size limits, works out each client's address, refuses a client that +sends too many requests, and writes a JSON log line for every request. The +country lists and the image an app builds on come with the rest of milestone 2, +and the rest of the design after that, in the order of the build order in +[`SPEC.md`](SPEC.md). The survey of existing tools that led to the design is in +[`EVALUATION.md`](EVALUATION.md). ## Getting started @@ -38,7 +39,7 @@ It then listens on port 8080 and passes every request to the app at app on `127.0.0.1:8081`. On `SIGTERM` or `SIGINT` it stops taking requests and gives those in progress five seconds to finish. -## What milestone 1 does +## What it does so far - Passes each request to the app and the app's answer back unchanged: method, path, query, headers, body and status. Bodies stream through in both @@ -61,6 +62,16 @@ gives those in progress five seconds to finish. `smallwebwaf` was waiting for the client to send more, and `504` if it was waiting for the app to take what it had. Once the response has started, a limit can only cut the connection. +- Counts each client's requests over a minute, an hour and a day. A request that + takes the client over one of the rate limits below is refused with `429` + before anything reaches the app, and so is each request after it until the + client is back under every limit. A client is one IPv4 address, or one IPv6 + /64, since one abuser usually holds a whole /64. Refused requests count too, + so a client that keeps sending too fast stays refused until it slows down. + Each window is counted in two fixed buckets, the earlier one weighted by how + much of it the window still covers. At most 20,000 clients are kept, the least + recently seen dropped first, and only in memory: a restart starts every client + afresh. - Writes a line in the request log for each request (see "Request log" below). ## Settings @@ -86,18 +97,25 @@ it, and the effective settings are logged at start. to send its whole answer, from the end of the request to the last byte. - `SWWAF_REQUEST_MAX_BYTES` (default `100M`): the largest request body. - `SWWAF_RESPONSE_MAX_BYTES` (default `5G`): the largest response body. +- `SWWAF_RATE_LIMIT_PER_MINUTE` (default `1000`), `SWWAF_RATE_LIMIT_PER_HOUR` + (default `10000`) and `SWWAF_RATE_LIMIT_PER_DAY` (default `50000`): the most + requests a client may make in a minute, an hour and a day. The defaults are + several times what one busy person produces, since a browser loading a heavy + page makes a few hundred requests and several people often share one address. Durations are in Go's syntax, with `d` for days (`90s`, `15m`, `7d`). Sizes are bytes, with an optional `K`, `M` or `G`, which are powers of 1024 (`1K` is 1024 -bytes). Netblocks are in CIDR form, and a bare address stands for itself alone. -`off` switches a timeout or a size limit off. +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 @@ -113,10 +131,13 @@ refused ones included: - `status` is what the client was sent, `0` if nothing was; `upstream_status` is what the app answered, and is left out when the app did not answer. - `request_bytes` and `response_bytes` count body bytes. -- `action` is `forward` for a request passed to the app, `too_large` for a - request or response over its size limit, `timed_out` for one that ran out of - time, and `upstream_error` when the app could not be reached or its answer - broke off. +- `action` is `forward` for a request passed to the app, `rate_limited` for one + refused for a rate limit, `too_large` for a request or response over its size + limit, `timed_out` for one that ran out of time, and `upstream_error` when the + app could not be reached or its answer broke off. +- `limit_hit` is there for a request refused for a rate limit, and names the + window whose limit it went over: `minute`, `hour` or `day`, the shortest if it + went over several. - `aborted` is there, and true, when the client went away early. - `duration_total` and `duration_upstream_total` are in milliseconds. @@ -391,12 +412,17 @@ refusal comes with `SWWAF_ALLOW_NETS` in milestone 3 or later. - `internal/proxy`: what happens to each request: it works out the client, runs the checks, passes the request to the app and the answer back with the standard library's `httputil.ReverseProxy` within the timeouts and size - limits, and writes the request's log line. Its `check` method is where - milestone 2's rate limits and country lists refuse a request. + limits, and writes the request's log line. Its `check` method is where a + request is refused before anything reaches the app: for a rate limit, for an + announced body over the size limit, and, with the rest of milestone 2, for the + country lists. +- `internal/ratelimit`: counts each client's requests and tells when one takes + it over a rate limit. - `internal/requestlog`: the lines on stdout: the request log line and the process's own messages. -Only the Go standard library is used. +Besides the Go standard library, `github.com/hashicorp/golang-lru/v2` keeps the +table of clients to 20,000, dropping the least recently seen. ## Entrypoints @@ -430,8 +456,8 @@ so that they run in minimal containers. ## TODO -- Milestone 2: rate limits per client, the country lists and the image an app - builds on (https://git.eeqj.de/sneak/smallwebwaf/issues/14). +- Milestone 2: the country lists and the image an app builds on + (https://git.eeqj.de/sneak/smallwebwaf/issues/14); its rate limits are built. - The rest of the design, in the order of the build order in [`SPEC.md`](SPEC.md). diff --git a/go.mod b/go.mod index c60a421..7e30871 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,5 @@ module sneak.berlin/go/smallwebwaf go 1.26.0 + +require github.com/hashicorp/golang-lru/v2 v2.0.7 diff --git a/go.sum b/go.sum index e69de29..a33c54a 100644 --- a/go.sum +++ b/go.sum @@ -0,0 +1,2 @@ +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= diff --git a/internal/config/config.go b/internal/config/config.go index a0236c2..703aa18 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -16,7 +16,8 @@ import ( "time" ) -// Config is smallwebwaf's settings. A timeout or size of zero is off. +// Config is smallwebwaf's settings. A timeout, size or rate limit of zero +// is off. type Config struct { // ListenAddr is where smallwebwaf listens (SWWAF_LISTEN_ADDR). ListenAddr string @@ -43,13 +44,21 @@ type Config struct { // ResponseMaxBytes is the largest response body // (SWWAF_RESPONSE_MAX_BYTES). ResponseMaxBytes int64 + // RateLimitPerMinute, RateLimitPerHour and RateLimitPerDay are the + // most requests a client may make in a minute, an hour and a day + // (SWWAF_RATE_LIMIT_PER_MINUTE, SWWAF_RATE_LIMIT_PER_HOUR and + // SWWAF_RATE_LIMIT_PER_DAY). + RateLimitPerMinute int64 + RateLimitPerHour int64 + RateLimitPerDay int64 // settings are the values read, as given or by default, for the // log line at start. settings []slog.Attr } -// off is the value that switches a timeout or a size limit off. +// off is the value that switches a timeout, a size limit or a rate limit +// off. const off = "off" const ( @@ -64,6 +73,8 @@ var ( "is not a duration such as 90s, 15m or 7d, or off") errNotSize = errors.New( "is not a size such as 512K, 100M or 5G, or off") + errNotCount = errors.New( + "is not a whole number of requests such as 1000, or off") errNotPositive = errors.New("must be more than zero, or off") errEmptyItem = errors.New("has an empty item in its list") errNotNetblock = errors.New( @@ -90,6 +101,9 @@ func FromEnvironment(lookupEnv func(string) (string, bool)) (*Config, error) { UpstreamResponseTimeout: env.duration("SWWAF_UPSTREAM_RESPONSE_TIMEOUT", "30m"), RequestMaxBytes: env.size("SWWAF_REQUEST_MAX_BYTES", "100M"), ResponseMaxBytes: env.size("SWWAF_RESPONSE_MAX_BYTES", "5G"), + RateLimitPerMinute: env.count("SWWAF_RATE_LIMIT_PER_MINUTE", "1000"), + RateLimitPerHour: env.count("SWWAF_RATE_LIMIT_PER_HOUR", "10000"), + RateLimitPerDay: env.count("SWWAF_RATE_LIMIT_PER_DAY", "50000"), } if env.err != nil { @@ -179,6 +193,14 @@ func (e *environment) size(name, defaultValue string) int64 { return size } +// count reads a setting that is a number of requests. +func (e *environment) count(name, defaultValue string) int64 { + count, err := parseCount(e.value(name, defaultValue)) + e.check(name, err) + + return count +} + // parseDuration reads a duration in Go's syntax, such as 90s or 15m, a // whole number of days such as 7d, or off. func parseDuration(value string) (time.Duration, error) { @@ -249,6 +271,24 @@ func splitUnit(value string) (string, int64) { } } +// parseCount reads a whole number of requests, or off. +func parseCount(value string) (int64, error) { + if value == off { + return 0, nil + } + + n, err := strconv.ParseInt(value, 10, 64) + if err != nil { + return 0, fmt.Errorf("%q %w", value, errNotCount) + } + + if n <= 0 { + return 0, fmt.Errorf("%q %w", value, errNotPositive) + } + + return n, nil +} + // parseList splits a comma-separated list and trims the spaces around // each item. An empty value is an empty list. func parseList(value string) ([]string, error) { diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 964ffa1..83a910b 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -25,9 +25,12 @@ const ( upstreamResponseTimeout = "SWWAF_UPSTREAM_RESPONSE_TIMEOUT" requestMaxBytes = "SWWAF_REQUEST_MAX_BYTES" responseMaxBytes = "SWWAF_RESPONSE_MAX_BYTES" + rateLimitPerMinute = "SWWAF_RATE_LIMIT_PER_MINUTE" + rateLimitPerHour = "SWWAF_RATE_LIMIT_PER_HOUR" + rateLimitPerDay = "SWWAF_RATE_LIMIT_PER_DAY" ) -// off switches a timeout or a size limit off. +// off switches a timeout, a size limit or a rate limit off. const off = "off" // environment is a set of environment variables, for FromEnvironment. @@ -65,6 +68,9 @@ func TestDefaults(t *testing.T) { UpstreamResponseTimeout: 30 * time.Minute, RequestMaxBytes: 100 << 20, ResponseMaxBytes: 5 << 30, + RateLimitPerMinute: 1000, + RateLimitPerHour: 10000, + RateLimitPerDay: 50000, }) if cfg.UpstreamURL.String() != "http://127.0.0.1:8081" { @@ -88,6 +94,9 @@ func TestValuesAsSet(t *testing.T) { upstreamResponseTimeout: off, requestMaxBytes: "512K", responseMaxBytes: "1234", + rateLimitPerMinute: "60", + rateLimitPerHour: "600", + rateLimitPerDay: "6000", }) wantSettings(t, cfg, config.Config{ @@ -98,6 +107,9 @@ func TestValuesAsSet(t *testing.T) { UpstreamResponseTimeout: 0, RequestMaxBytes: 512 << 10, ResponseMaxBytes: 1234, + RateLimitPerMinute: 60, + RateLimitPerHour: 600, + RateLimitPerDay: 6000, }) if cfg.UpstreamURL.String() != "https://app.internal:8443/" { @@ -123,6 +135,22 @@ func TestSizesAndOff(t *testing.T) { } } +func TestRateLimitsOff(t *testing.T) { + t.Parallel() + + cfg := fromEnvironment(t, environment{ + rateLimitPerMinute: off, + rateLimitPerHour: off, + rateLimitPerDay: off, + }) + + if cfg.RateLimitPerMinute != 0 || cfg.RateLimitPerHour != 0 || + cfg.RateLimitPerDay != 0 { + t.Errorf("off read as %d, %d and %d", + cfg.RateLimitPerMinute, cfg.RateLimitPerHour, cfg.RateLimitPerDay) + } +} + func TestTrustedProxiesSetButEmptyTrustNothing(t *testing.T) { t.Parallel() @@ -164,6 +192,12 @@ func TestInvalidValueStopsTheStart(t *testing.T) { {responseMaxBytes, "0"}, {responseMaxBytes, "-5"}, {responseMaxBytes, "99999999999G"}, + {rateLimitPerMinute, ""}, + {rateLimitPerMinute, "1K"}, + {rateLimitPerHour, "0"}, + {rateLimitPerHour, "1.5"}, + {rateLimitPerDay, "-1"}, + {rateLimitPerDay, "lots"}, } { t.Run(tc.name+"="+tc.value, func(t *testing.T) { t.Parallel() @@ -208,6 +242,9 @@ func TestLogsEachSettingWithItsValue(t *testing.T) { upstreamResponseTimeout: "30m", requestMaxBytes: "100M", responseMaxBytes: "5G", + rateLimitPerMinute: "1000", + rateLimitPerHour: "10000", + rateLimitPerDay: "50000", } if !maps.Equal(line.Settings, want) { t.Errorf("logged settings\n%v\nwant\n%v", line.Settings, want) @@ -224,7 +261,10 @@ func wantSettings(t *testing.T, got *config.Config, want config.Config) { got.UpstreamRequestTimeout != want.UpstreamRequestTimeout || got.UpstreamResponseTimeout != want.UpstreamResponseTimeout || got.RequestMaxBytes != want.RequestMaxBytes || - got.ResponseMaxBytes != want.ResponseMaxBytes { + got.ResponseMaxBytes != want.ResponseMaxBytes || + got.RateLimitPerMinute != want.RateLimitPerMinute || + got.RateLimitPerHour != want.RateLimitPerHour || + got.RateLimitPerDay != want.RateLimitPerDay { t.Errorf("settings\n%+v\nwant\n%+v", got, want) } } diff --git a/internal/proxy/client.go b/internal/proxy/client.go index 1f9f1e9..d8b247a 100644 --- a/internal/proxy/client.go +++ b/internal/proxy/client.go @@ -48,6 +48,20 @@ func clientAddress( return client } +// ipv6GroupPrefix is the length of the IPv6 netblock that is one client. +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. +func clientGroup(addr netip.Addr) netip.Prefix { + if addr.Is6() { + return netip.PrefixFrom(addr, ipv6GroupPrefix).Masked() + } + + return netip.PrefixFrom(addr, addr.BitLen()) +} + // isInside reports whether addr is in one of the netblocks. func isInside(addr netip.Addr, netblocks []netip.Prefix) bool { return slices.ContainsFunc(netblocks, func(netblock netip.Prefix) bool { diff --git a/internal/proxy/proxy.go b/internal/proxy/proxy.go index 327ba81..a650c11 100644 --- a/internal/proxy/proxy.go +++ b/internal/proxy/proxy.go @@ -11,6 +11,7 @@ import ( "time" "sneak.berlin/go/smallwebwaf/internal/config" + "sneak.berlin/go/smallwebwaf/internal/ratelimit" ) // The request line and headers a client may send, and how long a @@ -57,6 +58,11 @@ func New(params Params) *http.Server { processLog: params.ProcessLog, errorLog: errorLog, transport: newTransport(), + limiter: ratelimit.New(ratelimit.Limits{ + PerMinute: params.Config.RateLimitPerMinute, + PerHour: params.Config.RateLimitPerHour, + PerDay: params.Config.RateLimitPerDay, + }), }, ReadHeaderTimeout: params.Config.ClientRequestTimeout, IdleTimeout: clientIdleTimeout, @@ -73,6 +79,7 @@ type handler struct { processLog *slog.Logger errorLog *log.Logger transport http.RoundTripper + limiter *ratelimit.Limiter } // newTransport returns what carries requests to the app. It never goes diff --git a/internal/proxy/proxy_test.go b/internal/proxy/proxy_test.go index 9a37a40..7b4ebab 100644 --- a/internal/proxy/proxy_test.go +++ b/internal/proxy/proxy_test.go @@ -44,6 +44,7 @@ const ( requestMaxBytes = "SWWAF_REQUEST_MAX_BYTES" responseMaxBytes = "SWWAF_RESPONSE_MAX_BYTES" trustedProxies = "SWWAF_TRUSTED_PROXIES" + rateLimitPerMinute = "SWWAF_RATE_LIMIT_PER_MINUTE" ) // output collects what smallwebwaf writes on stdout. diff --git a/internal/proxy/ratelimits_test.go b/internal/proxy/ratelimits_test.go new file mode 100644 index 0000000..f481218 --- /dev/null +++ b/internal/proxy/ratelimits_test.go @@ -0,0 +1,62 @@ +package proxy_test + +import ( + "net/http" + "sync/atomic" + "testing" + + "sneak.berlin/go/smallwebwaf/internal/requestlog" +) + +func TestRateLimitRefusesWith429BeforeTheApp(t *testing.T) { + t.Parallel() + + var calls atomic.Int32 + + app := startApp(t, func(http.ResponseWriter, *http.Request) { + calls.Add(1) + }) + addr, out := startProxy(t, app.URL, map[string]string{ + trustedProxies: trustLocalhost, + rateLimitPerMinute: "1", + }) + + // 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. + requests := []struct { + client string + 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}, + } + + for i, sent := range requests { + req := newRequest(t, http.MethodGet, addr, "/", http.NoBody) + req.Header.Set(forwardedFor, sent.client) + 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 sent.want == http.StatusOK { + wantLine(t, line, http.StatusOK, requestlog.ActionForward) + } else { + wantLine(t, line, http.StatusTooManyRequests, requestlog.ActionRateLimited) + + if line.LimitHit != "minute" { + t.Errorf("log line has limit_hit %q, want minute", line.LimitHit) + } + } + } + + if calls.Load() != 4 { + t.Errorf("the app was called %d times, want 4", calls.Load()) + } +} diff --git a/internal/proxy/request.go b/internal/proxy/request.go index bf73130..6520d6b 100644 --- a/internal/proxy/request.go +++ b/internal/proxy/request.go @@ -38,6 +38,7 @@ type request struct { body *requestBody // nil for a request without a body line requestlog.Line + client netip.Addr peer netip.Addr peerTrusted bool start time.Time @@ -75,6 +76,7 @@ func (h *handler) newRequest(w http.ResponseWriter, r *http.Request) *request { in: r, rc: http.NewResponseController(w), out: &responseWriter{ResponseWriter: w}, + client: client, peer: peer, peerTrusted: isInside(peer, trusted), start: start, @@ -100,10 +102,20 @@ func (h *handler) newRequest(w http.ResponseWriter, r *http.Request) *request { } // check is the one place where a request can be refused once its client -// is known, before its body is read or anything reaches the app; the rate -// limits and country lists of milestone 2 go here. It returns nil to let -// the request through. +// is known, before its body is read or anything reaches the app. It +// returns nil to let the request through. The rate limits come first, so +// that every request is counted, one refused for its size too. func (rq *request) check() *refusal { + limitHit := rq.h.limiter.Count(clientGroup(rq.client), rq.start) + if limitHit != "" { + rq.line.LimitHit = limitHit + + return &refusal{ + status: http.StatusTooManyRequests, + action: requestlog.ActionRateLimited, + } + } + maxBytes := rq.h.config.RequestMaxBytes if maxBytes > 0 && rq.in.ContentLength > maxBytes { return &refusal{ diff --git a/internal/ratelimit/ratelimit.go b/internal/ratelimit/ratelimit.go new file mode 100644 index 0000000..3396c18 --- /dev/null +++ b/internal/ratelimit/ratelimit.go @@ -0,0 +1,132 @@ +// Package ratelimit counts each client's requests over a minute, an hour +// and a day, as the "Counting method" section of SPEC.md describes, and +// tells when a request takes a client over a rate limit. The counts are +// kept in memory only, for at most 20,000 clients. +package ratelimit + +import ( + "net/netip" + "sync" + "time" + + "github.com/hashicorp/golang-lru/v2/simplelru" +) + +// maxClients is how many clients are kept. Past it, the least recently +// seen client is dropped, and starts afresh if it comes back. +const maxClients = 20000 + +const day = 24 * time.Hour + +// Limits are the most requests a client may make in a minute, an hour and +// a day. Zero is no limit. +type Limits struct { + PerMinute int64 + PerHour int64 + PerDay int64 +} + +// Limiter counts each client's requests against the limits. It is safe +// for concurrent use. +type Limiter struct { + windows [3]window + + mu sync.Mutex + // clients holds each client's buckets, one pair for each of windows, + // in the same order. + clients *simplelru.LRU[netip.Prefix, *[3]buckets] +} + +// New returns a Limiter for limits, with no client counted yet. +func New(limits Limits) *Limiter { + clients, err := simplelru.NewLRU[netip.Prefix, *[3]buckets](maxClients, nil) + if err != nil { + panic(err) // NewLRU fails only for a size below one + } + + return &Limiter{ + windows: [3]window{ + {name: "minute", length: time.Minute, limit: limits.PerMinute}, + {name: "hour", length: time.Hour, limit: limits.PerHour}, + {name: "day", length: day, limit: limits.PerDay}, + }, + clients: clients, + } +} + +// Count counts a request from client at now, in every window, whether or +// not it is refused. It returns the window whose limit the request takes +// the client over, "minute", "hour" or "day", the shortest if it is over +// several, or "" if it is within every limit. +func (l *Limiter) Count(client netip.Prefix, now time.Time) string { + l.mu.Lock() + defer l.mu.Unlock() + + counts, seen := l.clients.Get(client) + if !seen { + counts = &[3]buckets{} + l.clients.Add(client, counts) + } + + limitHit := "" + + for i, w := range l.windows { + requests := counts[i].add(now, w.length) + if limitHit == "" && w.limit > 0 && requests > float64(w.limit) { + limitHit = w.name + } + } + + return limitHit +} + +// window is a length of time over which requests are counted, and the +// most requests a client may make in it. +type window struct { + name string + length time.Duration + limit int64 +} + +// buckets are a client's two buckets in one window: the requests in the +// bucket under way, which began at start, and in the bucket before it. +type buckets struct { + start time.Time + current int64 + previous int64 +} + +// add counts a request at now in a window of length, and returns the +// client's requests in the window that ends at now: those in the bucket +// under way, and those in the bucket before it weighted by how much of +// that bucket the window still covers. +// +// Concurrent requests can be counted out of order, so now can be a moment +// before the bucket under way began; such a request is counted in that +// bucket. A request dated more than a second before it means the clock +// was set back, and the buckets start afresh: otherwise the bucket before +// would keep its full weight until the clock caught up. +func (b *buckets) add(now time.Time, length time.Duration) float64 { + if now.Before(b.start.Add(-time.Second)) { + *b = buckets{} + } + + start := now.Truncate(length) + if start.After(b.start) { + if start.Equal(b.start.Add(length)) { + b.previous = b.current + } else { + b.previous = 0 + } + + b.start = start + b.current = 0 + } + + b.current++ + + elapsed := max(now.Sub(b.start), 0) + covered := 1 - float64(elapsed)/float64(length) + + return float64(b.previous)*covered + float64(b.current) +} diff --git a/internal/ratelimit/ratelimit_test.go b/internal/ratelimit/ratelimit_test.go new file mode 100644 index 0000000..58ae89d --- /dev/null +++ b/internal/ratelimit/ratelimit_test.go @@ -0,0 +1,200 @@ +package ratelimit_test + +import ( + "net/netip" + "testing" + "time" + + "sneak.berlin/go/smallwebwaf/internal/ratelimit" +) + +// limit is the limit the tests set. +const limit = 3 + +// The windows, as Count names them. +const ( + minute = "minute" + hour = "hour" +) + +func TestEachWindowRefusesAtItsLimitAndLetsTheClientBack(t *testing.T) { + t.Parallel() + + for _, tc := range []struct { + window string + limits ratelimit.Limits + length time.Duration + }{ + {minute, ratelimit.Limits{PerMinute: limit}, time.Minute}, + {hour, ratelimit.Limits{PerHour: limit}, time.Hour}, + {"day", ratelimit.Limits{PerDay: limit}, 24 * time.Hour}, + } { + t.Run(tc.window, func(t *testing.T) { + t.Parallel() + + limiter := ratelimit.New(tc.limits) + client := netip.MustParsePrefix("203.0.113.9/32") + start := midnight() + quarter := tc.length / 4 + + for range limit { + wantCount(t, limiter, client, start, "") + } + + wantCount(t, limiter, client, start, tc.window) + + // A quarter into the next bucket, the window still covers three + // quarters of the bucket before, with its four requests: 3 + 1 + // is over the limit. + wantCount(t, limiter, client, start.Add(tc.length+quarter), tc.window) + + // Three quarters into it, a quarter: 1 + 2 is within. + wantCount(t, limiter, client, start.Add(tc.length+3*quarter), "") + }) + } +} + +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() + + limiter := ratelimit.New(ratelimit.Limits{PerMinute: limit, PerHour: 2 * limit}) + refused := netip.MustParsePrefix("203.0.113.9/32") + within := netip.MustParsePrefix("203.0.113.10/32") + start := midnight() + + for range limit { + wantCount(t, limiter, refused, start, "") + wantCount(t, limiter, within, start, "") + } + + for range limit { + wantCount(t, limiter, refused, start, minute) + } + + // Half a minute into the next bucket the window covers half of the + // bucket before: 3 + 1 is over the minute's limit for the client + // whose three refused requests count, and 1.5 + 1 within it for the + // other. The first is over the hour's limit too, and the shorter + // window is named. + halfway := start.Add(time.Minute + time.Minute/2) + wantCount(t, limiter, refused, halfway, minute) + wantCount(t, limiter, within, halfway, "") + + // The refused requests count in the hour as well: 6 + 1 + 1 is over + // its limit, and 3 + 1 + 1 within it. + later := start.Add(10 * time.Minute) + wantCount(t, limiter, refused, later, hour) + wantCount(t, limiter, within, later, "") +} + +func TestRequestCountedLateGoesInTheBucketUnderWay(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, "") + } + + // A concurrent request dated a moment before the bucket under way, but + // counted after it began, is counted in it: 3 + 1 is over the limit. + wantCount(t, limiter, client, start.Add(-time.Millisecond), minute) +} + +func TestClockSetBackStartsTheBucketsAfresh(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, "") + } + + // Half an hour into the next bucket: 3 / 2 + 1 is within the limit. + wantCount(t, limiter, client, start.Add(time.Hour+time.Hour/2), "") + + // The clock is set back an hour. Counted in the bucket under way, the + // next request would find the bucket before it at full weight, 3 + 2, + // over the limit until the clock caught up. The buckets start afresh + // instead, and the client is refused only past the limit again. + setBack := start.Add(time.Hour / 2) + for range limit { + wantCount(t, limiter, client, setBack, "") + } + + wantCount(t, limiter, client, setBack, hour) +} + +func TestKeepsAtMost20000ClientsDroppingTheLeastRecentlySeen(t *testing.T) { + t.Parallel() + + const maxClients = 20000 + + limiter := ratelimit.New(ratelimit.Limits{PerMinute: 1}) + now := midnight() + + clients := make([]netip.Prefix, maxClients+1) + addr := netip.MustParseAddr("10.0.0.0") + + for i := range clients { + clients[i] = netip.PrefixFrom(addr, addr.BitLen()) + addr = addr.Next() + } + + for _, client := range clients[:maxClients] { + wantCount(t, limiter, client, now, "") + } + + // The first client is seen again: its second request is over the + // limit of one, so it is still counted. + wantCount(t, limiter, clients[0], now, minute) + + // One client more drops the least recently seen, the second, which + // starts afresh, while the first is kept. + wantCount(t, limiter, clients[maxClients], now, "") + wantCount(t, limiter, clients[1], now, "") + wantCount(t, limiter, clients[0], now, minute) +} + +// midnight is the start of a bucket in every window. +func midnight() time.Time { + return time.Date(2026, 10, 4, 0, 0, 0, 0, time.UTC) +} + +// wantCount counts a request from client at now, and checks the window +// whose limit it goes over, "" for none. +func wantCount( + t *testing.T, limiter *ratelimit.Limiter, client netip.Prefix, now time.Time, + want string, +) { + t.Helper() + + got := limiter.Count(client, now) + if got != want { + t.Errorf("request from %s at %s is over %q, want %q", + client, now.Format(time.RFC3339), got, want) + } +} diff --git a/internal/requestlog/requestlog.go b/internal/requestlog/requestlog.go index 07f074b..c31b9d9 100644 --- a/internal/requestlog/requestlog.go +++ b/internal/requestlog/requestlog.go @@ -23,6 +23,9 @@ const ( // ActionUpstreamError is a request the app could not be reached // for, or whose answer could not be passed on. ActionUpstreamError = "upstream_error" + // ActionRateLimited is a request refused because it took its client + // over a rate limit, or came while the client was over one. + ActionRateLimited = "rate_limited" ) // timeLayout is RFC 3339 with milliseconds. @@ -49,6 +52,9 @@ type Line struct { Referer string `json:"referer"` UserAgent string `json:"user_agent"` Action string `json:"action"` + // LimitHit is the window whose rate limit the request went over: + // minute, hour or day. + LimitHit string `json:"limit_hit,omitempty"` // Aborted is true when the client went away early. Aborted bool `json:"aborted,omitempty"` // DurationTotal and DurationUpstreamTotal are in milliseconds. diff --git a/internal/requestlog/requestlog_test.go b/internal/requestlog/requestlog_test.go index 84fba56..a2d0003 100644 --- a/internal/requestlog/requestlog_test.go +++ b/internal/requestlog/requestlog_test.go @@ -49,7 +49,9 @@ func TestWriteWritesOneJSONLineMarkedRequest(t *testing.T) { } } - unset := []string{"upstream_status", "aborted", "duration_upstream_total"} + unset := []string{ + "upstream_status", "limit_hit", "aborted", "duration_upstream_total", + } for _, name := range unset { _, present := fields[name] if present { diff --git a/internal/smallwebwaf/smallwebwaf_test.go b/internal/smallwebwaf/smallwebwaf_test.go index 2013071..fb7341b 100644 --- a/internal/smallwebwaf/smallwebwaf_test.go +++ b/internal/smallwebwaf/smallwebwaf_test.go @@ -185,6 +185,9 @@ func wantStartingLine(t *testing.T, line map[string]any, appURL string) { "SWWAF_UPSTREAM_RESPONSE_TIMEOUT": "30m", "SWWAF_REQUEST_MAX_BYTES": "100M", "SWWAF_RESPONSE_MAX_BYTES": "5G", + "SWWAF_RATE_LIMIT_PER_MINUTE": "1000", + "SWWAF_RATE_LIMIT_PER_HOUR": "10000", + "SWWAF_RATE_LIMIT_PER_DAY": "50000", } for name, value := range want {