skip to content

A Go service answers 200 on /debug/pprof/ though its own code registers no such route — why?

level: seniorimportance: should knowfreq 48%

answer

  1. your code is not the only code registering
  2. package-level state, shared by the whole binary
  3. an init function ran before main
  4. a nil Handler is not an empty Handler
  5. the import may be two dependencies down

basics

~10 s

An imported package registered those routes on http.DefaultServeMux from its init — net/http/pprof does exactly that when blank-imported — and the server was started with a nil Handler, which means serve DefaultServeMux.

solid answer

~40 s

Two facts meet. First, `http.DefaultServeMux` is a package-level mux, and `http.Handle`/`http.HandleFunc` register on it — so **any** package linked into the binary can add routes from an `init` function, including a transitive dependency you never read. `net/http/pprof` registers `/debug/pprof/` and friends exactly that way when it is blank-imported; `expvar` registers `/debug/vars`. Second, starting a server with a nil `Handler` means "serve `DefaultServeMux`", so those registrations become live routes on your public listener. The fix is to stop serving the global mux at all: create `http.NewServeMux()`, register only your own routes on it, and set it as the server's `Handler`. The imported package still registers into `DefaultServeMux`, but nothing serves that mux any more. If you actually want profiling endpoints, mount them deliberately on a separate admin listener.

code

go · 6 lines
go
import _ "net/http/pprof" // its init registers /debug/pprof/* on http.DefaultServeMux

func serve(addr string) error {
	srv := &http.Server{Addr: addr} // Handler is nil, so DefaultServeMux is served
	return srv.ListenAndServe()
}

go deeper

for a junior

Know that http.Handle and http.HandleFunc write to a mux shared by the whole program, and that a server started with a nil handler serves that shared mux. Prefer creating your own mux from the start.

for a middle

Explain the mechanism end to end: an imported package's init registers on http.DefaultServeMux before main runs, and a nil Server.Handler selects it. Be able to name net/http/pprof and expvar as the standard library packages that do this.

for a senior

Diagnose it from the outside in: probe the routes, inspect the linked package graph rather than your own imports, and fix it by owning the mux. Then decide deliberately whether profiling belongs on a private admin listener rather than deleting the capability.

for a principal

Treat this as a fleet-wide default rather than one service's bug. Decide whether every service inherits a bootstrap that cannot reach the global mux, what the escape hatch is for teams that need profiling, and how you verify the exposure is gone rather than that the rule was followed.

## The two halves of the surprise ### Half one: registration is global `net/http` ships a package-level mux, `http.DefaultServeMux`, and a pair of package-level registration functions that write to it: ```go http.Handle("/x", h) // registers on http.DefaultServeMux http.HandleFunc("/y", f) // same mux ``` Because it is package-level state, **every package in the binary shares it**, and a package can register from an `init` function that runs before `main` without anyone calling into it. `net/http/pprof` is built on this: its documented usage is a blank import ```go import _ "net/http/pprof" ``` whose only effect is an `init` that registers `/debug/pprof/`, `/debug/pprof/cmdline`, `/debug/pprof/profile`, `/debug/pprof/symbol` and `/debug/pprof/trace` on `DefaultServeMux`. `expvar` does the same for `/debug/vars`, and it exposes the process command line and every published variable. Neither package asks whether the mux they are writing to is the one facing the internet. The import does not have to be yours. Any dependency — or a dependency of a dependency — that blank-imports `net/http/pprof` pulls those registrations into your binary. Removing the import from your own `main` package is not a fix if something below you still has it. ### Half two: a nil handler serves that mux ```go srv := &http.Server{Addr: cfg.Addr} // Handler left nil ``` A nil `Handler` on `http.Server` does not mean "serve nothing". It means `DefaultServeMux`. Same for `http.ListenAndServe(addr, nil)`. So the moment those two halves coexist — a package that registers globally, and a server that serves the global mux — the routes are live on the public port. ## Why that matters `/debug/pprof/profile` runs a CPU profile for a caller-controlled duration, which is a free way to slow your service down. `/debug/pprof/heap` and the goroutine profile leak internal structure: package paths, function names, goroutine stacks. `/debug/pprof/cmdline` prints the process arguments, which in plenty of services include things nobody meant to publish. `expvar`'s `/debug/vars` publishes the command line and memory statistics by default. None of this is a vulnerability in `net/http`; it is a diagnostic surface that landed on the wrong listener. ## The fix, and the one that only looks like a fix The fix is to **never serve the default mux**: ```go func routes() http.Handler { mux := http.NewServeMux() mux.HandleFunc("/healthz", healthz) return mux } srv := &http.Server{Addr: cfg.Addr, Handler: routes()} ``` The imported package's `init` still runs and still registers — into a mux nothing serves. The registrations are inert. Note that passing `http.DefaultServeMux` explicitly as the `Handler` is **not** a fix; it is the same global by a longer name. The risk is the shared mux, not the syntax that selects it. If profiling endpoints are something you actually want in production — and they are genuinely useful — mount them yourself rather than inheriting them. `net/http/pprof` exports its handlers: `pprof.Index`, `pprof.Cmdline`, `pprof.Profile`, `pprof.Symbol`, `pprof.Trace`, and `pprof.Handler(name)` for a named profile. Register those on a second mux served by a second `http.Server` on a listener bound to a private interface, or behind whatever authentication your platform already has. Then their exposure is a decision with an owner rather than a side effect of an import. ## Finding it before a scanner does - Hit the routes. `/debug/pprof/`, `/debug/vars` and `/debug/pprof/cmdline` answering 200 from outside is the whole finding. - Inspect the build's package graph rather than your own imports: `go list -deps ./...` prints every package that will be linked, so you can see `net/http/pprof` or `expvar` in there even when it arrives transitively. - Grep the codebase for `http.Handle`, `http.HandleFunc` and a nil `Handler`, and treat all three as defects in a service. - Do not expect `go vet` to catch this. Registering on a package-level mux is perfectly legal code; no analyser in the toolchain flags it by default. ## The shape of a good answer Name the mechanism precisely — an imported package's `init` writing to a process-wide mux, plus a nil `Handler` that selects that mux — rather than blaming configuration. Then give the fix in one sentence (own the mux, always set `Handler`) and the deliberate alternative (mount the profiling handlers on an admin listener). Mentioning that the import may be transitive is what separates a candidate who has actually chased this from one who has read about it.

  • The blank import comes from a transitive dependency. How do you find it?
    Look at the build's package graph rather than your own import block: go list -deps ./... prints every package that will be linked, so net/http/pprof or expvar shows up even when nothing in your repository imports it. From there, walk the importers to find which dependency pulled it in, and decide whether to raise it upstream or simply stop serving the default mux.
  • You do want profiling endpoints in production. How do you expose them without this happening by accident?
    Mount them yourself. net/http/pprof exports pprof.Index, pprof.Cmdline, pprof.Profile, pprof.Symbol, pprof.Trace and pprof.Handler(name), so register the ones you want on a private mux served by a second http.Server on an internal listener, or behind your platform's authentication. Exposure then belongs to a line of code with an owner instead of an import's side effect.
  • Does passing http.DefaultServeMux explicitly as the server's Handler make the problem go away?
    No. It is the same global mux, just named rather than defaulted to, so every route any package registered is still served. The risk is that the mux is process-wide, not the syntax that selects it. The fix is a mux you created with http.NewServeMux and registered only your own routes on.
  • Would go vet or the compiler have warned about this?
    No. Registering handlers on a package-level mux and leaving Server.Handler nil are both entirely legal, idiomatic-looking Go, and no analyser in the standard toolchain flags them by default. This is caught by probing the deployed service, by inspecting the linked package graph, or by a house lint rule you write yourself.

The default mux is a noticeboard in a shared hallway. Anyone in the building can pin something to it, and the moment you point the front door at that hallway, their notices are your public signage.

saying these in an interview costs you the question

  • Blames router configuration instead of a package init
  • Thinks only the main package can register routes
  • Believes the profiler adds the endpoints, not the import
  • Assumes a nil Handler means no routes are served
  • Says removing the import from main is always enough
  • Passes http.DefaultServeMux explicitly and calls it fixed
  • Expects go vet to flag globally registered handlers