Table of contents
Before writing any middleware, answer a different question: is something in front
of this process already doing it? A service that implements everything itself
carries code its platform already runs, and occasionally code that fights it: two
components both answering a Cross-Origin Resource Sharing (CORS) preflight send
two Access-Control-Allow-Origin headers, and a browser rejects that.
The boundary
| Concern | Usually lives at | What the service still owns |
|---|---|---|
| Transport Layer Security (TLS) termination | The edge | Supporting TLS for direct and mesh-internal exposure, and knowing which mode it is in |
| Response compression | The edge | Not compressing twice, and knowing which end negotiates |
| Rate limiting | Gateway, ingress, content delivery network (CDN) or web application firewall (WAF) | A last-resort limit for direct and internal callers |
| CORS and preflight | The gateway, when there is one | Answering preflight when there is not |
| Request body caps | The ingress, commonly | Its own cap, because the edge cap does not cover internal callers |
| Client Internet Protocol (IP) address | The proxy sets the header; the platform decides the trusted hop depth | Being told the hop count rather than guessing it |
| Authentication | Frequently an edge concern | Reading an established identity, per page 17 |
The diagram runs top to bottom in rejection order, and the rule it encodes is
cheapest rejection first: parsing a 10MB body before finding the caller is over
its rate limit is work done for nothing. The edge lane holds TLS termination and
compression, a body cap answering 413, a rate limit answering 429 with
Retry-After, and X-Forwarded-For being set. The service lane holds
MaxBytesHandler, the trace ID and per-request logger, a preflight answered with
204 only if the edge did not, a per-instance rate limit, client IP resolution at
the configured depth, authentication answering 401 or 403, and route matching
answering 404 or 405.
The seam
Page 07 covered what the server builds for you: the body cap, the request logger and the per-route timeout, in that order. Your own attaches at two points, both taking the same function type.
type MiddlewareFn func(args MiddlewareArgs, next http.Handler) http.Handler
WithMiddlewareFn adds a layer to every route on a server; the Middleware
field on a Route adds one to a single route. Everything you add sits inside what
the server built, in the order you pass it.
MiddlewareArgs carries the route’s method, path and description, so a middleware
knows which endpoint it wraps without inspecting the request, plus the server’s
logger, trace ID header name and redaction function:
middleware := func(args httpserver.MiddlewareArgs, next http.Handler) http.Handler {
return m.InstrumentHandler(args.Path, next.ServeHTTP)
}
Strip that argument and you have func(http.Handler) http.Handler, which every
other Go middleware already is, so anything written for chi, gorilla or the
standard library wraps into one in two lines.
The three listeners have three separate chains, so the public one can carry a rate limiter the monitoring one does not need.
Ordering
Panic recovery is outermost. It can only catch panics raised inside the handlers it wraps. Here it sits at the router level, outside every middleware, so a panic in your own middleware is still answered with a 500.
Instrumentation wraps the handler, not the router. The metrics middleware
needs the route pattern, /items/{id}, rather than the request path,
/items/019813a0-.... Around the router it would see only the latter, and a
counter labelled with the raw path produces a new time series per item, which is
how a metrics backend falls over. Hence args.Path above, and
page 13 on label cardinality.
A middleware that writes to the response body ends the response for everything outside it. Once a rate limiter has written 429, the handler never runs and anything outside that expected to modify the response cannot. So a middleware either rejects and returns, or delegates and leaves the body alone. Doing both is where “superfluous WriteHeader call” comes from.
Rate limiting
A gateway, ingress, CDN or WAF sees all the traffic where a single instance sees its own share, so the real limit belongs there. Inside the process it is a floor: a public gateway does not cover a caller reaching the private listener directly, or a misbehaving internal client.
The mechanism is a token bucket per client key, and
golang.org/x/time/rate
is the standard answer: a rate.Limiter per key, allocated on first sight, asked
Allow() on each request, wrapped in a MiddlewareFn that refuses with 429 and
a Retry-After header. Closing the connection instead tells a well-behaved
client nothing, so it retries immediately.
Page 11 is the same exchange from the other side.
The map of limiters grows. A public endpoint keyed on client IP under a
distributed scan accumulates entries until the process runs out of memory. Sweep
what has not been seen for a while, from a goroutine registered with
page 05’s wait group and shutdown signal, or
hold the limiters in a bounded cache such as
sfcache, at the cost of dropping one for an active
client.
It does not compose across replicas. Ten requests per second per client on six replicas is sixty, and 120 when the autoscaler doubles them, with nobody changing the configuration. Coordinating through Redis or Valkey costs a round trip per request and adds a dependency whose failure has to be decided in advance: fail open and lose the limit, or fail closed and lose the service. The first is usually right.
Set the in-process limit well above the edge limit, and read a 429 in your own logs as evidence that the edge was bypassed.
CORS
Exactly one component must own it, and where there is a gateway, that is it.
A browser refuses to let JavaScript on app.example.com read a response from
api.example.com unless the response says it may, and for anything beyond a
simple GET it asks first with an OPTIONS preflight carrying Origin and
Access-Control-Request-Method.
MDN’s CORS guide
is the reference for which headers each side sends. The exchange is advisory, so
Access-Control-Allow-Origin protects your users’ browsers and offers nothing
against a script with curl.
Use a maintained package rather than the header logic by hand: three details are
easy to get wrong and each fails only in a browser. Echo the origin from an
allowlist instead of reflecting whatever arrived, or the policy permits
credentialed requests from any site. Send Vary: Origin, or a shared cache serves
one origin’s headers to another. Treat an OPTIONS request as a preflight only
when Access-Control-Request-Method is present, or you break clients that use
OPTIONS for discovery.
The routing wrinkle. A preflight arrives as OPTIONS /items on a route
registered as POST /items, and whether the chain sees it depends on the
router. httprouter answers OPTIONS before the middleware chain runs, so the
preflight never reaches your code. Where it does not answer, the request lands on
the method-not-allowed handler from page 07 and
returns a 405 with no CORS headers, so the browser reports a CORS failure that
looks nothing like a routing problem. Register an explicit OPTIONS route per
path, or handle CORS outside the chain, and verify in a browser.
Request body limits
The ingress commonly caps bodies at around 1MB, which covers only traffic arriving through it, and not a caller on the private listener or a request in an integration test. Without a cap in the process, a single POST can make the service allocate until the container’s memory limit kills it: a denial of service costing the attacker one request. Of the four concerns here, this is the one where the in-process implementation is required rather than merely defensible.
The server-wide cap installs
http.MaxBytesHandler
as the outermost middleware, ahead of the logger so that a debug request dump
cannot buffer an unbounded body in order to log it:
httpserver.WithMaxRequestBodyBytes(1 << 20) // 1MiB
Per-endpoint, a handler wraps r.Body in http.MaxBytesReader before decoding,
sized to what that endpoint accepts, four kilobytes for an item. The server-wide
one is a backstop in megabytes; the tighter one stops an attacker using a
legitimate endpoint to allocate a megabyte at a time.
Page 15 sizes one and shows the
decode. An over-sized body then fails inside Decode, and answering it 400
along with every other unreadable body is the short version; matching
*http.MaxBytesError with errors.As first is what earns the client a 413 and
a reason.
When the two caps disagree by an order of magnitude, a payload accepted in a test is refused in production by a component the developer does not control. Write both numbers in the same place.
Client IP, and the trust question
r.RemoteAddr is the address of whatever connected to the socket. Behind a load
balancer that is the load balancer. To get the client you read a header, and a
header is something the client can send.
X-Forwarded-For: 203.0.113.7, 70.41.3.18, 150.172.238.178
The list grows left to right as it passes through proxies, so the leftmost entry
is the oldest. That is why “take the first entry” is wrong: a client can send
X-Forwarded-For: 1.2.3.4 in the original request, and a proxy appends rather than
replaces, so the first entry is whatever the client chose. Rate limiting is then
defeated by a random value per request, audit logs record an attacker-chosen
address, and an IP allowlist is bypassed by claiming an allowed address.
Count from the right instead, discarding as many entries as there are proxies you trust. That hop count is configuration, per page 04, because the process cannot work it out: one ingress means 1, an ingress behind a CDN means 2, and a directly reachable service means 0, which has to mean “ignore the header entirely” rather than “take the last entry”. Trusting too few hops gives you your proxy’s address for every client, which is bad and visible. Trusting too many is silent and exploitable.
Forwarded standardises
the same idea with a different syntax and the same trust model. If your proxy sets
it, parse that instead; the counting rule is unchanged. The count changes when
somebody puts a CDN in front, so it belongs in the same manifest as the topology,
which page 19 does.
What the toolkit contributes
Nothing except the option name and the MiddlewareFn signature. Rate limiting
policy, CORS origins and proxy depth are properties of a deployment, so the
library provides the place to attach them, the order they run in, and the
guarantee that a panic anywhere inside is still answered. The full list of options
is in
the httpserver package documentation,
and the article on its edges covers the built-in
chain.