- new module cooler: Install, Handle, OpenRedis (no URL in errors), RateLimitStore (atomic fixed-window Lua script with MemoryStore semantics, per-instance fallback) - serve installs cooler and publishes the Redis surf.Store before BuildRouter; BuildRouter looks up a published Store, route:list keeps the memory store - config keys http.rate_limit.store and redis.url; go-redis v9.23.0 - cooler README, root table row, surf README and rate-limiting, configuration and scaling docs
99 lines
5.9 KiB
Markdown
99 lines
5.9 KiB
Markdown
---
|
|
title: Rate limiting
|
|
description: Throttle routes with inline limits or named buckets, key them by user or client IP, and trust X-Forwarded-For only from configured proxies.
|
|
section: services
|
|
order: 40
|
|
---
|
|
# Rate limiting
|
|
|
|
Laravel limits requests with the `throttle` middleware and named limiters from `RateLimiter::for`. SummerCMS has both through [surf](../../modules/surf/README.md): a `throttle` middleware that takes an inline limit or the name of a bucket a plugin declares.
|
|
|
|
## Inline limits
|
|
|
|
`throttle:<max>,<minutes>` allows `max` requests per window of `minutes` minutes. The counter is kept per signed-in user, or per client IP for guests. The plugin on [Routing](routing.md) puts `throttle:60,1` on its whole `/api/blog` group, so each client may make 60 requests a minute to it.
|
|
|
|
## Named buckets
|
|
|
|
A bucket gives a limit its own key, such as the client IP plus the route, or a token ID. A plugin declares buckets by implementing `surf.BucketProvider`, and routes name them as `throttle:<bucket>`:
|
|
|
|
```go src=modules/surf/example_test.go#BlogPlugin.Buckets
|
|
// Buckets declares a named rate limit, used as throttle:blog.comments.
|
|
func (p *BlogPlugin) Buckets() map[string]surf.Bucket {
|
|
return map[string]surf.Bucket{
|
|
"blog.comments": {
|
|
Max: 1,
|
|
Decay: time.Minute,
|
|
Key: func(r *http.Request) string { return "comments|" + surf.ClientIP(r, p.trusted) },
|
|
},
|
|
}
|
|
}
|
|
```
|
|
|
|
Each `surf.Bucket` has the maximum number of requests, the window length (`Decay`) and a `Key` function that builds the counter key from the request. Prefix the key with the bucket's purpose, as above, so two buckets never share a counter. A route may name several throttles; each counts separately. A throttle naming a bucket that no plugin declares fails the start-up.
|
|
|
|
A request over the limit gets a 429 response with the body `{"message":"Too Many Attempts."}` and a `Retry-After` header. Every throttled response carries `X-RateLimit-Limit` and `X-RateLimit-Remaining`, and a rejected one also `X-RateLimit-Reset`.
|
|
|
|
The limiter is a fixed window: the counter resets when the window ends, as Laravel's cache limiter does. By default counters live in the process (`surf.MemoryStore`, behind the `surf.Store` interface). With this memory store each application instance counts on its own, so behind a load balancer with several instances the effective limit is the configured limit times the number of instances. [Sharing limits across instances](#sharing-limits-across-instances) moves the counters to Redis.
|
|
|
|
## Sharing limits across instances
|
|
|
|
Laravel shares its limiter counters through the cache store, usually Redis. SummerCMS does the same through [cooler](../../modules/cooler/README.md): set `http.rate_limit.store` to `redis` (the default is `memory`) and point `redis.url` at a Redis every instance reaches.
|
|
|
|
```yaml
|
|
# config/http.yaml
|
|
rate_limit:
|
|
store: redis
|
|
```
|
|
|
|
```yaml
|
|
# config/redis.yaml
|
|
url: redis://10.0.0.7:6379/0
|
|
```
|
|
|
|
Keep a URL that carries a password in the environment instead: `SUMMER_REDIS__URL=redis://:secret@10.0.0.7:6379/0`. `rediss://` connects over TLS and `unix://` through a socket.
|
|
|
|
When `serve` starts, it calls `cooler.Install`, which builds the Redis client and pings it, and publishes a `cooler.RateLimitStore` as the `surf.Store` before the router is built. Routes do not change: `throttle:60,1` and `throttle:<bucket>` count in Redis, with the same fixed-window rules as the memory store. Each attempt is one atomic Lua script, so two instances answering requests from the same client count one window together: with `throttle:2,1`, the third request in the minute gets a 429 whichever instance answers it.
|
|
|
|
Keys are `<prefix>rl:<key>`. The prefix is `cache.prefix`, or by default `app.name` lower-cased with other characters than letters and digits replaced by `_`, followed by `:`, so several applications can share one Redis.
|
|
|
|
When Redis does not answer, each attempt waits at most `cooler.AttemptTimeout` (500 ms) and then counts in a per-instance `surf.MemoryStore`, as if the memory store were selected, until Redis answers again. A warning is logged at most once a minute. Redis being down at start-up is a warning too, not a failed start; a missing or malformed `redis.url` fails the start with an error that names the key and never shows the URL.
|
|
|
|
Only `serve` connects to Redis. `route:list` builds the router with the memory store, and so does `surf.Assemble` in tests, so neither needs a running Redis.
|
|
|
|
## Client IP and trusted proxies
|
|
|
|
`surf.ClientIP` is the one place the client IP comes from. It uses the connection's remote address, and reads `X-Forwarded-For` only when that address is inside a range listed in `http.trusted_proxies`. It then takes the rightmost address that is not itself a trusted proxy, so a client cannot choose its own IP by sending the header:
|
|
|
|
```go src=modules/surf/example_test.go#ExampleClientIP
|
|
cfg, err := compass.Open(compass.Options{Dir: "config", Env: "development", Environ: []string{}})
|
|
if err != nil {
|
|
fmt.Println(err)
|
|
return
|
|
}
|
|
_ = cfg.Set("http.trusted_proxies", []string{"10.0.0.0/8"})
|
|
trusted := surf.TrustedProxies(cfg)
|
|
|
|
// Through the load balancer at 10.0.0.5: the forwarded client is used.
|
|
viaProxy := httptest.NewRequest("GET", "/api/blog/posts", nil)
|
|
viaProxy.RemoteAddr = "10.0.0.5:4711"
|
|
viaProxy.Header.Set("X-Forwarded-For", "198.51.100.23, 10.0.0.9")
|
|
fmt.Println(surf.ClientIP(viaProxy, trusted))
|
|
|
|
// Straight from the internet: a forged header is ignored.
|
|
direct := httptest.NewRequest("GET", "/api/blog/posts", nil)
|
|
direct.RemoteAddr = "203.0.113.7:5000"
|
|
direct.Header.Set("X-Forwarded-For", "127.0.0.1")
|
|
fmt.Println(surf.ClientIP(direct, trusted))
|
|
// Output:
|
|
// 198.51.100.23
|
|
// 203.0.113.7
|
|
```
|
|
|
|
List only the proxies you run, in `config/http.yaml`:
|
|
|
|
```yaml
|
|
trusted_proxies: ["10.0.0.0/8"]
|
|
```
|
|
|
|
With the list empty (the default), the header is ignored and every request behind a proxy has the proxy's IP. A malformed entry is skipped. A bucket's `Key` function should use the same trusted list, as the plugin above does by reading `surf.TrustedProxies` in `Register`.
|