Client Addresses and Rate Limiting¶
SystemForge finds a request's client address with one rule, and limits
requests in layers built on that address. Both are plain net/http
middleware in session/clientaddr and session/ratelimit; SystemAuth
applies them itself, and an application applies them to its own routes.
The client address¶
A request's direct peer is the only address a client cannot choose. Behind a
reverse proxy the peer is the proxy, and the client's address is in a header
the proxy writes. A clientaddr.Resolver believes that header only when the
peer is one of the trusted proxy networks; from any other peer the header
is ignored, because any client can send it.
res, err := clientaddr.New(clientaddr.Config{
TrustedProxies: []string{"127.0.0.1/32", "::1/128"}, // the proxy on this host
Header: clientaddr.HeaderXForwardedFor, // the default
})
ip := res.ClientIP(r)
Two kinds of header are read:
X-Forwarded-For(the default) is read from the right. Each trusted proxy appends the peer it saw, so the first address from the right that is not a trusted proxy is the client. Entries a client wrote at the left are never reached. An entry that is not an address stops the walk, and the peer is used.- A single-value header, such as
CF-Connecting-IP,True-Client-IPorX-Real-IP, holds one address set by the trusted proxy. A list or a repeated header is not believed.
Ports (203.0.113.9:443, [2001:db8::1]:443) are removed, and IPv4-mapped
IPv6 addresses are unmapped. Without trusted networks, the zero Config,
every request is attributed to its peer.
A typical deployment¶
A CDN or edge proxy, then a reverse proxy on the host, then the application
on loopback. The reverse proxy sets X-Forwarded-For to the single client
address it took from the edge's header, so the application trusts loopback
and reads X-Forwarded-For (clientaddr.LoopbackProxy()). In SystemAuth's
configuration:
The reverse proxy must replace X-Forwarded-For rather than append a
client-supplied value to it, and the origin must accept traffic only from the
edge (see the production checklist).
When the application is reached directly from the edge instead, trust the
edge's published networks and read its single-value header.
Who uses it¶
| Component | Setting |
|---|---|
| SystemAuth | client_address in the configuration, or systemauth.WithClientAddressResolver |
BFF (session/bff) |
ClientIPConfig.Resolver |
appauth |
Options.ClientAddress (returned by Authenticator.ClientAddress) |
| Rate limits | ratelimit.PolicyConfig.Address, ratelimit.AddressKey |
| API key middleware | APIKeyMiddlewareConfig.IPExtractor: res.ClientIP |
| SCIM tokens | scimtoken.WithClientAddress |
Share one Resolver so every part of an application agrees on who a client
is. The older settings (ratelimit.ForwardedClientIP, ForwardedIPKey,
systemauth.WithLoginTokenClientIP, bff.CloudflareClientIPConfig and the
ClientIPConfig trust fields) still work and are deprecated.
Layers¶
| Layer | Where | Keyed by | Default |
|---|---|---|---|
| Default | outermost, every route, before authentication | client address | 300 a minute |
| Route policies | same middleware, matching paths | address, or OAuth client | see below |
| Signed-in | after authentication | principal or service account | 1200 a minute |
| Handler limits | inside handlers | what only the handler knows | see below |
Default layer and route policies¶
ratelimit.Policy applies the default limit and the route table in one
middleware. Install it outermost:
storage := ratelimit.NewMemoryStorage() // one instance; RedisStorage for several
policy, err := ratelimit.NewPolicy(ratelimit.PolicyConfig{
Storage: storage,
Address: res,
Routes: ratelimit.MergeRoutes(ratelimit.DefaultRoutes(), ratelimit.Route{
Name: ratelimit.RouteLogin, Paths: []string{"/login*"},
Limits: []ratelimit.RouteLimit{{Key: ratelimit.KeyAddress, Limit: ratelimit.PerMinute(60)}},
}),
ExemptNetworks: []string{"10.20.0.0/16"}, // back-channel callers
})
if err != nil {
return err
}
handler := policy.Middleware()(mux)
The default route table (ratelimit.DefaultRoutes):
| Name | Paths | Limits |
|---|---|---|
token |
/oauth/token |
120 a minute per address; 1200 a minute per client |
login |
/login* |
120 a minute per address |
signup |
/signup* |
60 a minute per address |
bff_auth |
/bff/auth/* |
120 a minute per address |
health |
/healthz, /readyz, /livez, /health |
exempt |
static |
/static/*, /assets/*, /favicon.ico, /robots.txt |
exempt |
payment_webhook |
/billing/webhook |
exempt: the provider calls from shared addresses |
A path ending in * is a prefix; the longest matching pattern wins. Routes
are matched below the mount point when the policy runs inside a mounted chi
router. Replace an entry by name with MergeRoutes, or add new ones. The
client key reads the HTTP Basic user name or the client_id form field
without consuming the body; with PolicyConfig.KnownClient, client IDs that
are not registered share one bucket, so made-up IDs cannot grow the storage.
A policy inside another one (an embedded SystemAuth under an application's
policy) does nothing, so a request is never counted twice.
Signed-in callers¶
signedIn := ratelimit.NewSignedInLimiter(storage, ratelimit.PerMinute(1200))
api := authMiddleware(signedIn.Middleware()(apiHandler))
It keys on the principal requestprincipal or the JWT middleware put in the
context, and lets anonymous requests through. With appauth, pass it as
Options.SignedInRateLimit and Authenticator.Middleware applies it after
authentication.
Handler limits¶
Some limits need the request's outcome, or a key only the handler knows.
They use the same storage through ratelimit.Check, or Exhausted and
Record for limits that count only some outcomes. SystemAuth applies:
| Limit | Default |
|---|---|
Failed client authentication at /oauth/token, per address and client |
20 a minute; then the pair is refused until the window ends |
| Failed client authentication, per address | 60 a minute |
| Tokens the JWT-bearer grant issues, per service account | 60 a minute |
| Social login start, and separately the callback, per address | 30 a minute |
| Login-token exchange, per address and per registered client | 60 and 600 a minute |
| Sign-up waitlist, per address and per submitted email | 20 a minute, 5 an hour |
| Sign-up code page, per address | 30 a minute |
| Claim codes and manager invitations, per caller | managedprincipal.DefaultCodeLimit |
Responses¶
A refused request gets 429 Too Many Requests with Retry-After in whole
seconds, rounded up and never below 1. Every checked response carries the
IETF RateLimit-Policy and RateLimit headers, one item per limit checked,
and the older X-RateLimit-* headers:
RateLimit-Policy: "default";q=300;w=60, "login";q=120;w=60
RateLimit: "default";r=297;t=41, "login";r=118;t=41
X-RateLimit-Limit: 120
X-RateLimit-Remaining: 118
X-RateLimit-Reset: 1767225600
Storage outages¶
When the storage fails, the default layer and the signed-in limit let the
request through and log the failure; the route policies and the handler
limits of the credential endpoints (token, login, sign-up) refuse with 503.
Set PolicyConfig.DefaultFailClosed, or Route.FailOpen, to change either.
A full MemoryStorage (ErrTooManyKeys) is always a 429, so a flood of
new addresses cannot switch limits off.
Storage and instances¶
Use ratelimit.RedisStorage when several instances serve the same routes,
and MemoryStorage with one. Memory counters with several instances let each
instance allow the full limit. The packaged SystemAuth service warns at
startup in that case unless one instance is declared; it does not refuse to
start, because the limits are looser but no check is bypassed (see
Deploying SystemAuth).
SystemAuth configuration¶
client_address:
trusted_proxies: ["127.0.0.1/32", "::1/128"]
rate_limit:
redis_url: ${SYSTEMAUTH_RATE_LIMIT_REDIS_URL} # several instances
# single_instance: true # or one instance
default: {requests: 300, period: 1m}
signed_in: {requests: 1200, period: 1m}
exempt_networks: ["10.20.0.0/16"]
routes:
- name: token
paths: ["/oauth/token"]
per_address: {requests: 60, period: 1m}
per_client: {requests: 600, period: 1m}
rate_limit.disabled: true turns off the default layer and the route
policies, for a host that applies its own ratelimit.Policy in front of
SystemAuth; the handler limits stay on. An embedding application sets the
storage with systemauth.WithRateLimitStorage, or
systemauthsvc.Options.RateLimitStorage, and reads it back with
Server.RateLimitStorage for its own handler limits.