Table of contents
Page 01 said the three listeners are an enforcement mechanism, and there is now a real feature to place.
The question here is narrow: which listener is this endpoint on. Who a caller is and what they may do is a different problem, and the last section says what this guide does about it.
The three zones
Three sockets, three audiences, three networks.
| Listener | Reachable from | Carries |
|---|---|---|
:8073 public | The internet, through the edge | What the product exposes |
:8072 private | The cluster, from peer services | Internal operations |
:8071 monitoring | The operator network only | Health, metrics, profiling |
“Reachable” is the word doing the work. A route on the monitoring server is unreachable from the internet because there is no network path to that socket, which is a different statement from “a middleware rejects it”.
Why a socket beats a path prefix
The alternative arrangement is one listener with a middleware that inspects the path:
// The arrangement this guide argues against.
func internalOnly(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.HasPrefix(r.URL.Path, "/internal/") && !isInternal(r.RemoteAddr) {
http.NotFound(w, r)
return
}
next.ServeHTTP(w, r)
})
}
It works, and it has five silent ways of stopping working.
Path normalisation. /internal/metrics is caught. Whether
//internal/metrics, /Internal/metrics, /x/../internal/metrics or a
percent-encoded variant is caught depends on the router, the proxy in front, and
the order in which each normalises. Every component has to agree, and they are
written by different people.
Middleware ordering. The guard has to run before the router matches. A refactor that moves it after, or registers a route outside the chain, removes the protection with no error and no failing test unless somebody wrote one.
The prefix is a convention. A new endpoint at /admin/purge is unprotected
because its author did not know about the naming rule. Nothing enforces it.
Address checking is guesswork. isInternal(r.RemoteAddr) behind a load
balancer sees the load balancer, so it either always passes or always fails.
Page 08’s client IP problem, in a security
control.
The error handlers. The not-found and panic handlers are registered on the router, outside the middleware chain. A panic inside an internal endpoint answers through a handler the guard never saw.
A socket has none of those failure modes, because the enforcement is not in the code:
err = startServiceServer(ctx, "public", serviceBinderPublic, cfgServer(cfg.Servers.Public), ...)
A route registered on the monitoring server exists only in that server’s router, bound to that server’s socket. There is no request to the public port that reaches it. Not a malformed one, not an encoded one, not one that arrives after a middleware was reordered.
The cost is three ports to configure and three network policies to write, a one-time cost in a deployment manifest. Page 19 writes them.
The guarantee’s limit
Sockets separate only as far as the network agrees. Binding :8071 gives you a
separate socket and does not stop somebody port-forwarding to it. The service
makes the separation possible and the platform enforces it, as page 19 covers.
What must never be public
Four of the six operational routes give something away.
/pprof/* is the worst of them. A heap profile contains allocation sites and
sizes, and the goroutine dump names every function currently executing, which
together describe your internals in detail. A CPU profile request is also a denial
of service: ?seconds=300 makes the service
profile itself for five minutes, and concurrent requests compound.
/metrics lists every route by name, so a scrape enumerates your endpoint
surface including what is not in the public documentation. Counters reveal traffic
volumes and error rates: competitive information for some businesses and
reconnaissance for everyone. Pool metrics tell an attacker how
close you are to your connection limit, the number they need to size an attack.
/status names your dependencies and, on failure, includes the error, typically dial tcp 10.0.3.14:3306: connect: connection refused. That is a
hostname, a private address and a port.
/ is a generated list of every registered route with its description.
Whatever the effort of not documenting an internal endpoint was worth, this
undoes it.
/ip is less severe and still worth withholding: it makes an outbound request to
a third party on demand, so a public one is an unauthenticated proxy to
api.ipify.org at your expense.
/ping is the one that is safe anywhere, so it is the only default route the
private and public servers enable:
httpserver.WithEnableDefaultRoutes(httpserver.PingRoute)
It consults nothing, so it reveals nothing beyond the fact that something is listening, which the open port already revealed.
Placing the item endpoints
The routes from page 14, as the example registers them:
// internal/httphandlerpub/item.go
{Method: http.MethodPost, Path: "/items", Description: "Creates an item"},
{Method: http.MethodGet, Path: "/items", Description: "Lists a page of items"},
{Method: http.MethodGet, Path: "/items/:id", Description: "Returns a single item"},
{Method: http.MethodDelete, Path: "/items/:id", Description: "Deletes a single item"},
itemRoutes is a method on the public handler, so all four are on the public
server. The questions worth asking of each one, in order:
Who calls it? An end user’s application is public. Another service in your system is private. An operator or an automated probe is monitoring.
What does it reveal about a caller who is not the subject? GET /items returns everything in the table: correct for a catalogue, wrong the moment items belong to somebody. This is where “which listener” runs out of
answers: the fix is scoping the list to the caller, which requires knowing who
the caller is.
What is the blast radius if it is wrong? A read of one item is a data
exposure. A delete that anybody can call is data loss, and the example’s
DELETE /items/:id is the endpoint in this feature that would not survive
review on a public listener without an answer to who may call it. A bulk version
of it is an outage.
Could it be misused as a resource? An endpoint that triggers an expensive query, an outbound call or a report is a load amplifier, and needs the rate limiting from page 08 or a private listener.
The example does not have these, and they belong elsewhere:
// Private: a peer service reconciling its own stock counts.
{Method: http.MethodGet, Path: "/items/export", Description: "Bulk export for reconciliation"},
// Private: an administrative correction, not a product feature.
{Method: http.MethodPost, Path: "/items/:id/adjust", Description: "Adjusts a quantity"},
Both go on the private binder. Neither is something an end user’s application does, and both would be a liability on the public port.
That placement is a line in bind.go, not a middleware:
// internal/cli/bind.go
serviceBinderPrivate := httphandlerpriv.New(nil, l)
serviceBinderPublic := httphandlerpub.New(itemSvc, l)
The private handler is constructed with a nil service because the example has no private business logic. Moving an endpoint between listeners means moving a route from one handler’s method to the other’s, a diff a reviewer can see.
What the private listener still needs
“Private” is a name until the network agrees with it. In a default Kubernetes
cluster, a ClusterIP service is reachable by every pod, including every other
team’s workloads and anything that has been compromised. The port is not on the
internet and not restricted either.
What makes the name true:
A network policy that permits ingress to 8072 only from the pods that should reach it, by label selector. This is the load-bearing one, and its absence is the most common gap.
A service definition that does not expose it. The public port has an ingress
in front of it. The private and monitoring ports should have no ingress, no
NodePort and no load balancer.
Mutual TLS, where a service mesh provides it, so a caller proves identity rather than merely being on the network. This is the one that survives a compromised pod.
Page 19 writes the manifest.
The trusted proxy question from page 08 is a
boundary question here, and the depth differs per listener: one or two hops for
the public one behind an ingress and a CDN, zero for the private one reached
directly, which makes X-Forwarded-For there client-supplied and ignorable. One
depth for the whole process gets one of them wrong.
Where authentication attaches
The service does not implement authentication. The shape still matters when the implementation is elsewhere, so here is where it attaches, as middleware, per listener:
authMiddleware := func(args httpserver.MiddlewareArgs, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
identity, err := authenticate(r)
if err != nil {
w.Header().Set("WWW-Authenticate", `Bearer realm="items"`)
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
ctx := auth.NewContext(r.Context(), identity)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
Middleware rather than a call at the top of each handler, for the same reason the exposure boundary is a socket: a check repeated in twenty handlers goes missing from the twenty-first with nothing failing, where a middleware covers every route on the server, including the ones added later.
What it must put in the context
// The established identity, read by handlers and by the service layer.
type Identity struct {
Subject string // who the caller is
Scopes []string // what they are permitted to do
TenantID string // whose data they may see
}
The middleware establishes it. Handlers read it. Nothing below the handler layer parses a token.
TenantID is the field that answers the GET /items question from earlier.
With an identity in the context, the list endpoint scopes to the caller:
params, err := itemListParams(r.URL.Query())
if err != nil {
h.sendItemError(ctx, w, err)
return
}
// Scoping is applied after parsing and cannot be overridden by a query
// parameter, because the field is not in the filterable allowlist.
params.TenantID = auth.FromContext(ctx).TenantID
The scoping comes from the identity and no query parameter can change it,
because tenant_id is not in
page 16’s allowlist. That is the
allowlist doing security work rather than validation work.
Authorisation is not authentication
Authentication is who the caller is, and it belongs in middleware because it is uniform.
Authorisation is what they may do, and most of it is not uniform. “Any authenticated caller may reach this route” is a middleware concern. “This caller may delete this item because they recorded it” needs the item, so it belongs in the service layer where the item is loaded.
Scattering authorisation checks through handlers gives each one a place to be
forgotten. Service.Delete is the place to decide whether this caller may
remove that row, so every caller of that method gets the check, including the
queue consumer added next year.
When the edge does it
Behind a gateway that terminates authentication, the process receives a request with the identity already established, typically as a signed header or a verified JWT the gateway checked.
The middleware verifies the gateway’s assertion rather than the client’s credential, cheaper but dependent on a trust relationship with the gateway.
The service must not accept that header from anywhere else. This is
page 08’s client IP problem again with worse
consequences: a service that trusts X-Authenticated-User and is reachable
without going through the gateway has no authentication at all.
WWW-Authenticate and the credential flow move to the gateway.
What does not change is that the handler reads an identity from the context. Keeping that boundary means moving authentication in or out of the process is a change to one middleware.
The guide does not implement it
The reference service has none, so there is no real code to describe. The subject outgrows a page, and a partial treatment of authentication is worse than none. Terminating it at the edge is a legitimate answer many readers already have.
The starting points: the JWT article, Argon2id password hashing, checking passwords against Have I Been Pwned, and nurago.org’s security notes.
The checklist
For each endpoint you add:
- Which listener, and why.
- If public: is it rate limited, is its response scoped to the caller, and is its cost bounded.
- If private: is there a network policy that makes “private” true.
- If monitoring: does it reveal internals, and is the operator network actually separate.
- Which OpenAPI document it belongs in, the same decision written down.
That last one is why page 14 split the specification three ways: the document an endpoint appears in records the exposure decision, where a reviewer sees it in the diff.
Next: Tests That Would Have Caught It, which starts Part VI.