- 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
3.9 KiB
title, description, section, order
| title | description | section | order |
|---|---|---|---|
| Rate limiting | Throttle routes with inline limits or named buckets, key them by user or client IP, and trust X-Forwarded-For only from configured proxies. | services | 40 |
Rate limiting
Laravel limits requests with the throttle middleware and named limiters from RateLimiter::for. SummerCMS has both through surf: 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 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>:
// 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:
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:
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.