Files
summercms/docs/services/rate-limiting.md
Jakub Zych efb35a2d35 feat(11.1-04): add the Database section and the core Services pages
- docs/database: models, migrations, queries and pagination, relations,
  casts and validation, attachments and transactions (lagoon.Transaction,
  lagoon.AfterCommit, nested savepoints, lagoon.OnDatabase)
- docs/services: configuration, events, routing with auth groups, rate
  limiting, authentication, the OAuth server, mail and localization
- runnable Examples for lagoon, attach, compass, surf, wire, bouncer,
  wristband, postcard, phrasebook and festival; lagoon TestDocs* regions
  run on the package's Postgres harness through DocsDB
- 15 new required pages
2026-09-30 22:59:25 +02:00

74 lines
3.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. Counters live in the process (`surf.MemoryStore`, behind the `surf.Store` interface), so each application instance counts on its own. Behind a load balancer with several instances, the effective limit is the configured limit times the number of instances.
## 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`.